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