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