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