Skip to main content

magi/
worker.rs

1//! Durable detached execution, independent of the scheduler and web lifetimes.
2//!
3//! A reservation is published before spawn, and the child must take its execution
4//! lease and acknowledge that reservation before dispatch. Reconciliation cancels
5//! an unacknowledged startup under the same store lock the child needs: a late
6//! child can never execute a cancelled operation. Process IDs are diagnostic only.
7//! Metadata publication closes its staging handle before atomic replacement.
8//! Windows replacement/delete-pending handoffs use the short, bounded lock-file
9//! retry policy; failed publication never releases an uncertain reservation.
10
11use std::fs::{File, OpenOptions};
12use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::{LazyLock, Mutex, OnceLock};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18use anyhow::{Context, Result, bail};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22const STARTUP_SECONDS: u64 = 30;
23const RETAIN_FINISHED: usize = 256;
24
25/// Durable execution state. Starting reservations count as occupied slots.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Status {
29    /// Published but not yet acknowledged by the detached child.
30    Starting,
31    /// The child acknowledged startup and owns the execution lease.
32    Running,
33    /// The handler returned successfully.
34    Finished,
35    /// Startup, handler execution, or the owning process failed.
36    Failed,
37}
38
39/// An explicit owner control; upgrades do not send these to task workers.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Control {
43    /// Park at the handler's existing safe cancellation boundary.
44    Park,
45    /// Stop the operation through its handler's cancellation path.
46    Stop,
47}
48
49#[derive(Debug)]
50struct NotStarted(anyhow::Error);
51
52impl std::fmt::Display for NotStarted {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(formatter, "{:#}", self.0)
55    }
56}
57
58impl std::error::Error for NotStarted {
59    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
60        Some(self.0.as_ref())
61    }
62}
63
64fn not_started_error(error: impl Into<anyhow::Error>) -> anyhow::Error {
65    anyhow::Error::new(NotStarted(error.into()))
66}
67
68/// Whether this spawn invocation provably did not execute its handler. This
69/// covers preparation failures and a failed OS spawn whose fresh reservation
70/// was exclusively leased and durably fenced before this error was returned.
71/// Only a fresh operation key makes this proof sufficient to restore a caller's
72/// publication. Unknown startup, existing work, or a failed fence is never tagged.
73pub fn not_started(error: &anyhow::Error) -> bool {
74    error.downcast_ref::<NotStarted>().is_some()
75}
76
77/// A durable operation record. Its payload belongs to the started generation.
78#[derive(Clone, Debug, Serialize, Deserialize)]
79pub struct Job {
80    /// Unique invocation identity.
81    pub id: String,
82    /// Durable total reservation order, independent of wall clock resolution.
83    #[serde(default)]
84    pub sequence: u64,
85    /// Dispatch handler, such as task, chat, scheduler, or service.
86    pub kind: String,
87    /// Exclusive logical operation identity, independent of executable generation.
88    pub key: String,
89    /// Handler-owned request.
90    pub payload: serde_json::Value,
91    /// SHA-256 identity of the immutable executable.
92    pub generation: String,
93    /// Version reported by the started executable, unknown before acknowledgement.
94    #[serde(default)]
95    pub version: Option<String>,
96    /// Last durable execution state.
97    pub status: Status,
98    /// Successful completion note or failure diagnostic.
99    pub result: Option<String>,
100    /// Diagnostic process ID, never used as an execution witness.
101    pub pid: Option<u32>,
102    /// Reservation publication time, seconds since the Unix epoch.
103    pub created_at: u64,
104    /// Terminal state publication time.
105    pub finished_at: Option<u64>,
106}
107
108/// An immutable, content-addressed executable.
109#[derive(Clone, Debug)]
110pub struct Generation {
111    /// SHA-256 digest of the executable bytes.
112    pub id: String,
113    /// Absolute path to the retained executable.
114    pub executable: PathBuf,
115}
116
117/// OS-held lock. Closing it, including on a crash, releases ownership.
118#[derive(Debug)]
119pub struct Lease {
120    _file: File,
121}
122
123impl Drop for Lease {
124    fn drop(&mut self) {
125        // flock's lock belongs to the open-file description; explicitly unlock
126        // so a temporarily inherited descriptor cannot prolong ownership.
127        #[cfg(unix)]
128        {
129            use std::os::fd::AsRawFd;
130            unsafe { libc::flock(self._file.as_raw_fd(), libc::LOCK_UN) };
131        }
132    }
133}
134
135fn root(home: &Path) -> PathBuf {
136    home.join("workers")
137}
138fn now() -> u64 {
139    SystemTime::now()
140        .duration_since(UNIX_EPOCH)
141        .unwrap_or_default()
142        .as_secs()
143}
144fn nonce() -> String {
145    crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4()
146}
147fn valid_id(id: &str) -> bool {
148    !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
149}
150fn job_dir(home: &Path, id: &str) -> Result<PathBuf> {
151    if !valid_id(id) {
152        bail!("invalid worker id");
153    }
154    Ok(root(home).join("jobs").join(id))
155}
156
157fn open_lock(path: &Path) -> Result<File> {
158    if let Some(parent) = path.parent() {
159        std::fs::create_dir_all(parent)
160            .with_context(|| format!("create worker lock directory {}", parent.display()))?;
161    }
162    OpenOptions::new()
163        .create(true)
164        .truncate(false)
165        .read(true)
166        .write(true)
167        .open(path)
168        .with_context(|| format!("open worker lock {}", path.display()))
169}
170
171fn try_lock(path: &Path) -> Result<Option<Lease>> {
172    let file = open_lock(path)?;
173    match crate::pr_head::try_lock_file(&file) {
174        Ok(()) => Ok(Some(Lease { _file: file })),
175        Err(e)
176            if e.kind() == std::io::ErrorKind::WouldBlock
177                || matches!(e.raw_os_error(), Some(33)) =>
178        {
179            Ok(None)
180        }
181        Err(e) => Err(e).with_context(|| format!("acquire worker lock {}", path.display())),
182    }
183}
184
185#[cfg(test)]
186pub(crate) fn execution_lease_for_test(path: &Path) -> Lease {
187    try_lock(path)
188        .expect("execution lease")
189        .expect("free execution lease")
190}
191
192/// Try to own a named cross-process singleton, without PID-based stale handling.
193/// Names are hashed, so callers may use logical keys containing paths or colons.
194pub fn try_lease(home: &Path, name: &str) -> Result<Option<Lease>> {
195    try_lock(
196        &root(home)
197            .join("leases")
198            .join(format!("{}.lock", digest(name.as_bytes()))),
199    )
200}
201
202fn store_lock(home: &Path) -> Result<Lease> {
203    let deadline = std::time::Instant::now() + Duration::from_secs(10);
204    loop {
205        if let Some(lease) = try_lock(&root(home).join("store.lock"))? {
206            return Ok(lease);
207        }
208        if std::time::Instant::now() >= deadline {
209            bail!("worker store is busy; retry later");
210        }
211        std::thread::sleep(Duration::from_millis(5));
212    }
213}
214
215#[cfg(unix)]
216fn sync_dir(path: &Path) -> Result<()> {
217    File::open(path)
218        .with_context(|| format!("open worker directory for sync {}", path.display()))?
219        .sync_all()
220        .with_context(|| format!("sync worker directory {}", path.display()))?;
221    Ok(())
222}
223
224#[cfg(not(unix))]
225fn sync_dir(_path: &Path) -> Result<()> {
226    Ok(())
227}
228
229fn save(path: &Path, value: &impl Serialize) -> Result<()> {
230    let parent = path.parent().context("worker record has no parent")?;
231    std::fs::create_dir_all(parent)
232        .with_context(|| format!("create worker record directory {}", parent.display()))?;
233    let temp = parent.join(format!(".pending-{}", nonce()));
234    let result = (|| -> Result<()> {
235        let mut file = OpenOptions::new()
236            .create_new(true)
237            .write(true)
238            .open(&temp)
239            .with_context(|| format!("create staged worker record {}", temp.display()))?;
240        serde_json::to_writer(&mut file, value)
241            .with_context(|| format!("serialize worker record {}", path.display()))?;
242        file.flush()
243            .with_context(|| format!("flush staged worker record {}", temp.display()))?;
244        file.sync_all()
245            .with_context(|| format!("sync staged worker record {}", temp.display()))?;
246        // Publication must not retain our own writable staging handle. Windows
247        // readers may briefly deny deletion; only the atomic handoff retries.
248        drop(file);
249        replace(&temp, path).with_context(|| {
250            format!(
251                "publish worker record {} -> {}",
252                temp.display(),
253                path.display()
254            )
255        })?;
256        sync_dir(parent)
257    })();
258    if result.is_err() {
259        let _ = std::fs::remove_file(&temp);
260    }
261    result
262}
263
264#[cfg(not(windows))]
265fn replace(from: &Path, to: &Path) -> Result<()> {
266    Ok(std::fs::rename(from, to)?)
267}
268
269#[cfg(windows)]
270fn replace(from: &Path, to: &Path) -> Result<()> {
271    // std uses FileRenameInfoEx/POSIX semantics where supported, unlike
272    // MoveFileExW: a FILE_SHARE_DELETE reader may retain the previous record
273    // while the replacement becomes visible atomically. Readers that briefly
274    // deny deletion still get only the existing short Windows handoff budget.
275    crate::lock_file::Retry::new(None).run(|| std::fs::rename(from, to))?;
276    Ok(())
277}
278
279// Atomic replacement can leave the old Windows name briefly delete-pending.
280// Keep that handoff bounded; malformed records and permanent IO errors propagate.
281fn open_record(path: &Path) -> std::io::Result<File> {
282    #[cfg(windows)]
283    {
284        crate::lock_file::Retry::new(None).run(|| File::open(path))
285    }
286    #[cfg(not(windows))]
287    {
288        File::open(path)
289    }
290}
291
292/// Load a specific invocation, including a retained terminal result.
293pub fn get(home: &Path, id: &str) -> Result<Job> {
294    let path = job_dir(home, id)?.join("job.json");
295    let file = match open_record(&path) {
296        Ok(file) => file,
297        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
298            let retained = root(home).join("results").join(id).join("job.json");
299            open_record(&retained)
300                .with_context(|| format!("open retained worker record {}", retained.display()))?
301        }
302        Err(error) => {
303            return Err(error).with_context(|| format!("open worker record {}", path.display()));
304        }
305    };
306    serde_json::from_reader(file).with_context(|| format!("decode worker record {id}"))
307}
308
309/// Identify this detached invocation. Helpers inherit its identity, while a
310/// newly spawned worker receives its own id before exec; no global environment
311/// mutation is required inside the asynchronous handler runtime.
312pub fn current_job(home: &Path) -> Result<Option<Job>> {
313    match std::env::var("MAGI_WORKER_ID") {
314        Ok(id) => Ok(Some(get(home, &id)?)),
315        Err(std::env::VarError::NotPresent) => Ok(None),
316        Err(error) => Err(error.into()),
317    }
318}
319
320fn next_sequence(home: &Path) -> Result<u64> {
321    let path = root(home).join("sequence.json");
322    let previous: u64 = match open_record(&path) {
323        Ok(file) => serde_json::from_reader(file)
324            .with_context(|| format!("decode worker sequence {}", path.display()))?,
325        Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
326        Err(error) => {
327            return Err(error).with_context(|| format!("open worker sequence {}", path.display()));
328        }
329    };
330    let sequence = previous
331        .checked_add(1)
332        .context("worker reservation sequence exhausted")?;
333    save(&path, &sequence)?;
334    Ok(sequence)
335}
336
337fn records(home: &Path) -> Result<Vec<Job>> {
338    let directory = root(home).join("jobs");
339    std::fs::create_dir_all(&directory)?;
340    let mut jobs = Vec::new();
341    let mut retired = false;
342    for entry in std::fs::read_dir(directory)? {
343        let entry = entry?;
344        if !entry.file_type()?.is_dir() {
345            continue;
346        }
347        let name = entry.file_name();
348        let Some(id) = name.to_str() else {
349            continue;
350        };
351        if id.starts_with(".new-") {
352            // Reservations are staged only while holding the store lock. Any
353            // staging directory visible to the next owner was left by a crash.
354            std::fs::remove_dir_all(entry.path())?;
355            continue;
356        }
357        if id.starts_with('.') {
358            continue;
359        }
360        for pending in std::fs::read_dir(entry.path())? {
361            let pending = pending?;
362            if pending.file_type()?.is_file()
363                && pending
364                    .file_name()
365                    .to_string_lossy()
366                    .starts_with(".pending-")
367            {
368                std::fs::remove_file(pending.path())?;
369            }
370        }
371        let job = get(home, id).with_context(|| format!("read worker {id}"))?;
372        if matches!(job.status, Status::Starting | Status::Running) {
373            jobs.push(job);
374        } else {
375            retired |= retire(home, &job);
376        }
377    }
378    if retired {
379        if let Err(error) = prune_results(home) {
380            tracing::warn!(%error, "worker result retention deferred");
381        }
382    }
383    Ok(jobs)
384}
385
386fn terminal(home: &Path, job: &mut Job, status: Status, result: String) -> Result<()> {
387    job.status = status;
388    job.result = Some(result);
389    job.finished_at = Some(now());
390    save(&job_dir(home, &job.id)?.join("job.json"), job)
391        .with_context(|| format!("publish worker {} terminal status {status:?}", job.id))?;
392    // The result is authoritative now. Keep the directory in place while this
393    // process owns live.lock and its stdout/stderr handles. Another store owner
394    // retires it opportunistically after execution ownership is released.
395    clear_control_locked(home, &job.key)
396}
397
398fn retire(home: &Path, job: &Job) -> bool {
399    let attempt = || -> Result<bool> {
400        let dir = job_dir(home, &job.id)?;
401        let Some(lease) = try_lock(&dir.join("live.lock"))? else {
402            return Ok(false);
403        };
404        drop(lease);
405        archive(home, job)?;
406        Ok(true)
407    };
408    // Windows can still deny the directory rename in the short interval after
409    // lease release but before process stdout/stderr are closed. Retry on the
410    // next store read; archival failure must never turn completion into failure
411    // or terminate an otherwise healthy scheduler.
412    match attempt() {
413        Ok(retired) => retired,
414        Err(error) => {
415            tracing::warn!(
416                worker = %job.id,
417                error = %format_args!("{error:#}"),
418                "worker result retirement deferred"
419            );
420            false
421        }
422    }
423}
424
425fn archive(home: &Path, job: &Job) -> Result<()> {
426    let results = root(home).join("results");
427    std::fs::create_dir_all(&results)
428        .with_context(|| format!("create worker results directory {}", results.display()))?;
429    let from = job_dir(home, &job.id)?;
430    let to = results.join(&job.id);
431    std::fs::rename(&from, &to).with_context(|| {
432        format!(
433            "archive worker result {} -> {}",
434            from.display(),
435            to.display()
436        )
437    })?;
438    sync_dir(&root(home).join("jobs"))?;
439    sync_dir(&results)
440}
441
442// Caller owns the store lock. Acquiring the execution lease and then marking a
443// dead startup terminal fences a late child: it must recheck state under store.
444fn reconcile(home: &Path, jobs: &mut [Job]) -> Result<()> {
445    let mut retired = false;
446    for job in jobs {
447        if !matches!(job.status, Status::Starting | Status::Running) {
448            continue;
449        }
450        let lease = try_lock(&job_dir(home, &job.id)?.join("live.lock"))?;
451        if lease.is_none() {
452            continue;
453        }
454        if job.status == Status::Starting && now().saturating_sub(job.created_at) < STARTUP_SECONDS
455        {
456            continue;
457        }
458        let reason = if job.status == Status::Starting {
459            "worker startup reservation expired before acknowledgement"
460        } else {
461            "worker exited without recording a result"
462        };
463        terminal(home, job, Status::Failed, reason.to_owned())?;
464        drop(lease);
465        retired |= retire(home, job);
466    }
467    if retired {
468        if let Err(error) = prune_results(home) {
469            tracing::warn!(%error, "worker result retention deferred");
470        }
471    }
472    Ok(())
473}
474
475/// List live operations and unresolved startups. A missing lease only frees a
476/// startup after its deadline and an atomic cancellation fence; PID is irrelevant.
477pub fn active(home: &Path) -> Result<Vec<Job>> {
478    let _store = store_lock(home)?;
479    let mut jobs = records(home)?;
480    reconcile(home, &mut jobs)?;
481    Ok(jobs
482        .into_iter()
483        .filter(|job| matches!(job.status, Status::Starting | Status::Running))
484        .collect())
485}
486
487/// Observe execution without creating, repairing, archiving, or locking the
488/// writable store. Sandboxed diagnostics need only file reads and a read-only
489/// handle for the OS lease probe. Starting reservations remain protected until
490/// an authorized writer fences them; a missing running lease is unknown.
491pub fn observe_active(home: &Path) -> Result<Vec<Job>> {
492    let entries = match std::fs::read_dir(root(home).join("jobs")) {
493        Ok(entries) => entries,
494        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
495        Err(error) => return Err(error.into()),
496    };
497    let mut active = Vec::new();
498    for entry in entries {
499        let entry = entry?;
500        if !entry.file_type()?.is_dir() {
501            continue;
502        }
503        let name = entry.file_name();
504        let id = name.to_str().context("invalid worker reservation name")?;
505        if id.starts_with('.') {
506            continue;
507        }
508        let job = get(home, id)?;
509        match job.status {
510            Status::Starting => active.push(job),
511            Status::Running => {
512                let file = match File::open(entry.path().join("live.lock")) {
513                    Ok(file) => file,
514                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
515                        active.push(job);
516                        continue;
517                    }
518                    Err(error) => return Err(error.into()),
519                };
520                match crate::pr_head::try_lock_file(&file) {
521                    Ok(()) => drop(Lease { _file: file }),
522                    Err(error)
523                        if error.kind() == std::io::ErrorKind::WouldBlock
524                            || cfg!(windows) && error.raw_os_error() == Some(33) =>
525                    {
526                        active.push(job);
527                    }
528                    Err(error) => return Err(error.into()),
529                }
530            }
531            Status::Finished | Status::Failed => {}
532        }
533    }
534    Ok(active)
535}
536
537fn control_path(home: &Path, key: &str) -> PathBuf {
538    root(home)
539        .join("controls")
540        .join(format!("{}.json", digest(key.as_bytes())))
541}
542
543/// Persist a stop or park request; the operation's handler consumes it safely.
544pub fn request(home: &Path, key: &str, control: Control) -> Result<()> {
545    let _store = store_lock(home)?;
546    save(&control_path(home, key), &control)
547}
548
549/// Read the latest durable owner request for a logical operation.
550pub fn control(home: &Path, key: &str) -> Result<Option<Control>> {
551    let path = control_path(home, key);
552    match open_record(&path) {
553        Ok(file) => serde_json::from_reader(file)
554            .map(Some)
555            .with_context(|| format!("decode worker control {}", path.display())),
556        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
557        Err(e) => Err(e).with_context(|| format!("open worker control {}", path.display())),
558    }
559}
560
561fn clear_control_locked(home: &Path, key: &str) -> Result<()> {
562    let path = control_path(home, key);
563    match std::fs::remove_file(&path) {
564        Ok(()) => sync_dir(&root(home).join("controls")),
565        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
566        Err(e) => Err(e).with_context(|| format!("remove worker control {}", path.display())),
567    }
568}
569
570/// Clear controls at an explicitly owned new singleton invocation boundary.
571pub fn clear_control(home: &Path, key: &str) -> Result<()> {
572    let _store = store_lock(home)?;
573    clear_control_locked(home, key)
574}
575
576/// Resolve a retained executable strictly by its content identity.
577pub fn executable(home: &Path, generation: &str) -> Result<PathBuf> {
578    if generation.len() != 64 || !generation.bytes().all(|b| b.is_ascii_hexdigit()) {
579        bail!("invalid worker generation");
580    }
581    let path = root(home)
582        .join("generations")
583        .join(generation)
584        .join(if cfg!(windows) { "magi.exe" } else { "magi" });
585    if !path.is_file() {
586        bail!("worker generation {generation} is unavailable");
587    }
588    Ok(path)
589}
590
591/// Resolve helper invocations to this worker's started executable, not a newly
592/// installed binary. Outside a worker, pin the caller's current executable.
593pub fn helper_executable(home: &Path) -> Result<PathBuf> {
594    if let Some(generation) = std::env::var_os("MAGI_WORKER_GENERATION") {
595        return executable(
596            home,
597            generation
598                .to_str()
599                .context("invalid generation environment")?,
600        );
601    }
602    Ok(pin_current(home)?.executable)
603}
604
605/// Remember the installed executable pathname before an updater replaces it.
606/// A web process calls this at startup so Linux's deleted-executable marker or
607/// Windows's backup rename cannot redirect successor workers to the old binary.
608pub fn remember_current_executable() -> Result<()> {
609    if INSTALLED_EXECUTABLE.get().is_some() {
610        return Ok(());
611    }
612    let path = std::env::current_exe()?.canonicalize()?;
613    let _ = INSTALLED_EXECUTABLE.set(path);
614    Ok(())
615}
616
617static INSTALLED_EXECUTABLE: OnceLock<PathBuf> = OnceLock::new();
618
619/// Copy and SHA-256 address the executable. MAGI_WORKER_EXE is an explicit
620/// embedding/test override; it is never an in-process execution fallback.
621pub fn pin_current(home: &Path) -> Result<Generation> {
622    let source = match std::env::var_os("MAGI_WORKER_EXE") {
623        Some(path) => PathBuf::from(path),
624        None => match INSTALLED_EXECUTABLE.get() {
625            Some(path) => path.clone(),
626            None => std::env::current_exe()?,
627        },
628    };
629    let source = source.canonicalize().context("resolve worker executable")?;
630    // A worker already runs the immutable copy. Do not copy/hash a large binary
631    // again for every finite maintenance invocation in the same generation.
632    if let Some(directory) = source.parent() {
633        if directory.parent() == Some(root(home).join("generations").as_path()) {
634            if let Some(id) = directory.file_name().and_then(|name| name.to_str()) {
635                if id.len() == 64
636                    && id.bytes().all(|b| b.is_ascii_hexdigit())
637                    && executable(home, id)?.canonicalize()? == source
638                {
639                    return Ok(Generation {
640                        id: id.to_owned(),
641                        executable: source,
642                    });
643                }
644            }
645        }
646    }
647    let stamp = source_stamp(&source)?;
648    let mut cache = PIN_CACHE
649        .lock()
650        .map_err(|_| anyhow::anyhow!("worker generation cache poisoned"))?;
651    if let Some(entry) = cache.iter().find(|entry| {
652        entry.home == home
653            && entry.source == source
654            && entry.stamp == stamp
655            && entry.generation.executable.is_file()
656    }) {
657        return Ok(entry.generation.clone());
658    }
659    let generation = pin_source(home, &source)?;
660    if source_stamp(&source)? != stamp {
661        bail!("worker executable changed while being pinned; retry");
662    }
663    if cache.len() >= 8 {
664        cache.remove(0);
665    }
666    cache.push(CachedGeneration {
667        home: home.to_path_buf(),
668        source,
669        stamp,
670        generation: generation.clone(),
671    });
672    Ok(generation)
673}
674
675type SourceStamp = (u64, Option<SystemTime>, Option<SystemTime>, u64, u64);
676struct CachedGeneration {
677    home: PathBuf,
678    source: PathBuf,
679    stamp: SourceStamp,
680    generation: Generation,
681}
682static PIN_CACHE: LazyLock<Mutex<Vec<CachedGeneration>>> = LazyLock::new(|| Mutex::new(Vec::new()));
683
684fn source_stamp(path: &Path) -> Result<SourceStamp> {
685    let metadata = std::fs::metadata(path)?;
686    #[cfg(unix)]
687    let identity = {
688        use std::os::unix::fs::MetadataExt;
689        (metadata.dev(), metadata.ino())
690    };
691    #[cfg(not(unix))]
692    let identity = (0, 0);
693    Ok((
694        metadata.len(),
695        metadata.modified().ok(),
696        metadata.created().ok(),
697        identity.0,
698        identity.1,
699    ))
700}
701
702fn pin_source(home: &Path, source: &Path) -> Result<Generation> {
703    let generations = root(home).join("generations");
704    std::fs::create_dir_all(&generations)?;
705    let staging = generations.join(format!(".new-{}", nonce()));
706    let mut staging_lease = {
707        let _store = store_lock(home)?;
708        // Only unpublished copies are reclaimed, never executable generations.
709        for entry in std::fs::read_dir(&generations)? {
710            let entry = entry?;
711            if entry.file_type()?.is_dir()
712                && entry.file_name().to_string_lossy().starts_with(".new-")
713            {
714                if let Some(lease) = try_lock(&entry.path().join("pin.lock"))? {
715                    drop(lease);
716                    remove_staging(&entry.path())?;
717                }
718            }
719        }
720        std::fs::create_dir(&staging)?;
721        Some(try_lock(&staging.join("pin.lock"))?.context("generation staging lease unavailable")?)
722    };
723    let temp = staging.join(if cfg!(windows) { "magi.exe" } else { "magi" });
724    let result = (|| -> Result<Generation> {
725        let mut input = File::open(source)?;
726        let mut output = OpenOptions::new()
727            .create_new(true)
728            .write(true)
729            .open(&temp)?;
730        let mut hash = Sha256::new();
731        let mut buffer = [0u8; 65536];
732        loop {
733            let n = input.read(&mut buffer)?;
734            if n == 0 {
735                break;
736            }
737            hash.update(&buffer[..n]);
738            output.write_all(&buffer[..n])?;
739        }
740        let id = hex_digest(hash);
741        let mut permissions = input.metadata()?.permissions();
742        permissions.set_readonly(true);
743        output.set_permissions(permissions)?;
744        output.sync_all()?;
745        drop(output);
746        sync_dir(&staging)?;
747        let target = generations.join(&id);
748        // The store lock serializes publication with retention and other pins.
749        let _store = store_lock(home)?;
750        drop(staging_lease.take());
751        std::fs::remove_file(staging.join("pin.lock"))?;
752        if target.exists() {
753            let existing = target.join(temp.file_name().context("binary filename")?);
754            if hash_file(&existing)? != id {
755                bail!("immutable worker generation is corrupt: {id}");
756            }
757        } else {
758            std::fs::rename(&staging, &target)?;
759            sync_dir(&generations)?;
760        }
761        Ok(Generation {
762            executable: target.join(temp.file_name().context("binary filename")?),
763            id,
764        })
765    })();
766    let _store = store_lock(home)?;
767    drop(staging_lease);
768    if staging.exists() {
769        remove_staging(&staging)?;
770    }
771    result
772}
773
774fn remove_staging(staging: &Path) -> Result<()> {
775    #[cfg(windows)]
776    {
777        use std::os::windows::{ffi::OsStrExt, fs::MetadataExt};
778        #[link(name = "kernel32")]
779        unsafe extern "system" {
780            fn SetFileAttributesW(path: *const u16, attributes: u32) -> i32;
781        }
782        const READONLY: u32 = 0x1;
783        for entry in std::fs::read_dir(staging)? {
784            let entry = entry?;
785            let attributes = entry.metadata()?.file_attributes();
786            if attributes & READONLY != 0 {
787                let path: Vec<u16> = entry.path().as_os_str().encode_wide().chain([0]).collect();
788                // SAFETY: path is a live, NUL-terminated UTF-16 buffer. Clear
789                // only the Windows readonly attribute, preserving other bits.
790                if unsafe { SetFileAttributesW(path.as_ptr(), attributes & !READONLY) } == 0 {
791                    return Err(std::io::Error::last_os_error())
792                        .context("clear staging readonly attribute");
793                }
794            }
795        }
796    }
797    Ok(std::fs::remove_dir_all(staging)?)
798}
799
800fn hash_file(path: &Path) -> Result<String> {
801    let mut file = File::open(path)?;
802    let mut hash = Sha256::new();
803    let mut buffer = [0u8; 65536];
804    loop {
805        let n = file.read(&mut buffer)?;
806        if n == 0 {
807            break;
808        }
809        hash.update(&buffer[..n]);
810    }
811    Ok(hex_digest(hash))
812}
813
814fn reserve(
815    home: &Path,
816    generation: &Generation,
817    kind: &str,
818    key: &str,
819    payload: serde_json::Value,
820) -> Result<(Job, bool)> {
821    if kind.is_empty() || key.is_empty() {
822        bail!("worker kind and operation key must be nonempty");
823    }
824    let _store = store_lock(home)?;
825    let mut jobs = records(home)?;
826    reconcile(home, &mut jobs)?;
827    if let Some(job) = jobs
828        .iter()
829        .find(|job| job.key == key && matches!(job.status, Status::Starting | Status::Running))
830    {
831        if job.kind != kind || job.payload != payload {
832            bail!("worker operation key {key} is already reserved for a different request");
833        }
834        return Ok((job.clone(), false));
835    }
836    let id = nonce();
837    let job = Job {
838        id: id.clone(),
839        sequence: next_sequence(home)?,
840        kind: kind.to_owned(),
841        key: key.to_owned(),
842        payload,
843        generation: generation.id.clone(),
844        version: None,
845        status: Status::Starting,
846        result: None,
847        pid: None,
848        created_at: now(),
849        finished_at: None,
850    };
851    let jobs_dir = root(home).join("jobs");
852    let staging = jobs_dir.join(format!(".new-{id}"));
853    std::fs::create_dir(&staging)
854        .with_context(|| format!("create worker reservation staging {}", staging.display()))?;
855    save(&staging.join("job.json"), &job)?;
856    clear_control_locked(home, key)?;
857    std::fs::rename(&staging, job_dir(home, &id)?)
858        .with_context(|| format!("publish worker {id} reservation from {}", staging.display()))?;
859    sync_dir(&jobs_dir)?;
860    Ok((job, true))
861}
862
863fn prune_results(home: &Path) -> Result<()> {
864    let results = root(home).join("results");
865    std::fs::create_dir_all(&results)?;
866    let mut entries = Vec::new();
867    for entry in std::fs::read_dir(&results)? {
868        let entry = entry?;
869        if entry.file_type()?.is_dir() {
870            // Directory mtime changes when the terminal job.json is published.
871            // Retention needs no deserialization of old handler/config payloads.
872            entries.push((entry.metadata()?.modified()?, entry.path()));
873        }
874    }
875    entries.sort_unstable_by_key(|(modified, _)| std::cmp::Reverse(*modified));
876    for (_, dir) in entries.into_iter().skip(RETAIN_FINISHED) {
877        if let Some(lease) = try_lock(&dir.join("live.lock"))? {
878            drop(lease);
879            std::fs::remove_dir_all(dir)?;
880        }
881    }
882    sync_dir(&results)
883}
884
885fn detached_command(home: &Path, job: &Job, generation: &Generation) -> Result<Command> {
886    // Open before exec so argument parsing, startup panics, and task progress
887    // remain diagnosable even before the child can publish its acknowledgement.
888    // Archiving/pruning the reservation also archives/prunes its log.
889    let log_path = job_dir(home, &job.id)?.join("worker.log");
890    let log = OpenOptions::new()
891        .create(true)
892        .append(true)
893        .open(&log_path)
894        .with_context(|| format!("open worker startup log {}", log_path.display()))?;
895    let stderr = log
896        .try_clone()
897        .with_context(|| format!("clone worker startup log {}", log_path.display()))?;
898    let mut command = Command::new(&generation.executable);
899    command
900        .arg("__worker")
901        .arg("--home")
902        .arg(home)
903        .arg(&job.id)
904        .env("MAGI_HOME", home)
905        .env("MAGI_WORKER_HOME", home)
906        .env("MAGI_WORKER_ID", &job.id)
907        .env("MAGI_WORKER_GENERATION", &generation.id)
908        .env("MAGI_WORKER_EXE", &generation.executable)
909        .env_remove("MAGI_RUN")
910        .env_remove("MAGI_NODE")
911        .env_remove("MAGI_SEAT")
912        .env_remove("MAGI_TURN_SCOPE")
913        .stdin(Stdio::null())
914        .stdout(Stdio::from(log))
915        .stderr(Stdio::from(stderr));
916    let mut paths = vec![
917        generation
918            .executable
919            .parent()
920            .context("generation directory")?
921            .to_path_buf(),
922    ];
923    if let Some(path) = std::env::var_os("PATH") {
924        paths.extend(std::env::split_paths(&path));
925    }
926    command.env("PATH", std::env::join_paths(paths)?);
927    #[cfg(unix)]
928    {
929        use std::os::unix::process::CommandExt;
930        unsafe {
931            command.pre_exec(|| {
932                if libc::setsid() < 0 {
933                    return Err(std::io::Error::last_os_error());
934                }
935                // A scheduler's Ctrl-C handling must not be inherited as an
936                // ignored shutdown signal by its independent worker session.
937                libc::signal(libc::SIGINT, libc::SIG_DFL);
938                libc::signal(libc::SIGTERM, libc::SIG_DFL);
939                Ok(())
940            });
941        }
942    }
943    #[cfg(windows)]
944    {
945        use std::os::windows::process::CommandExt;
946        command.creation_flags(
947            crate::chat_watch::windows::detached_flags()
948                .context("prepare independent Windows worker")?,
949        );
950    }
951    Ok(command)
952}
953
954/// Reserve an exclusive operation and detach its immutable executable. Returns
955/// after acknowledgement; a timeout preserves the reservation and never kills
956/// the child or silently executes in the caller. Repeated identical requests
957/// attach to the existing reservation rather than dispatching twice.
958pub async fn spawn(home: &Path, kind: &str, key: &str, payload: serde_json::Value) -> Result<Job> {
959    std::fs::create_dir_all(home).map_err(not_started_error)?;
960    let home = home
961        .canonicalize()
962        .context("resolve worker home")
963        .map_err(not_started_error)?;
964    let pin_home = home.clone();
965    let generation = tokio::task::spawn_blocking(move || pin_current(&pin_home))
966        .await
967        .map_err(not_started_error)?
968        .map_err(not_started_error)?;
969    let (job, fresh) = reserve(&home, &generation, kind, key, payload)?;
970    if fresh {
971        match detached_command(&home, &job, &generation)
972            .and_then(|mut command| Ok(command.spawn()?))
973        {
974            Ok(mut child) => {
975                // std::process::Child has no kill-on-drop. The reaper is only
976                // bookkeeping; runtime or parent exit cannot terminate a worker.
977                std::thread::spawn(move || {
978                    let _ = child.wait();
979                });
980            }
981            Err(error) => {
982                let _store = store_lock(&home)?;
983                let mut job = get(&home, &job.id)?;
984                if job.status == Status::Starting {
985                    if let Some(_lease) = try_lock(&job_dir(&home, &job.id)?.join("live.lock"))? {
986                        terminal(
987                            &home,
988                            &mut job,
989                            Status::Failed,
990                            format!("worker spawn failed: {error}"),
991                        )?;
992                        return Err(not_started_error(error));
993                    }
994                }
995                return Err(error);
996            }
997        }
998    }
999    let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
1000    loop {
1001        let current = get(&home, &job.id)?;
1002        match current.status {
1003            Status::Running | Status::Finished => return Ok(current),
1004            Status::Failed => bail!(
1005                "worker {} failed: {}",
1006                current.id,
1007                current.result.as_deref().unwrap_or("unknown failure")
1008            ),
1009            Status::Starting => {}
1010        }
1011        if tokio::time::Instant::now() >= deadline {
1012            bail!(
1013                "worker {} startup is unacknowledged; its reservation remains occupied; inspect or retry the same operation key",
1014                job.id
1015            );
1016        }
1017        tokio::time::sleep(Duration::from_millis(20)).await;
1018    }
1019}
1020
1021/// Hidden detached entrypoint. The lease is acquired before acknowledgement and
1022/// is retained through handler result persistence. Only the recorded generation
1023/// can acknowledge a reservation, even if a newer CLI is accidentally invoked.
1024pub async fn run(home: &Path, id: &str) -> Result<()> {
1025    let dir = job_dir(home, id)?;
1026    let (job, _lease) = {
1027        let _store = store_lock(home)?;
1028        let mut job = get(home, id)?;
1029        if job.status != Status::Starting {
1030            bail!("worker reservation is no longer startable");
1031        }
1032        let lease =
1033            try_lock(&dir.join("live.lock"))?.context("worker execution lease is already held")?;
1034        let current = std::env::current_exe()
1035            .context("locate running worker executable")?
1036            .canonicalize()
1037            .context("resolve running worker executable")?;
1038        let expected = executable(home, &job.generation)?
1039            .canonicalize()
1040            .with_context(|| {
1041                format!("resolve worker {id} reserved generation {}", job.generation)
1042            })?;
1043        if current != expected {
1044            bail!("worker must execute its reserved immutable generation");
1045        }
1046        job.pid = Some(std::process::id());
1047        job.version = Some(env!("CARGO_PKG_VERSION").to_owned());
1048        job.status = Status::Running;
1049        save(&dir.join("job.json"), &job)
1050            .with_context(|| format!("acknowledge worker {id} startup"))?;
1051        (job, lease)
1052    };
1053    let result = match job.kind.as_str() {
1054        "task" => crate::daemon::run_task_worker(home, job.payload.clone()).await,
1055        "scheduler" => crate::daemon::run_scheduler_worker(home, job.payload.clone()).await,
1056        "chat" => crate::consult::run_turn_worker(home, job.payload.clone()).await,
1057        "service" => crate::consult::run_service_worker(home, job.payload.clone()).await,
1058        "daemon-service" => crate::daemon::run_service_worker(home, job.payload.clone()).await,
1059        "deputy" => crate::deputy::run_worker(home, job.payload.clone()).await,
1060        kind => Err(anyhow::anyhow!("unknown worker kind {kind}")),
1061    };
1062    let _store = store_lock(home)?;
1063    let mut current = get(home, id)?;
1064    match &result {
1065        Ok(()) => terminal(home, &mut current, Status::Finished, "completed".to_owned())?,
1066        Err(error) => terminal(home, &mut current, Status::Failed, format!("{error:#}"))?,
1067    }
1068    result
1069}
1070
1071fn hex_digest(hash: Sha256) -> String {
1072    format!("{:x}", hash.finalize())
1073}
1074fn digest(bytes: &[u8]) -> String {
1075    let mut hash = Sha256::new();
1076    hash.update(bytes);
1077    hex_digest(hash)
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083
1084    #[test]
1085    fn observing_an_unused_store_requires_no_writes() {
1086        let home = tempfile::tempdir().unwrap();
1087        assert!(observe_active(home.path()).unwrap().is_empty());
1088        assert!(!home.path().join("workers").exists());
1089    }
1090
1091    #[test]
1092    fn observing_execution_probes_the_lease_without_rewriting_history() {
1093        let home = tempfile::tempdir().unwrap();
1094        let mut job = reservation(home.path(), "task:observer");
1095        let directory = job_dir(home.path(), &job.id).unwrap();
1096        let live_path = directory.join("live.lock");
1097        let lease = try_lock(&live_path).unwrap().unwrap();
1098        job.status = Status::Running;
1099        save(&directory.join("job.json"), &job).unwrap();
1100        #[cfg(unix)]
1101        {
1102            use std::os::unix::fs::PermissionsExt as _;
1103            std::fs::set_permissions(&live_path, std::fs::Permissions::from_mode(0o444)).unwrap();
1104        }
1105        let before = std::fs::read(directory.join("job.json")).unwrap();
1106        let observed = observe_active(home.path()).unwrap();
1107        assert_eq!(observed.len(), 1);
1108        assert_eq!(observed[0].id, job.id);
1109        drop(lease);
1110        assert!(observe_active(home.path()).unwrap().is_empty());
1111        assert_eq!(std::fs::read(directory.join("job.json")).unwrap(), before);
1112        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Running);
1113    }
1114    fn generation() -> Generation {
1115        Generation {
1116            id: "a".repeat(64),
1117            executable: PathBuf::from("unused"),
1118        }
1119    }
1120    fn reservation(home: &Path, key: &str) -> Job {
1121        reserve(
1122            home,
1123            &generation(),
1124            "task",
1125            key,
1126            serde_json::json!({"task":"example"}),
1127        )
1128        .unwrap()
1129        .0
1130    }
1131
1132    #[test]
1133    fn reservation_deduplicates_identical_keys_and_rejects_collision() {
1134        let home = tempfile::tempdir().unwrap();
1135        let first = reservation(home.path(), "task:one");
1136        assert_eq!(first.id, reservation(home.path(), "task:one").id);
1137        assert!(
1138            reserve(
1139                home.path(),
1140                &generation(),
1141                "chat",
1142                "task:one",
1143                serde_json::Value::Null
1144            )
1145            .is_err()
1146        );
1147        assert_ne!(first.id, reservation(home.path(), "task:two").id);
1148    }
1149
1150    #[test]
1151    fn reservation_order_is_durable_and_corruption_never_resets_it() {
1152        let home = tempfile::tempdir().unwrap();
1153        let first = reservation(home.path(), "chat:first");
1154        assert_eq!(
1155            first.sequence,
1156            reservation(home.path(), "chat:first").sequence
1157        );
1158        let second = reservation(home.path(), "chat:second");
1159        assert!(second.sequence > first.sequence);
1160        let encoded = serde_json::to_vec(&second).unwrap();
1161        assert_eq!(
1162            serde_json::from_slice::<Job>(&encoded).unwrap().sequence,
1163            second.sequence
1164        );
1165        let mut legacy = serde_json::to_value(&first).unwrap();
1166        legacy.as_object_mut().unwrap().remove("sequence");
1167        assert_eq!(serde_json::from_value::<Job>(legacy).unwrap().sequence, 0);
1168        std::fs::write(root(home.path()).join("sequence.json"), b"broken").unwrap();
1169        assert!(
1170            reserve(
1171                home.path(),
1172                &generation(),
1173                "chat",
1174                "chat:third",
1175                serde_json::Value::Null
1176            )
1177            .is_err()
1178        );
1179    }
1180
1181    #[tokio::test]
1182    async fn pre_reservation_failure_is_proven_without_history_absence_guesses() {
1183        let home = tempfile::tempdir().unwrap();
1184        let blocked = home.path().join("not-a-directory");
1185        std::fs::write(&blocked, b"file").unwrap();
1186        let error = spawn(&blocked, "chat", "fresh:chat", serde_json::Value::Null)
1187            .await
1188            .unwrap_err();
1189        assert!(not_started(&error));
1190        assert!(not_started(&error.context("publish recorded turn")));
1191        assert!(!not_started(&anyhow::anyhow!(
1192            "startup acknowledgement timed out"
1193        )));
1194        assert!(!blocked.join("workers").exists());
1195    }
1196
1197    #[test]
1198    fn failed_os_spawn_is_fenced_and_proven_not_started() {
1199        let home = tempfile::tempdir().unwrap();
1200        let executable = home.path().join("invalid-executable");
1201        std::fs::write(&executable, b"not an executable binary").unwrap();
1202        let output = Command::new(std::env::current_exe().unwrap())
1203            .args([
1204                "--exact",
1205                "worker::tests::unstarted_spawn_helper",
1206                "--ignored",
1207                "--nocapture",
1208            ])
1209            .env("MAGI_WORKER_EXE", &executable)
1210            .env("MAGI_SPAWN_FAILURE_HOME", home.path())
1211            .output()
1212            .unwrap();
1213        assert!(
1214            output.status.success(),
1215            "{}\n{}",
1216            String::from_utf8_lossy(&output.stdout),
1217            String::from_utf8_lossy(&output.stderr)
1218        );
1219    }
1220
1221    #[tokio::test]
1222    #[ignore = "isolated environment for the failed OS spawn test"]
1223    async fn unstarted_spawn_helper() {
1224        let home = PathBuf::from(std::env::var_os("MAGI_SPAWN_FAILURE_HOME").unwrap());
1225        let error = spawn(&home, "chat", "fresh:failed-exec", serde_json::Value::Null)
1226            .await
1227            .unwrap_err();
1228        assert!(not_started(&error));
1229        let entry = std::fs::read_dir(root(&home).join("jobs"))
1230            .unwrap()
1231            .next()
1232            .unwrap()
1233            .unwrap();
1234        let id = entry.file_name().to_str().unwrap().to_owned();
1235        let job = get(&home, &id).unwrap();
1236        assert_eq!(job.status, Status::Failed);
1237        assert_eq!(job.pid, None);
1238        assert!(active(&home).unwrap().is_empty());
1239        assert_eq!(get(&home, &id).unwrap().status, Status::Failed);
1240    }
1241
1242    #[test]
1243    fn unresolved_startup_remains_reserved_and_live_lease_never_expires() {
1244        let home = tempfile::tempdir().unwrap();
1245        let mut job = reservation(home.path(), "task:one");
1246        assert_eq!(active(home.path()).unwrap().len(), 1);
1247        job.created_at = 0;
1248        save(
1249            &job_dir(home.path(), &job.id).unwrap().join("job.json"),
1250            &job,
1251        )
1252        .unwrap();
1253        let lease = try_lock(&job_dir(home.path(), &job.id).unwrap().join("live.lock"))
1254            .unwrap()
1255            .unwrap();
1256        assert_eq!(active(home.path()).unwrap().len(), 1);
1257        drop(lease);
1258        assert!(active(home.path()).unwrap().is_empty());
1259        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Failed);
1260    }
1261
1262    #[tokio::test]
1263    async fn cancelled_startup_cannot_execute_late() {
1264        let home = tempfile::tempdir().unwrap();
1265        let mut job = reservation(home.path(), "task:one");
1266        job.created_at = 0;
1267        save(
1268            &job_dir(home.path(), &job.id).unwrap().join("job.json"),
1269            &job,
1270        )
1271        .unwrap();
1272        assert!(active(home.path()).unwrap().is_empty());
1273        assert!(
1274            run(home.path(), &job.id)
1275                .await
1276                .unwrap_err()
1277                .to_string()
1278                .contains("no longer startable")
1279        );
1280    }
1281
1282    #[test]
1283    fn running_without_lease_fails_regardless_of_pid() {
1284        let home = tempfile::tempdir().unwrap();
1285        let mut job = reservation(home.path(), "task:one");
1286        job.status = Status::Running;
1287        job.pid = Some(std::process::id());
1288        save(
1289            &job_dir(home.path(), &job.id).unwrap().join("job.json"),
1290            &job,
1291        )
1292        .unwrap();
1293        assert!(active(home.path()).unwrap().is_empty());
1294        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Failed);
1295    }
1296
1297    #[test]
1298    fn controls_survive_reopen_and_reset_at_new_invocation() {
1299        let home = tempfile::tempdir().unwrap();
1300        request(home.path(), "task:one", Control::Park).unwrap();
1301        assert_eq!(
1302            control(home.path(), "task:one").unwrap(),
1303            Some(Control::Park)
1304        );
1305        reservation(home.path(), "task:one");
1306        assert_eq!(control(home.path(), "task:one").unwrap(), None);
1307        request(home.path(), "task:one", Control::Stop).unwrap();
1308        assert_eq!(
1309            control(home.path(), "task:one").unwrap(),
1310            Some(Control::Stop)
1311        );
1312        assert_eq!(control(home.path(), "task:other").unwrap(), None);
1313    }
1314
1315    #[test]
1316    fn singleton_locks_release_without_pid_cleanup() {
1317        let home = tempfile::tempdir().unwrap();
1318        let lease = try_lease(home.path(), "scheduler").unwrap().unwrap();
1319        assert!(try_lease(home.path(), "scheduler").unwrap().is_none());
1320        assert!(try_lease(home.path(), "other").unwrap().is_some());
1321        drop(lease);
1322        assert!(try_lease(home.path(), "scheduler").unwrap().is_some());
1323    }
1324
1325    #[test]
1326    fn immutable_generation_keeps_old_content_after_source_replacement() {
1327        let home = tempfile::tempdir().unwrap();
1328        let source = home.path().join("source");
1329        std::fs::write(&source, b"old executable").unwrap();
1330        let old = pin_source(home.path(), &source).unwrap();
1331        std::fs::write(&source, b"new executable").unwrap();
1332        let new = pin_source(home.path(), &source).unwrap();
1333        assert_ne!(old.id, new.id);
1334        assert_eq!(
1335            std::fs::read(executable(home.path(), &old.id).unwrap()).unwrap(),
1336            b"old executable"
1337        );
1338        assert_eq!(std::fs::read(new.executable).unwrap(), b"new executable");
1339    }
1340
1341    #[test]
1342    fn acknowledgement_records_own_version_and_handler_failure() {
1343        let home = tempfile::tempdir().unwrap();
1344        let generation = pin_source(home.path(), &std::env::current_exe().unwrap()).unwrap();
1345        let (job, _) = reserve(
1346            home.path(),
1347            &generation,
1348            "unknown-kind",
1349            "unknown:one",
1350            serde_json::Value::Null,
1351        )
1352        .unwrap();
1353        let invoke = || {
1354            let output = Command::new(&generation.executable)
1355                .args([
1356                    "--exact",
1357                    "worker::tests::pinned_entrypoint_helper",
1358                    "--ignored",
1359                    "--nocapture",
1360                ])
1361                .env("MAGI_WORKER_TEST_HOME", home.path())
1362                .env("MAGI_WORKER_TEST_ID", &job.id)
1363                .output()
1364                .unwrap();
1365            assert!(
1366                output.status.success(),
1367                "{}",
1368                String::from_utf8_lossy(&output.stderr)
1369            );
1370        };
1371        invoke();
1372        let failed = get(home.path(), &job.id).unwrap();
1373        assert_eq!(failed.status, Status::Failed);
1374        assert_eq!(failed.version.as_deref(), Some(env!("CARGO_PKG_VERSION")));
1375        assert!(failed.result.unwrap().contains("unknown worker kind"));
1376        assert!(active(home.path()).unwrap().is_empty());
1377        assert!(!job_dir(home.path(), &job.id).unwrap().exists());
1378        invoke();
1379        assert!(!job_dir(home.path(), &job.id).unwrap().exists());
1380    }
1381
1382    #[tokio::test]
1383    #[ignore = "subprocess entrypoint for the immutable-generation startup test"]
1384    async fn pinned_entrypoint_helper() {
1385        let home = PathBuf::from(std::env::var_os("MAGI_WORKER_TEST_HOME").unwrap());
1386        let id = std::env::var("MAGI_WORKER_TEST_ID").unwrap();
1387        assert!(run(&home, &id).await.is_err());
1388    }
1389
1390    #[test]
1391    fn unfinished_staging_is_reclaimed_without_removing_generations() {
1392        let home = tempfile::tempdir().unwrap();
1393        let old = root(home.path()).join("generations").join(".new-abandoned");
1394        std::fs::create_dir_all(&old).unwrap();
1395        let partial = old.join("partial");
1396        std::fs::write(&partial, b"interrupted copy").unwrap();
1397        #[cfg(windows)]
1398        {
1399            let mut permissions = std::fs::metadata(&partial).unwrap().permissions();
1400            permissions.set_readonly(true);
1401            std::fs::set_permissions(&partial, permissions).unwrap();
1402        }
1403        let source = home.path().join("source");
1404        std::fs::write(&source, b"complete executable").unwrap();
1405        let generation = pin_source(home.path(), &source).unwrap();
1406        assert!(!old.exists());
1407        assert!(generation.executable.is_file());
1408    }
1409
1410    #[tokio::test]
1411    async fn unrelated_executable_cannot_acknowledge_or_consume_reservation() {
1412        let home = tempfile::tempdir().unwrap();
1413        let source = home.path().join("other-binary");
1414        std::fs::write(&source, b"different executable of the same Cargo version").unwrap();
1415        let generation = pin_source(home.path(), &source).unwrap();
1416        let (job, _) = reserve(
1417            home.path(),
1418            &generation,
1419            "task",
1420            "task:one",
1421            serde_json::Value::Null,
1422        )
1423        .unwrap();
1424        assert!(
1425            run(home.path(), &job.id)
1426                .await
1427                .unwrap_err()
1428                .to_string()
1429                .contains("immutable generation")
1430        );
1431        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Starting);
1432        assert_eq!(active(home.path()).unwrap().len(), 1);
1433    }
1434
1435    #[test]
1436    fn abandoned_record_staging_is_bounded_and_never_executes() {
1437        let home = tempfile::tempdir().unwrap();
1438        let staging = root(home.path()).join("jobs").join(".new-interrupted");
1439        std::fs::create_dir_all(&staging).unwrap();
1440        std::fs::write(staging.join("job.json"), b"partial json").unwrap();
1441        assert!(active(home.path()).unwrap().is_empty());
1442        assert!(!staging.exists());
1443    }
1444
1445    #[cfg(unix)]
1446    #[test]
1447    fn detached_startup_preserves_stdout_and_stderr_in_record_log() {
1448        use std::os::unix::fs::PermissionsExt;
1449        let home = tempfile::tempdir().unwrap();
1450        let source = home.path().join("startup-diagnostic");
1451        std::fs::write(
1452            &source,
1453            b"#!/bin/sh\nprintf 'startup stdout\\n'\nprintf 'startup stderr\\n' >&2\nexit 7\n",
1454        )
1455        .unwrap();
1456        std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
1457        let generation = pin_source(home.path(), &source).unwrap();
1458        let (mut job, _) = reserve(
1459            home.path(),
1460            &generation,
1461            "task",
1462            "task:log",
1463            serde_json::Value::Null,
1464        )
1465        .unwrap();
1466        let mut command = detached_command(home.path(), &job, &generation).unwrap();
1467        for key in ["MAGI_RUN", "MAGI_NODE", "MAGI_SEAT", "MAGI_TURN_SCOPE"] {
1468            assert!(
1469                command
1470                    .get_envs()
1471                    .any(|(name, value)| name == key && value.is_none())
1472            );
1473        }
1474        assert_eq!(command.spawn().unwrap().wait().unwrap().code(), Some(7));
1475        let log = job_dir(home.path(), &job.id).unwrap().join("worker.log");
1476        let output = std::fs::read_to_string(log).unwrap();
1477        assert!(output.contains("startup stdout"));
1478        assert!(output.contains("startup stderr"));
1479        terminal(
1480            home.path(),
1481            &mut job,
1482            Status::Failed,
1483            "startup failed".into(),
1484        )
1485        .unwrap();
1486        assert!(active(home.path()).unwrap().is_empty());
1487        assert!(
1488            root(home.path())
1489                .join("results")
1490                .join(&job.id)
1491                .join("worker.log")
1492                .is_file()
1493        );
1494    }
1495
1496    #[cfg(windows)]
1497    #[test]
1498    fn record_publication_survives_readers_and_short_delete_sharing_handoffs() {
1499        use std::os::windows::fs::OpenOptionsExt as _;
1500
1501        let home = tempfile::tempdir().unwrap();
1502        let mut job = reservation(home.path(), "task:metadata-handoff");
1503        let directory = job_dir(home.path(), &job.id).unwrap();
1504        let path = directory.join("job.json");
1505        let lease = try_lock(&directory.join("live.lock")).unwrap().unwrap();
1506        let snapshot = File::open(&path).unwrap();
1507        // This reader allows ordinary IO but temporarily denies replacement.
1508        let reader = OpenOptions::new()
1509            .read(true)
1510            .share_mode(1 | 2) // FILE_SHARE_READ | FILE_SHARE_WRITE
1511            .open(&path)
1512            .unwrap();
1513        std::thread::scope(|scope| {
1514            scope.spawn(move || {
1515                std::thread::sleep(Duration::from_millis(15));
1516                drop(reader);
1517            });
1518            job.status = Status::Running;
1519            save(&path, &job).unwrap();
1520        });
1521        let original: Job = serde_json::from_reader(snapshot).unwrap();
1522        assert_eq!(original.status, Status::Starting);
1523        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Running);
1524        let running_snapshot = File::open(&path).unwrap();
1525        terminal(home.path(), &mut job, Status::Finished, "completed".into()).unwrap();
1526        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Finished);
1527        let previous: Job = serde_json::from_reader(running_snapshot).unwrap();
1528        assert_eq!(previous.status, Status::Running);
1529        drop(lease);
1530        assert!(active(home.path()).unwrap().is_empty());
1531    }
1532
1533    #[cfg(windows)]
1534    #[test]
1535    fn denied_record_publication_preserves_reservation_and_original_os_error() {
1536        use std::os::windows::fs::OpenOptionsExt as _;
1537
1538        let home = tempfile::tempdir().unwrap();
1539        let job = reservation(home.path(), "task:metadata-denied");
1540        let directory = job_dir(home.path(), &job.id).unwrap();
1541        let path = directory.join("job.json");
1542        let reader = OpenOptions::new()
1543            .read(true)
1544            .share_mode(1 | 2) // Deny deletion for the entire handoff budget.
1545            .open(&path)
1546            .unwrap();
1547        let mut acknowledged = job.clone();
1548        acknowledged.status = Status::Running;
1549        let error = save(&path, &acknowledged).unwrap_err();
1550        assert!(format!("{error:#}").contains("publish worker record"));
1551        let io = error.downcast_ref::<std::io::Error>().unwrap();
1552        assert!(matches!(io.raw_os_error(), Some(5 | 32)));
1553        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Starting);
1554        assert_eq!(reservation(home.path(), &job.key).id, job.id);
1555        assert!(std::fs::read_dir(&directory).unwrap().all(|entry| {
1556            !entry
1557                .unwrap()
1558                .file_name()
1559                .to_string_lossy()
1560                .starts_with(".pending-")
1561        }));
1562        drop(reader);
1563        save(&path, &acknowledged).unwrap();
1564        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Running);
1565    }
1566
1567    #[test]
1568    fn terminal_result_survives_open_execution_and_output_handles() {
1569        let home = tempfile::tempdir().unwrap();
1570        let mut job = reservation(home.path(), "task:open-handles");
1571        let directory = job_dir(home.path(), &job.id).unwrap();
1572        let lease = try_lock(&directory.join("live.lock")).unwrap().unwrap();
1573        let mut log = OpenOptions::new()
1574            .create(true)
1575            .append(true)
1576            .open(directory.join("worker.log"))
1577            .unwrap();
1578        writeln!(log, "before completion").unwrap();
1579        terminal(home.path(), &mut job, Status::Finished, "completed".into()).unwrap();
1580        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Finished);
1581        assert!(active(home.path()).unwrap().is_empty());
1582        assert!(
1583            directory.exists(),
1584            "a held execution lease must prevent retirement"
1585        );
1586        drop(lease);
1587        // Retirement may still be denied by the output handle on Windows.
1588        // Either way, querying active work and the durable result must succeed.
1589        assert!(active(home.path()).unwrap().is_empty());
1590        assert_eq!(get(home.path(), &job.id).unwrap().status, Status::Finished);
1591        writeln!(log, "after completion").unwrap();
1592        drop(log);
1593        assert!(active(home.path()).unwrap().is_empty());
1594        assert!(
1595            root(home.path())
1596                .join("results")
1597                .join(&job.id)
1598                .join("worker.log")
1599                .is_file()
1600        );
1601        assert!(!directory.exists());
1602    }
1603
1604    #[test]
1605    fn terminal_records_are_bounded_without_pruning_live_reservations() {
1606        let home = tempfile::tempdir().unwrap();
1607        for i in 0..RETAIN_FINISHED + 3 {
1608            let mut job = reservation(home.path(), &format!("task:{i}"));
1609            terminal(home.path(), &mut job, Status::Finished, "done".into()).unwrap();
1610        }
1611        let live = reservation(home.path(), "task:live");
1612        let jobs = records(home.path()).unwrap();
1613        assert_eq!(jobs.len(), 1);
1614        assert_eq!(
1615            std::fs::read_dir(root(home.path()).join("results"))
1616                .unwrap()
1617                .count(),
1618            RETAIN_FINISHED
1619        );
1620        assert!(jobs.iter().any(|job| job.id == live.id));
1621    }
1622}