@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.
- package/dist/bin.js +4 -33
- package/dist/bin.js.map +1 -1
- package/harness/harness/gate.py +146 -99
- package/harness/harness/peers.py +42 -27
- package/harness/test_work.py +62 -0
- package/package.json +3 -3
package/harness/harness/gate.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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) ->
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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(
|
|
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
|
|
393
|
-
"""
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
the only strategy left
|
|
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 =
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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".
|
|
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
|
-
|
|
557
|
-
done, gates =
|
|
597
|
+
out = _run_out(running)
|
|
598
|
+
done, gates = out["done"], out["gates"]
|
|
558
599
|
return _answer(
|
|
559
600
|
"running",
|
|
560
|
-
f"running {
|
|
561
|
-
{
|
|
562
|
-
|
|
563
|
-
"
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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) ->
|
|
682
|
-
"""Why a finished result cannot be used,
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
799
|
-
|
|
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)
|
package/harness/harness/peers.py
CHANGED
|
@@ -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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
package/harness/test_work.py
CHANGED
|
@@ -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.
|
|
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",
|