@appchy/jarvis 0.1.121 → 0.1.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,31 +68,15 @@ def _run_file(root) -> Path:
68
68
 
69
69
 
70
70
  def _alive(pid) -> bool:
71
- """Whether the process that started a run is still there.
71
+ """Whether the process that started a run is still there — `peers.alive`, so the
72
+ lock and the peer scan cannot answer one question two ways.
72
73
 
73
74
  Liveness is the PID and never a clock. A lease would have to guess how long a
74
75
  verify takes, and this repo's own set spans 174 seconds warm and far more cold —
75
76
  a guess short enough to be useful would strand a real run, and one long enough to
76
77
  be safe would not guard anything.
77
-
78
- A pid somebody else owns answers `PermissionError`, and that is ALIVE — the kernel
79
- refused to signal a process it confirmed exists. Reading it as dead let a second
80
- run start over a live one, and it was also a second answer to the one question
81
- `peers._live()` already answers this way: two readings of one pid is exactly the
82
- kind of disagreement this file exists to remove.
83
78
  """
84
- try:
85
- # Strictly positive: signal 0 to pid 0 asks about the whole process group and
86
- # to a negative pid about another one, so either would answer "alive" about
87
- # something that is not this run at all.
88
- if int(pid) <= 0:
89
- return False
90
- os.kill(int(pid), 0)
91
- except PermissionError:
92
- return True
93
- except (OSError, TypeError, ValueError):
94
- return False
95
- return True
79
+ return peers.alive(pid)
96
80
 
97
81
 
98
82
  def read_run(root):
@@ -133,12 +117,19 @@ def _write_run(root, data) -> None:
133
117
  p = _run_file(root)
134
118
  tmp = p.with_suffix(".tmp")
135
119
  try:
136
- tmp.write_text(json.dumps(data, indent=2) + "\n")
120
+ tmp.write_text(_encode(data))
137
121
  os.replace(tmp, p)
138
122
  except OSError:
139
123
  pass
140
124
 
141
125
 
126
+ def _encode(data) -> str:
127
+ """The run file's bytes. One definition, because the claim writes it straight into
128
+ an exclusively-created descriptor while every later update goes through the
129
+ rename above — two writers of one format is one of them drifting later."""
130
+ return json.dumps(data, indent=2) + "\n"
131
+
132
+
142
133
  def _begin(root, name, pid) -> dict:
143
134
  """The record a run declares itself with, written before the first gate.
144
135
 
@@ -192,7 +183,7 @@ def _lost(prior):
192
183
  ("task", "session", "machine", "started", "current", "done", "gates")}
193
184
 
194
185
 
195
- def _claim(root, name, pid) -> bool:
186
+ def _claim(root, name, pid) -> tuple:
196
187
  """Take the checkout's one verify slot, or report that somebody holds it.
197
188
 
198
189
  **Created exclusively, so two starts in the same instant cannot both win.** The
@@ -206,21 +197,33 @@ def _claim(root, name, pid) -> bool:
206
197
  strand every future verify in the checkout. Two callers racing to take over one
207
198
  DEAD slot can still both proceed, because breaking a stale lock needs an
208
199
  authority a file cannot be; that residue is stated rather than pretended away.
200
+
201
+ **Only "the file is already there" means somebody holds it.** Any other failure
202
+ to write is the harness's own problem — a directory that is not writable, a full
203
+ disk — and it is raised rather than folded into a refusal, because a caller told
204
+ *a verify is already running here (pid None)* would go looking for a session that
205
+ does not exist. A door that cannot tell its own failure from the caller's
206
+ situation must say which.
207
+
208
+ Returns `(claimed, holder)`. The holder comes back with the refusal because this
209
+ already read it to decide: making the caller read the file a third time to say
210
+ whose run it is was work done twice for one answer that cannot have changed.
209
211
  """
210
212
  p = _run_file(root)
211
- data = _begin(root, name, pid)
212
213
  try:
213
214
  fd = os.open(p, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
214
215
  except FileExistsError:
215
- if live_run(root):
216
- return False
217
- _write_run(root, data)
218
- return True
219
- except OSError:
220
- return False
216
+ # Built only once the slot is known to be takeable. `_begin` shells out for
217
+ # HEAD and reads the run file, and on a contended claim all of it was thrown
218
+ # away — wasted on exactly the path that happens when two sessions are busy.
219
+ held = live_run(root)
220
+ if held:
221
+ return False, held
222
+ _write_run(root, _begin(root, name, pid))
223
+ return True, None
221
224
  with os.fdopen(fd, "w") as f:
222
- f.write(json.dumps(data, indent=2) + "\n")
223
- return True
225
+ f.write(_encode(_begin(root, name, pid)))
226
+ return True, None
224
227
 
225
228
 
226
229
  def _elapsed(data) -> int:
@@ -366,6 +369,12 @@ def _execute(root, name, adopt: bool = False) -> tuple:
366
369
  finished=_now().isoformat(), passed=ok, sha=sha,
367
370
  results=[{"name": n, "status": s, "said": d, "log": g}
368
371
  for n, s, d, g in results])
372
+ # A note about a vanished run for the item THIS run just recorded is spent: the
373
+ # item has a result now, and a record that says both is self-contradictory at
374
+ # rest. Cleared where it stops being true rather than stepped around by whoever
375
+ # reads it next, which is the only place that cannot be forgotten.
376
+ if (data.get("lost") or {}).get("task") == data.get("task"):
377
+ data["lost"] = None
369
378
  _write_run(root, data)
370
379
 
371
380
  if name and not _record(root, name, results, ok, sha):
@@ -389,17 +398,23 @@ def _print_report(results, sha) -> None:
389
398
  print(f" {line}")
390
399
 
391
400
 
392
- def _progress(running) -> str:
393
- """How far through a run is, as a fraction a reader can act on.
401
+ def _where(run) -> str:
402
+ """Which gate a run had reached, as a fraction. Empty when its gate list is
403
+ unknown, which is the one case there is no fraction to state.
394
404
 
395
- A refusal saying only how long ago something started cannot answer the one
396
- question the refused caller has is this nearly over, or did it just begin — and
397
- the only strategy left without it is to ask again on a timer.
405
+ Shared by a live run and a dead one because it is one idea: a reader told only how
406
+ long ago something started cannot answer whether it is nearly over or has just
407
+ begun, and without that the only strategy left is to ask again on a timer.
398
408
  """
399
- done, gates = running.get("done") or [], running.get("gates") or []
409
+ done, gates = run.get("done") or [], run.get("gates") or []
410
+ return f"gate {len(done) + 1} of {len(gates)}" if gates else ""
411
+
412
+
413
+ def _progress(running) -> str:
414
+ """How far through a live run is, with what it is doing and for how long."""
400
415
  gate = running.get("current") or ""
401
- where = f"gate {len(done) + 1} of {len(gates)}" if gates else "starting"
402
- return f"{where}{f' ({gate})' if gate else ''}, {_elapsed(running)}s in"
416
+ return (f"{_where(running) or 'starting'}{f' ({gate})' if gate else ''}, "
417
+ f"{_elapsed(running)}s in")
403
418
 
404
419
 
405
420
  def _whose(running) -> str:
@@ -416,8 +431,14 @@ def _whose(running) -> str:
416
431
  return peers.describe(who, str(running.get("machine") or ""))
417
432
 
418
433
 
419
- def _busy(running) -> str:
420
- """Why a caller cannot start a verify, in the words the refused caller needs.
434
+ def _busy(running, asked: str = "", started: bool = True) -> str:
435
+ """The run holding this checkout, in the words the caller needs.
436
+
437
+ **One sentence for both doors**, because they state one policy and were drifting
438
+ apart the moment there were two of them: a caller that tried to START says "this
439
+ one did not start", and a caller that only ASKED says its own item has nothing —
440
+ and everything else, whose run it is, how far through, who to reach and the fact
441
+ that no place is being held, is the same either way.
421
442
 
422
443
  Never phrased as a failure. Two runs in one checkout corrupt each other's
423
444
  result — one gate's build cleans the directory another's log check is about to
@@ -430,12 +451,15 @@ def _busy(running) -> str:
430
451
  scheduler; saying honestly that there is none is a sentence (founder, 2026-09-12).
431
452
  """
432
453
  who = _whose(running)
433
- return (f"a verify is already running here — '{running.get('task') or 'no task'}', "
434
- f"{_progress(running)} (pid {running.get('pid')}) "
435
- f"on {running.get('machine') or 'this machine'}"
454
+ mine = (running.get("task") or "") == asked
455
+ head = ("a verify is already running here" if started or mine
456
+ else "a verify is running in this checkout and it is not yours")
457
+ outcome = ("Two at once corrupt each other's result, so this one did not start."
458
+ if started else f"Nothing has run for '{asked or 'no task'}'.")
459
+ return (f"{head} — '{running.get('task') or 'no task'}', {_progress(running)} "
460
+ f"(pid {running.get('pid')}) on {running.get('machine') or 'this machine'}"
436
461
  + (f", started by {who}" if who else "")
437
- + f". Two at once corrupt each other's result, so this one did not start. "
438
- f"Nothing is holding your place — ask again, and start it then.")
462
+ + f". {outcome} Nothing is holding your place ask again, and start it then.")
439
463
 
440
464
 
441
465
  def _describe(data) -> str:
@@ -520,6 +544,23 @@ def _answer(state, message, data=None) -> int:
520
544
  return 0
521
545
 
522
546
 
547
+ def _idle(message, reason) -> int:
548
+ """Nothing usable has run for the caller's item, and why. One helper because every
549
+ such answer carries the same two fields and there are five reasons to give it."""
550
+ return _answer("idle", message, {"gates": sorted(VERIFY), "reason": reason})
551
+
552
+
553
+ def _cannot_claim(error, outcome: str) -> str:
554
+ """The harness could not write the slot — said once, for both doors.
555
+
556
+ It exists to stop a caller reading the harness's own failure as another session's
557
+ lock, so it is the one message here that must not differ between the door that
558
+ blocks and the door that does not.
559
+ """
560
+ return (f"the verify slot could not be written, so nothing {outcome}: {error}. "
561
+ f"Nothing is holding it — this is the checkout, not another session.")
562
+
563
+
523
564
  def _run_out(running) -> dict:
524
565
  return {"task": running.get("task") or "", "gate": running.get("current") or "",
525
566
  "done": running.get("done") or [], "gates": running.get("gates") or [],
@@ -553,14 +594,15 @@ def _verify_report(root, name) -> int:
553
594
  # is a different situation from watching its own gates go — and reporting
554
595
  # both as `running` is how a session read a held checkout as its own progress.
555
596
  if (running.get("task") or "") == name:
556
- gate = running.get("current") or "starting"
557
- done, gates = running.get("done") or [], running.get("gates") or []
597
+ out = _run_out(running)
598
+ done, gates = out["done"], out["gates"]
558
599
  return _answer(
559
600
  "running",
560
- f"running {_elapsed(running)}s: {gate}. {len(done)} of {len(gates)} done.",
561
- {"gate": gate, "elapsedSeconds": _elapsed(running), "gates": gates,
562
- "done": done, "results": _results_out(running),
563
- "run": _run_out(running)})
601
+ f"running {out['elapsedSeconds']}s: {out['gate'] or 'starting'}. "
602
+ f"{len(done)} of {len(gates)} done.",
603
+ {"gate": out["gate"], "elapsedSeconds": out["elapsedSeconds"],
604
+ "gates": gates, "done": done, "results": _results_out(running),
605
+ "run": out})
564
606
  # Every gate-shaped field stays EMPTY. The caller has no run, and filling
565
607
  # them with another item's progress is the misreading this state exists to
566
608
  # make impossible.
@@ -570,16 +612,10 @@ def _verify_report(root, name) -> int:
570
612
  # asks, finds the slot naming somebody else's item, and that is the whole of
571
613
  # what it had to go on. Being behind a stranger's run and having lost your own
572
614
  # are two facts, and it needs both.
573
- lost = _abandoned(running, name)
574
- return _answer(
575
- "busy",
576
- f"A verify is running in this checkout and it is not yours — "
577
- f"'{running.get('task') or 'no task'}', {_progress(running)}"
578
- + (f", started by {_whose(running)}" if _whose(running) else "")
579
- + f". Nothing has run for '{name or 'no task'}', and nothing is holding "
580
- f"your place — ask again once the slot is free."
581
- + (f" {_gone(name, lost)}" if lost else ""),
582
- {"run": _run_out(running), "reason": "lost" if lost else ""})
615
+ lost = _abandoned(running, None, name)
616
+ said = _busy(running, asked=name, started=False)
617
+ return _answer("busy", said + (f" {_gone(name, lost)}" if lost else ""),
618
+ {"run": _run_out(running), "reason": "lost" if lost else ""})
583
619
 
584
620
  return _verify_verdict(root, name, read_run(root))
585
621
 
@@ -606,7 +642,7 @@ def _verify_verdict(root, name, done) -> int:
606
642
  nine-gate runs a day into something nothing caught.
607
643
  """
608
644
  mine = done if done and (done.get("task") or "") == name else None
609
- lost = _abandoned(done, name)
645
+ lost = _abandoned(done, mine, name)
610
646
 
611
647
  if mine and mine.get("finished") and not mine.get("read"):
612
648
  sha = mine.get("sha") or ""
@@ -618,35 +654,28 @@ def _verify_verdict(root, name, done) -> int:
618
654
  {"elapsedSeconds": _elapsed(mine), "gates": mine.get("gates") or [],
619
655
  "done": mine.get("done") or [], "passed": bool(mine.get("passed")),
620
656
  "commit": sha, "results": _results_out(mine)})
621
- return _answer("idle", _stale(root.parent, mine),
622
- {"commit": sha, "gates": sorted(VERIFY),
623
- "reason": "gone" if _vanished(root.parent, sha) else "moved"})
657
+ said, why = _stale(root.parent, mine)
658
+ return _answer("idle", said,
659
+ {"commit": sha, "gates": sorted(VERIFY), "reason": why})
624
660
 
625
661
  if lost:
626
- return _answer("idle", _gone(name, lost),
627
- {"gates": sorted(VERIFY), "reason": "lost"})
662
+ return _idle(_gone(name, lost), "lost")
628
663
 
629
664
  if mine and mine.get("read"):
630
- return _answer(
631
- "idle",
665
+ return _idle(
632
666
  f"The gates for '{name}' finished and that result has already been "
633
667
  f"reported once — asking again does not re-run them. Start them again to "
634
- f"prove the code as it stands now.",
635
- {"gates": sorted(VERIFY), "reason": "read"})
668
+ f"prove the code as it stands now.", "read")
636
669
 
637
670
  if done and done.get("finished"):
638
- return _answer(
639
- "idle",
671
+ return _idle(
640
672
  f"No gates have run for '{name or 'no task'}'. The last run in this "
641
673
  f"checkout proved '{done.get('task') or 'no task'}', which says nothing "
642
- f"about yours. Start them explicitly — asking never starts a run.",
643
- {"gates": sorted(VERIFY), "reason": "other"})
674
+ f"about yours. Start them explicitly — asking never starts a run.", "other")
644
675
 
645
- return _answer(
646
- "idle",
676
+ return _idle(
647
677
  f"No gates have run for '{name or 'no task'}' at this commit. Start them "
648
- f"explicitly — asking never starts a run.",
649
- {"gates": sorted(VERIFY), "reason": "none"})
678
+ f"explicitly — asking never starts a run.", "none")
650
679
 
651
680
 
652
681
  def _gone(name, lost) -> str:
@@ -656,19 +685,24 @@ def _gone(name, lost) -> str:
656
685
  slot — because the session it is addressed to reaches it either way and the fact
657
686
  does not change with the company it keeps.
658
687
  """
659
- gates, at = lost.get("gates") or [], len(lost.get("done") or []) + 1
688
+ where = _where(lost)
660
689
  return (f"A verify for '{name or 'no task'}' started and stopped without "
661
690
  f"recording anything"
662
- + (f" — it was at gate {at} of {len(gates)}" if gates else "")
691
+ + (f" — it was at {where}" if where else "")
663
692
  + ". Nothing was written, so there is no result to read: start them again.")
664
693
 
665
694
 
666
- def _abandoned(done, name):
695
+ def _abandoned(done, mine, name):
667
696
  """A run for `name` that stopped without recording — whether it is still the run
668
- on disk, or was carried onto the one that replaced it."""
669
- if done and (done.get("task") or "") == name and not done.get("finished") \
670
- and not _alive(done.get("pid")):
671
- return done
697
+ on disk, or was carried onto the one that replaced it.
698
+
699
+ Two facts, not three. `mine` arrives already worked out, because "is this record
700
+ the caller's" is one question and deriving it again here made it three copies of
701
+ one comparison with nothing linking them. The note itself is retired by the run
702
+ that records a result, in `_execute` — so nothing has to be stepped around here.
703
+ """
704
+ if mine and not mine.get("finished") and not _alive(mine.get("pid")):
705
+ return mine
672
706
  lost = (done or {}).get("lost")
673
707
  return lost if isinstance(lost, dict) and (lost.get("task") or "") == name else None
674
708
 
@@ -678,13 +712,19 @@ def _vanished(repo, sha: str) -> bool:
678
712
  return bool(sha) and sha != "no-git" and not git.has_commit(repo, sha)
679
713
 
680
714
 
681
- def _stale(repo, done) -> str:
682
- """Why a finished result cannot be used, naming both commits.
715
+ def _stale(repo, done) -> tuple:
716
+ """Why a finished result cannot be used, as `(sentence, reason)`.
717
+
718
+ **Both come out of one predicate**, because the sentence a person reads and the
719
+ word a program branches on must describe the same thing. Asking `_vanished` twice —
720
+ once here to pick the wording and once in the caller to pick the reason — cost two
721
+ git subprocesses and left nothing holding the two answers together if either call
722
+ ever drifted.
683
723
 
684
724
  A hash the reader cannot open is worse than no hash, so a commit that a push
685
725
  rebased out of the history is said to be GONE rather than reported as the code
686
- having moved: the code may well be identical, and the two call for different
687
- next steps.
726
+ having moved: the code may well be identical, and the two call for different next
727
+ steps.
688
728
  """
689
729
  sha, head = (done.get("sha") or "")[:12], _head(repo)[:12]
690
730
  ran = done.get("head") or ""
@@ -693,13 +733,13 @@ def _stale(repo, done) -> str:
693
733
  return (f"The gates for '{task}' passed at {sha}, and that commit is no "
694
734
  f"longer in this branch's history — a push rebased it away. The code "
695
735
  f"it measured may be untouched, but nothing can be proven against a "
696
- f"commit that is gone: start them again.")
736
+ f"commit that is gone: start them again."), "gone"
697
737
  drifted = (f" Those gates began at {ran[:12]} and ended at {sha}, so something "
698
738
  f"was committed while they were running." if ran and not sha.startswith(ran[:12])
699
739
  else "")
700
740
  return (f"The gates for '{task}' ran at {sha} and the code has moved since — "
701
741
  f"HEAD is {head}. That result does not describe what is in the tree now, "
702
- f"so it cannot be used: start them again.{drifted}")
742
+ f"so it cannot be used: start them again.{drifted}"), "moved"
703
743
 
704
744
 
705
745
  def _verify_start(root, name) -> int:
@@ -715,11 +755,14 @@ def _verify_start(root, name) -> int:
715
755
  # child cannot claim anything until a Python interpreter has finished starting —
716
756
  # and a slot left open for those few hundred milliseconds is a slot two polling
717
757
  # sessions will both walk through. The child adopts it and stamps its own pid.
718
- if not _claim(root, name, os.getpid()):
719
- running = live_run(root) or {}
758
+ try:
759
+ claimed, held = _claim(root, name, os.getpid())
760
+ except OSError as e:
761
+ return _idle(_cannot_claim(e, "started"), "none")
762
+ if not claimed:
720
763
  # `busy` and never `running`: this caller has no run, and the fields that
721
764
  # would describe one stay empty so the slot's state cannot be read as its own.
722
- return _answer("busy", _busy(running), {"run": _run_out(running)})
765
+ return _answer("busy", _busy(held, asked=name), {"run": _run_out(held)})
723
766
 
724
767
  try:
725
768
  subprocess.Popen(
@@ -732,8 +775,7 @@ def _verify_start(root, name) -> int:
732
775
  # expire with this process — a checkout that reads as busy for no reason is
733
776
  # the failure this whole item is about.
734
777
  _run_file(root).unlink(missing_ok=True)
735
- return _answer("idle", f"The gates could not be started: {e}",
736
- {"gates": sorted(VERIFY), "reason": "none"})
778
+ return _idle(f"The gates could not be started: {e}", "none")
737
779
  return _answer(
738
780
  "started",
739
781
  f"Gates started ({len(VERIFY)}): {', '.join(sorted(VERIFY))}. They take "
@@ -795,8 +837,13 @@ def cmd_verify(args) -> int:
795
837
 
796
838
  # The person's door claims the slot the same way the background one does, so
797
839
  # neither can be the hole in a guard the other honours.
798
- if not _claim(root, name, os.getpid()):
799
- print(f"error: {_busy(live_run(root) or {})}", file=sys.stderr)
840
+ try:
841
+ claimed, held = _claim(root, name, os.getpid())
842
+ except OSError as e:
843
+ print(f"error: {_cannot_claim(e, 'ran')}", file=sys.stderr)
844
+ return 1
845
+ if not claimed:
846
+ print(f"error: {_busy(held, asked=name)}", file=sys.stderr)
800
847
  return 1
801
848
 
802
849
  results, ok, sha = _execute(root, name, adopt=True)
@@ -46,6 +46,37 @@ def here() -> str:
46
46
  return git.machine()
47
47
 
48
48
 
49
+ def alive(pid) -> bool:
50
+ """Whether a process is still there. **One answer to this, for every caller.**
51
+
52
+ Signal 0 asks the kernel without touching the process. Strictly positive, because
53
+ signal 0 to pid 0 asks about the whole process group and to a negative pid about
54
+ another one — either would answer "alive" about something that is not the process
55
+ in question at all.
56
+
57
+ **A pid somebody else owns answers `PermissionError`, and that is ALIVE**: the
58
+ kernel refused to signal a process it just confirmed exists. The verify lock read
59
+ the same refusal as *dead* while this read it as alive, so a second run could
60
+ start over a live one — two readings of one pid, which is the disagreement every
61
+ shared answer in this module exists to prevent. It lives here rather than beside
62
+ either caller because a pid is a machine fact and this file is where those are.
63
+
64
+ **Known limit: a recycled pid reads as alive.** A process killed hard leaves its
65
+ artifacts behind, so nothing on the machine can corroborate the number, and
66
+ reading a start time needs a child process this module is deliberately not
67
+ allowed to spawn. What bounds it is where the answer is used.
68
+ """
69
+ try:
70
+ if int(pid) <= 0:
71
+ return False
72
+ os.kill(int(pid), 0)
73
+ except PermissionError:
74
+ return True
75
+ except (OSError, TypeError, ValueError):
76
+ return False
77
+ return True
78
+
79
+
49
80
  #: Where an agent client publishes the sessions it currently has running. Claude
50
81
  #: Code is the only one that publishes anything today; the shape is one JSON file
51
82
  #: per process carrying its own session id, its addressable name and its pid.
@@ -76,20 +107,15 @@ def _live():
76
107
  different and much stronger claim: something published a list and this session
77
108
  was not on it.
78
109
 
79
- A pid is checked rather than trusted. The directory is cleaned up on exit, so in
80
- practice it holds only live runs, but a process killed hard leaves its file
81
- behind and a stale entry here would resurrect exactly the ghost this exists to
82
- catch. Signal 0 asks the kernel whether the pid is there without touching it.
83
-
84
- **Known limit: a recycled pid reads as alive.** If a session is killed hard AND
85
- the operating system later hands its number to some unrelated program, this says
86
- running about a session that is gone. It is not closed here, and the alternatives
87
- were worse: every artifact that could corroborate the pid the messaging socket
88
- included — is left behind by the same hard kill, so pairing two stale files
89
- proves nothing, and reading a process's start time needs a child process this
90
- module is deliberately not allowed to spawn. What bounds it instead is where the answer is used: one
91
- row on a status screen reading "running" instead of "ended", never a board write,
92
- which is the constraint the rule about a client's facts already sets.
110
+ A pid is checked rather than trusted, through `alive()` the one answer this
111
+ module gives to that question, shared with the verify lock. The directory is
112
+ cleaned up on exit, so in practice it holds only live runs, but a process killed
113
+ hard leaves its file behind, and a stale entry here would resurrect exactly the
114
+ ghost this exists to catch.
115
+
116
+ `alive()` carries the recycled-pid limit. What bounds it is where the answer is
117
+ used: one row on a status screen reading "running" instead of "ended", never a
118
+ board write, which is the constraint the rule about a client's facts already sets.
93
119
 
94
120
  The result is cached per directory for the life of the process. The harness is a
95
121
  short-lived command, so re-globbing and re-signalling once per printed row is
@@ -110,20 +136,9 @@ def _live():
110
136
  continue # a half-written file is not a dead session
111
137
  if not isinstance(entry, dict):
112
138
  continue
113
- run, pid = str(entry.get("sessionId", "")), entry.get("pid")
114
- if not run:
115
- continue
116
- try:
117
- # Strictly positive: signal 0 to pid 0 asks about the whole process
118
- # group and to a negative pid about another one, so either would answer
119
- # "alive" about something that is not this session at all.
120
- if int(pid) <= 0:
121
- continue
122
- os.kill(int(pid), 0)
123
- except (TypeError, ValueError, ProcessLookupError):
139
+ run = str(entry.get("sessionId", ""))
140
+ if not run or not alive(entry.get("pid")):
124
141
  continue
125
- except PermissionError:
126
- pass # alive and owned by somebody else, which is still alive
127
142
  out[run] = entry
128
143
  _RUNNING[key] = out
129
144
  return out
@@ -3504,6 +3504,68 @@ def test_the_slot_is_held_from_before_the_child_starts():
3504
3504
  gate.VERIFY = old
3505
3505
 
3506
3506
 
3507
+ def test_a_lost_run_is_retired_by_the_run_that_records_one():
3508
+ # The carried note outlived what it was about: a run for this item died, the next
3509
+ # run for the same item passed and was reported, and every ask after the first
3510
+ # went back to saying nothing had been written — about gates that had just gone
3511
+ # green. A note that survives its own subject is the failure class this whole
3512
+ # item exists to remove, so it must not be reintroduced by the fix.
3513
+ with tempfile.TemporaryDirectory() as tmp:
3514
+ v = _tree(tmp)
3515
+ e = _epic(v, "an-epic")
3516
+ _task(e / "in-progress", "alpha", body="# T\n")
3517
+ with _work_dir(tmp) as root:
3518
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3519
+ try:
3520
+ gate._write_run(root, {"task": "alpha", "pid": 2 ** 31 - 1,
3521
+ "started": gate._now().isoformat(),
3522
+ "gates": ["tests"], "done": [],
3523
+ "finished": None, "read": False})
3524
+ # A real run for the same item, which picks the note up and then
3525
+ # RECORDS — so the note is spent, and the record must not be left
3526
+ # saying both "passed for alpha" and "alpha's run wrote nothing".
3527
+ # Cleared where it stops being true rather than stepped around by
3528
+ # whoever reads it next.
3529
+ gate.cmd_verify({"task": "alpha"})
3530
+ assert gate.read_run(root)["passed"] is True
3531
+ assert gate.read_run(root)["lost"] is None, gate.read_run(root)
3532
+
3533
+ first = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3534
+ assert first["state"] == "finished" and first["passed"] is True, first
3535
+ again = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3536
+ assert again["reason"] == "read", again
3537
+ assert "without recording" not in again["message"], again["message"]
3538
+ finally:
3539
+ gate.VERIFY = old
3540
+
3541
+
3542
+ def test_a_slot_the_harness_cannot_write_is_not_reported_as_somebody_elses_run():
3543
+ # Only "the file is already there" means somebody holds it. Any other failure to
3544
+ # write is the harness's own, and a caller told `a verify is already running here
3545
+ # (pid None)` goes looking for a session that does not exist — a door reporting
3546
+ # its own failure as the caller's situation.
3547
+ with tempfile.TemporaryDirectory() as tmp:
3548
+ v = _tree(tmp)
3549
+ _epic(v, "an-epic")
3550
+ with _work_dir(tmp) as root:
3551
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3552
+ real = gate.os.open
3553
+ try:
3554
+ def refuse(path, flags, *rest):
3555
+ if str(path).endswith(gate.RUN):
3556
+ raise PermissionError(13, "Permission denied")
3557
+ return real(path, flags, *rest)
3558
+ gate.os.open = refuse
3559
+ out = _said(lambda: gate.cmd_verify({"task": "alpha", "start": True}))
3560
+ assert out["state"] != "busy", out
3561
+ assert out["run"] is None, out
3562
+ assert "Permission denied" in out["message"], out["message"]
3563
+ assert "not another session" in out["message"], out["message"]
3564
+ finally:
3565
+ gate.os.open = real
3566
+ gate.VERIFY = old
3567
+
3568
+
3507
3569
  def test_a_pid_somebody_else_owns_is_alive_not_dead():
3508
3570
  # Two readings of one pid: `peers._live` treats a kernel refusal as alive and
3509
3571
  # this treated it as dead, so a run owned by another user read as over and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.121",
3
+ "version": "0.1.122",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",
@@ -63,10 +63,10 @@
63
63
  "@jarvis/errors": "1.0.0",
64
64
  "@jarvis/logger": "1.0.0",
65
65
  "@jarvis/rpc": "1.0.0",
66
+ "@jarvis/types": "1.0.0",
66
67
  "@jarvis/typescript-config": "1.0.0",
67
68
  "@jarvis/ui": "0.1.0",
68
- "@jarvis/vitest-config": "1.0.0",
69
- "@jarvis/types": "1.0.0"
69
+ "@jarvis/vitest-config": "1.0.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsx watch src/bin.ts start --foreground",