@appchy/jarvis 0.1.120 → 0.1.121
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 +44 -8
- package/dist/bin.js.map +1 -1
- package/harness/harness/gate.py +374 -82
- package/harness/harness/git.py +15 -0
- package/harness/presets/appchy/PRESET.md +1 -1
- package/harness/test_work.py +275 -2
- package/harness/work.py +9 -6
- package/package.json +3 -3
package/harness/harness/gate.py
CHANGED
|
@@ -74,9 +74,22 @@ def _alive(pid) -> bool:
|
|
|
74
74
|
verify takes, and this repo's own set spans 174 seconds warm and far more cold —
|
|
75
75
|
a guess short enough to be useful would strand a real run, and one long enough to
|
|
76
76
|
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.
|
|
77
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
|
|
79
90
|
os.kill(int(pid), 0)
|
|
91
|
+
except PermissionError:
|
|
92
|
+
return True
|
|
80
93
|
except (OSError, TypeError, ValueError):
|
|
81
94
|
return False
|
|
82
95
|
return True
|
|
@@ -126,6 +139,90 @@ def _write_run(root, data) -> None:
|
|
|
126
139
|
pass
|
|
127
140
|
|
|
128
141
|
|
|
142
|
+
def _begin(root, name, pid) -> dict:
|
|
143
|
+
"""The record a run declares itself with, written before the first gate.
|
|
144
|
+
|
|
145
|
+
`session` and `head` are here so the two doors can answer questions the file
|
|
146
|
+
could not: who to go and talk to, and whether the ground moved while the gates
|
|
147
|
+
were running. Neither is load-bearing — a missing session degrades to saying
|
|
148
|
+
nothing about who, which is what an enrichment is allowed to do.
|
|
149
|
+
"""
|
|
150
|
+
return {
|
|
151
|
+
"task": name,
|
|
152
|
+
"pid": pid,
|
|
153
|
+
"machine": peers.here(),
|
|
154
|
+
# The run belongs to a SESSION, not to the pid: the pid is a detached child
|
|
155
|
+
# that exits, and the session waiting for the answer is what outlives it.
|
|
156
|
+
"session": peers.me(),
|
|
157
|
+
"started": _now().isoformat(),
|
|
158
|
+
# The commit the gates STARTED on. `sha` below is where they ended, which is
|
|
159
|
+
# what the evidence records — and the two differ exactly when somebody
|
|
160
|
+
# committed mid-run, which is the thing nobody could see.
|
|
161
|
+
"head": _head(root.parent),
|
|
162
|
+
"gates": sorted(VERIFY),
|
|
163
|
+
"current": "",
|
|
164
|
+
"done": [],
|
|
165
|
+
"finished": None,
|
|
166
|
+
"passed": False,
|
|
167
|
+
"sha": "",
|
|
168
|
+
"results": [],
|
|
169
|
+
# Whether a finished result has already been handed to a caller. Reading one
|
|
170
|
+
# is what retires it: an agent that fixed something and asked again wants a
|
|
171
|
+
# fresh run, not the answer to the question it asked before the fix.
|
|
172
|
+
"read": False,
|
|
173
|
+
# The run this one replaced, when that run stopped without recording
|
|
174
|
+
# anything. Carried forward exactly one deep on purpose: the session whose
|
|
175
|
+
# run vanished needs to be told once, and keeping a chain of them would make
|
|
176
|
+
# this file a log of a thing that is over.
|
|
177
|
+
"lost": _lost(read_run(root)),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _lost(prior):
|
|
182
|
+
"""A prior run that stopped without ever recording, as the little a reader needs.
|
|
183
|
+
|
|
184
|
+
A run whose process is gone and which never finished is the one case the file
|
|
185
|
+
itself cannot report afterwards, because starting the next run overwrites it —
|
|
186
|
+
and that is precisely how a session learns its gates vanished by noticing the
|
|
187
|
+
slot naming somebody else's item.
|
|
188
|
+
"""
|
|
189
|
+
if not prior or prior.get("finished") or _alive(prior.get("pid")):
|
|
190
|
+
return None
|
|
191
|
+
return {k: prior.get(k) for k in
|
|
192
|
+
("task", "session", "machine", "started", "current", "done", "gates")}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _claim(root, name, pid) -> bool:
|
|
196
|
+
"""Take the checkout's one verify slot, or report that somebody holds it.
|
|
197
|
+
|
|
198
|
+
**Created exclusively, so two starts in the same instant cannot both win.** The
|
|
199
|
+
check-then-spawn this replaces left the slot unheld for the whole of a Python
|
|
200
|
+
interpreter's startup — hundreds of milliseconds, not an instant — and two
|
|
201
|
+
sessions polling the same board is exactly the traffic that finds a window that
|
|
202
|
+
wide.
|
|
203
|
+
|
|
204
|
+
A slot held by a run that is over is taken over rather than waited for, which is
|
|
205
|
+
the same fail-open `read_run` takes: a lock that outlives its process would
|
|
206
|
+
strand every future verify in the checkout. Two callers racing to take over one
|
|
207
|
+
DEAD slot can still both proceed, because breaking a stale lock needs an
|
|
208
|
+
authority a file cannot be; that residue is stated rather than pretended away.
|
|
209
|
+
"""
|
|
210
|
+
p = _run_file(root)
|
|
211
|
+
data = _begin(root, name, pid)
|
|
212
|
+
try:
|
|
213
|
+
fd = os.open(p, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
214
|
+
except FileExistsError:
|
|
215
|
+
if live_run(root):
|
|
216
|
+
return False
|
|
217
|
+
_write_run(root, data)
|
|
218
|
+
return True
|
|
219
|
+
except OSError:
|
|
220
|
+
return False
|
|
221
|
+
with os.fdopen(fd, "w") as f:
|
|
222
|
+
f.write(json.dumps(data, indent=2) + "\n")
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
|
|
129
226
|
def _elapsed(data) -> int:
|
|
130
227
|
try:
|
|
131
228
|
return max(0, int((_now() - datetime.fromisoformat(str(data["started"]))).total_seconds()))
|
|
@@ -238,30 +335,24 @@ def _record(root, name, results, ok, sha) -> bool:
|
|
|
238
335
|
return True
|
|
239
336
|
|
|
240
337
|
|
|
241
|
-
def _execute(root, name) -> tuple:
|
|
338
|
+
def _execute(root, name, adopt: bool = False) -> tuple:
|
|
242
339
|
"""Run the gates, tracking them in the run file, and record the outcome.
|
|
243
340
|
|
|
244
341
|
**Both doors go through here**, which is what makes the guard mean anything: a
|
|
245
342
|
blocking run that wrote no run file would be invisible to the next caller, and
|
|
246
343
|
the overlap this exists to prevent is exactly the one nobody declared.
|
|
344
|
+
|
|
345
|
+
`adopt` is the detached child taking over the slot its parent claimed on its
|
|
346
|
+
behalf, rather than writing a second record over it. Recreating it would throw
|
|
347
|
+
away the claim's own start time and the note of whatever run it displaced — and
|
|
348
|
+
would reopen the window the claim exists to close, since between the parent's
|
|
349
|
+
claim and a fresh record there would be a moment holding neither.
|
|
247
350
|
"""
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
"gates": sorted(VERIFY),
|
|
254
|
-
"current": "",
|
|
255
|
-
"done": [],
|
|
256
|
-
"finished": None,
|
|
257
|
-
"passed": False,
|
|
258
|
-
"sha": "",
|
|
259
|
-
"results": [],
|
|
260
|
-
# Whether a finished result has already been handed to a caller. Reading one
|
|
261
|
-
# is what retires it: an agent that fixed something and asked again wants a
|
|
262
|
-
# fresh run, not the answer to the question it asked before the fix.
|
|
263
|
-
"read": False,
|
|
264
|
-
}
|
|
351
|
+
held = read_run(root) if adopt else None
|
|
352
|
+
if held and not held.get("finished"):
|
|
353
|
+
data = dict(held, pid=os.getpid())
|
|
354
|
+
else:
|
|
355
|
+
data = _begin(root, name, os.getpid())
|
|
265
356
|
_write_run(root, data)
|
|
266
357
|
|
|
267
358
|
def progress(gate, so_far):
|
|
@@ -298,6 +389,33 @@ def _print_report(results, sha) -> None:
|
|
|
298
389
|
print(f" {line}")
|
|
299
390
|
|
|
300
391
|
|
|
392
|
+
def _progress(running) -> str:
|
|
393
|
+
"""How far through a run is, as a fraction a reader can act on.
|
|
394
|
+
|
|
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.
|
|
398
|
+
"""
|
|
399
|
+
done, gates = running.get("done") or [], running.get("gates") or []
|
|
400
|
+
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"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _whose(running) -> str:
|
|
406
|
+
"""Who to go and talk to about the run holding the slot, or nothing at all.
|
|
407
|
+
|
|
408
|
+
Nothing at all is a real answer and is left empty rather than filled with a
|
|
409
|
+
hedge: a run recorded by a build that did not note its session, or a client that
|
|
410
|
+
publishes no session list, has nobody a reader could reach, and a line saying so
|
|
411
|
+
is noise on a message that is already refusing something.
|
|
412
|
+
"""
|
|
413
|
+
who = str(running.get("session") or "")
|
|
414
|
+
if not who:
|
|
415
|
+
return ""
|
|
416
|
+
return peers.describe(who, str(running.get("machine") or ""))
|
|
417
|
+
|
|
418
|
+
|
|
301
419
|
def _busy(running) -> str:
|
|
302
420
|
"""Why a caller cannot start a verify, in the words the refused caller needs.
|
|
303
421
|
|
|
@@ -305,12 +423,19 @@ def _busy(running) -> str:
|
|
|
305
423
|
result — one gate's build cleans the directory another's log check is about to
|
|
306
424
|
read — and the message a person meets has to say *that*, because the alternative
|
|
307
425
|
reads as a broken build and teaches them to re-run until it goes green.
|
|
426
|
+
|
|
427
|
+
**It says plainly that nothing is holding your place**, because a refusal that
|
|
428
|
+
only describes the obstacle reads as a queue, and a caller that believes it is
|
|
429
|
+
queued waits for a call that will never come. Holding a place would be a
|
|
430
|
+
scheduler; saying honestly that there is none is a sentence (founder, 2026-09-12).
|
|
308
431
|
"""
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
f"{
|
|
312
|
-
f"
|
|
313
|
-
f"
|
|
432
|
+
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'}"
|
|
436
|
+
+ (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.")
|
|
314
439
|
|
|
315
440
|
|
|
316
441
|
def _describe(data) -> str:
|
|
@@ -326,6 +451,12 @@ def _describe(data) -> str:
|
|
|
326
451
|
return "\n".join([head] + [_failure(r) for r in failed])
|
|
327
452
|
|
|
328
453
|
|
|
454
|
+
#: Every flag `verify` answers to — its own three doors, plus the ones every command
|
|
455
|
+
#: here takes. Anything else is a typo or a caller built against a flag that has been
|
|
456
|
+
#: renamed, and both are told rather than run.
|
|
457
|
+
_DOORS = {"task", "start", "status", "detached", "project", "branch", "host"}
|
|
458
|
+
|
|
459
|
+
|
|
329
460
|
#: How many of a failing gate's kept lines reach a CALLER, as opposed to the run
|
|
330
461
|
#: file. Deliberately smaller: a caller pays for these in context on every failed
|
|
331
462
|
#: verify, and the point here is to say enough that the next step is obvious without
|
|
@@ -373,12 +504,28 @@ def _answer(state, message, data=None) -> int:
|
|
|
373
504
|
structured payload are the same computation rather than two.
|
|
374
505
|
"""
|
|
375
506
|
out = {"state": state, "gate": "", "elapsedSeconds": 0, "gates": [], "done": [],
|
|
376
|
-
"passed": False, "commit": "", "results": [], "message": message
|
|
507
|
+
"passed": False, "commit": "", "results": [], "message": message,
|
|
508
|
+
# WHY there is nothing to report, when there is nothing. One sentence for
|
|
509
|
+
# five causes is what made a held slot, a moved commit and a vanished run
|
|
510
|
+
# all read as "no gates have run" — and three of those are not the
|
|
511
|
+
# caller's doing in any sense.
|
|
512
|
+
"reason": "",
|
|
513
|
+
# The run holding this checkout, which is not necessarily the caller's.
|
|
514
|
+
# Every other field here is about the caller's ITEM; this one is about the
|
|
515
|
+
# CHECKOUT, and keeping them apart in the shape is what stops a true fact
|
|
516
|
+
# about the tree being read as a verdict on the work.
|
|
517
|
+
"run": None}
|
|
377
518
|
out.update(data or {})
|
|
378
519
|
print(json.dumps(out))
|
|
379
520
|
return 0
|
|
380
521
|
|
|
381
522
|
|
|
523
|
+
def _run_out(running) -> dict:
|
|
524
|
+
return {"task": running.get("task") or "", "gate": running.get("current") or "",
|
|
525
|
+
"done": running.get("done") or [], "gates": running.get("gates") or [],
|
|
526
|
+
"elapsedSeconds": _elapsed(running), "by": _whose(running)}
|
|
527
|
+
|
|
528
|
+
|
|
382
529
|
def _results_out(data) -> list:
|
|
383
530
|
return [{"name": r.get("name", ""), "passed": r.get("status") == "PASS",
|
|
384
531
|
"skipped": r.get("status") == "SKIP", "said": r.get("said") or ""}
|
|
@@ -401,42 +548,158 @@ def _verify_report(root, name) -> int:
|
|
|
401
548
|
"""
|
|
402
549
|
running = live_run(root)
|
|
403
550
|
if running:
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
551
|
+
# WHOSE run it is changes the answer, not merely its wording. A run proving
|
|
552
|
+
# somebody else's item leaves this caller with no evidence and no slot, which
|
|
553
|
+
# is a different situation from watching its own gates go — and reporting
|
|
554
|
+
# both as `running` is how a session read a held checkout as its own progress.
|
|
555
|
+
if (running.get("task") or "") == name:
|
|
556
|
+
gate = running.get("current") or "starting"
|
|
557
|
+
done, gates = running.get("done") or [], running.get("gates") or []
|
|
558
|
+
return _answer(
|
|
559
|
+
"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)})
|
|
564
|
+
# Every gate-shaped field stays EMPTY. The caller has no run, and filling
|
|
565
|
+
# them with another item's progress is the misreading this state exists to
|
|
566
|
+
# make impossible.
|
|
567
|
+
#
|
|
568
|
+
# A run of the caller's OWN that vanished is told here too, and this is the
|
|
569
|
+
# branch that matters most: the session whose gates stopped without recording
|
|
570
|
+
# asks, finds the slot naming somebody else's item, and that is the whole of
|
|
571
|
+
# what it had to go on. Being behind a stranger's run and having lost your own
|
|
572
|
+
# are two facts, and it needs both.
|
|
573
|
+
lost = _abandoned(running, name)
|
|
408
574
|
return _answer(
|
|
409
|
-
"
|
|
410
|
-
f"
|
|
411
|
-
{
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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 ""})
|
|
583
|
+
|
|
584
|
+
return _verify_verdict(root, name, read_run(root))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _verify_verdict(root, name, done) -> int:
|
|
588
|
+
"""Where the caller's own item stands, once nothing is running.
|
|
589
|
+
|
|
590
|
+
A finished run answers for the commit it ran at and no other. Once the CODE
|
|
591
|
+
moves, or once its answer has been read, it is history — and the next ask is a
|
|
592
|
+
new run rather than yesterday's verdict handed over again.
|
|
593
|
+
|
|
594
|
+
"Moved" is the completion gate's own definition, not string equality, and the
|
|
595
|
+
difference is not academic: recording a result is itself a board commit, so a run
|
|
596
|
+
in a repo with `git.commit` on always ends at a different HEAD than it started.
|
|
597
|
+
Comparing shas directly meant every finished run looked stale to the very caller
|
|
598
|
+
waiting for it, and the starting door answered "started" forever.
|
|
599
|
+
|
|
600
|
+
**Every way of having no usable result says WHICH, and they are not variations of
|
|
601
|
+
one sentence.** Measured across three sessions in one checkout: a held slot, a
|
|
602
|
+
commit landing mid-run, a run whose process vanished and a result already handed
|
|
603
|
+
over all reported as *no gates have run for this item*. Three of the four are
|
|
604
|
+
nobody's mistake, and a caller told the same thing in all four cases can only
|
|
605
|
+
guess which it is looking at — which is what turned roughly three wasted
|
|
606
|
+
nine-gate runs a day into something nothing caught.
|
|
607
|
+
"""
|
|
608
|
+
mine = done if done and (done.get("task") or "") == name else None
|
|
609
|
+
lost = _abandoned(done, name)
|
|
610
|
+
|
|
611
|
+
if mine and mine.get("finished") and not mine.get("read"):
|
|
612
|
+
sha = mine.get("sha") or ""
|
|
613
|
+
if _still_current(root.parent, sha):
|
|
614
|
+
mine["read"] = True
|
|
615
|
+
_write_run(root, mine)
|
|
616
|
+
return _answer(
|
|
617
|
+
"finished", _describe(mine),
|
|
618
|
+
{"elapsedSeconds": _elapsed(mine), "gates": mine.get("gates") or [],
|
|
619
|
+
"done": mine.get("done") or [], "passed": bool(mine.get("passed")),
|
|
620
|
+
"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"})
|
|
624
|
+
|
|
625
|
+
if lost:
|
|
626
|
+
return _answer("idle", _gone(name, lost),
|
|
627
|
+
{"gates": sorted(VERIFY), "reason": "lost"})
|
|
628
|
+
|
|
629
|
+
if mine and mine.get("read"):
|
|
429
630
|
return _answer(
|
|
430
|
-
"
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
631
|
+
"idle",
|
|
632
|
+
f"The gates for '{name}' finished and that result has already been "
|
|
633
|
+
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"})
|
|
636
|
+
|
|
637
|
+
if done and done.get("finished"):
|
|
638
|
+
return _answer(
|
|
639
|
+
"idle",
|
|
640
|
+
f"No gates have run for '{name or 'no task'}'. The last run in this "
|
|
641
|
+
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"})
|
|
434
644
|
|
|
435
645
|
return _answer(
|
|
436
646
|
"idle",
|
|
437
647
|
f"No gates have run for '{name or 'no task'}' at this commit. Start them "
|
|
438
648
|
f"explicitly — asking never starts a run.",
|
|
439
|
-
{"gates": sorted(VERIFY)})
|
|
649
|
+
{"gates": sorted(VERIFY), "reason": "none"})
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _gone(name, lost) -> str:
|
|
653
|
+
"""A run of the caller's that stopped without recording, in its own words.
|
|
654
|
+
|
|
655
|
+
Said from two places — on its own when nothing is running, and appended to a held
|
|
656
|
+
slot — because the session it is addressed to reaches it either way and the fact
|
|
657
|
+
does not change with the company it keeps.
|
|
658
|
+
"""
|
|
659
|
+
gates, at = lost.get("gates") or [], len(lost.get("done") or []) + 1
|
|
660
|
+
return (f"A verify for '{name or 'no task'}' started and stopped without "
|
|
661
|
+
f"recording anything"
|
|
662
|
+
+ (f" — it was at gate {at} of {len(gates)}" if gates else "")
|
|
663
|
+
+ ". Nothing was written, so there is no result to read: start them again.")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _abandoned(done, name):
|
|
667
|
+
"""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
|
|
672
|
+
lost = (done or {}).get("lost")
|
|
673
|
+
return lost if isinstance(lost, dict) and (lost.get("task") or "") == name else None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _vanished(repo, sha: str) -> bool:
|
|
677
|
+
"""Whether the commit a result named is no longer in this repo at all."""
|
|
678
|
+
return bool(sha) and sha != "no-git" and not git.has_commit(repo, sha)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _stale(repo, done) -> str:
|
|
682
|
+
"""Why a finished result cannot be used, naming both commits.
|
|
683
|
+
|
|
684
|
+
A hash the reader cannot open is worse than no hash, so a commit that a push
|
|
685
|
+
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.
|
|
688
|
+
"""
|
|
689
|
+
sha, head = (done.get("sha") or "")[:12], _head(repo)[:12]
|
|
690
|
+
ran = done.get("head") or ""
|
|
691
|
+
task = done.get("task") or "no task"
|
|
692
|
+
if _vanished(repo, done.get("sha") or ""):
|
|
693
|
+
return (f"The gates for '{task}' passed at {sha}, and that commit is no "
|
|
694
|
+
f"longer in this branch's history — a push rebased it away. The code "
|
|
695
|
+
f"it measured may be untouched, but nothing can be proven against a "
|
|
696
|
+
f"commit that is gone: start them again.")
|
|
697
|
+
drifted = (f" Those gates began at {ran[:12]} and ended at {sha}, so something "
|
|
698
|
+
f"was committed while they were running." if ran and not sha.startswith(ran[:12])
|
|
699
|
+
else "")
|
|
700
|
+
return (f"The gates for '{task}' ran at {sha} and the code has moved since — "
|
|
701
|
+
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}")
|
|
440
703
|
|
|
441
704
|
|
|
442
705
|
def _verify_start(root, name) -> int:
|
|
@@ -448,20 +711,29 @@ def _verify_start(root, name) -> int:
|
|
|
448
711
|
A refusal that names the task holding the checkout is the honest answer, and
|
|
449
712
|
the report door is one call away.
|
|
450
713
|
"""
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
714
|
+
# The slot is taken BEFORE the child is spawned, and by this process, because a
|
|
715
|
+
# child cannot claim anything until a Python interpreter has finished starting —
|
|
716
|
+
# and a slot left open for those few hundred milliseconds is a slot two polling
|
|
717
|
+
# 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 {}
|
|
720
|
+
# `busy` and never `running`: this caller has no run, and the fields that
|
|
721
|
+
# 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)})
|
|
723
|
+
|
|
724
|
+
try:
|
|
725
|
+
subprocess.Popen(
|
|
726
|
+
[sys.executable, os.path.abspath(sys.argv[0]), "verify", "--task", name,
|
|
727
|
+
"--detached"],
|
|
728
|
+
cwd=root.parent, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
729
|
+
stderr=subprocess.DEVNULL, start_new_session=True)
|
|
730
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
731
|
+
# Nothing is going to run, so the slot is given back rather than left to
|
|
732
|
+
# expire with this process — a checkout that reads as busy for no reason is
|
|
733
|
+
# the failure this whole item is about.
|
|
734
|
+
_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"})
|
|
465
737
|
return _answer(
|
|
466
738
|
"started",
|
|
467
739
|
f"Gates started ({len(VERIFY)}): {', '.join(sorted(VERIFY))}. They take "
|
|
@@ -472,20 +744,39 @@ def _verify_start(root, name) -> int:
|
|
|
472
744
|
def cmd_verify(args) -> int:
|
|
473
745
|
"""Execute the verify commands and RECORD the result against the task.
|
|
474
746
|
|
|
475
|
-
Four doors, and **
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
`--
|
|
747
|
+
Four doors, and **each is named for whether it starts anything.**
|
|
748
|
+
|
|
749
|
+
| door | starts a run |
|
|
750
|
+
|---|---|
|
|
751
|
+
| no flag | yes, here, printing the table when it is done |
|
|
752
|
+
| `--start` | yes, in the background, answering at once |
|
|
753
|
+
| `--status` | no — it only says where the run stands |
|
|
754
|
+
| `--detached` | the child `--start` spawns, and nobody else's to type |
|
|
755
|
+
|
|
756
|
+
The flag that starts in the background was called `--async` and is renamed
|
|
757
|
+
rather than aliased. It only ever started a run, but it READ like "ask me in the
|
|
758
|
+
background" — and a session polling with it set a fresh nine-gate run going every
|
|
759
|
+
time the previous one ended. `--start` cannot be misread, and matches what the
|
|
760
|
+
tool surface has always called the same thing.
|
|
481
761
|
|
|
482
|
-
|
|
483
|
-
the ambiguity cost more than it saved: a session polling for a result
|
|
484
|
-
the run it was waiting for, and one call attached to a run belonging to
|
|
762
|
+
Starting and asking used to be ONE call that started or attached depending on
|
|
763
|
+
timing, and the ambiguity cost more than it saved: a session polling for a result
|
|
764
|
+
started the run it was waiting for, and one call attached to a run belonging to
|
|
485
765
|
another task and would have reported that verdict here.
|
|
486
766
|
"""
|
|
487
767
|
root = find_work_root()
|
|
488
768
|
name = (args.get("task") or "").strip()
|
|
769
|
+
# A flag this door does not know is REFUSED rather than ignored. The parser turns
|
|
770
|
+
# any `--word` into a flag, so an unread one fell through to the blocking door —
|
|
771
|
+
# meaning a caller that typed the wrong thing silently waited minutes for a run
|
|
772
|
+
# it never asked to start, which is the surprise every door here is being made to
|
|
773
|
+
# stop having. A typo and a renamed flag both land here and both say so.
|
|
774
|
+
unknown = sorted(set(args) - _DOORS)
|
|
775
|
+
if unknown:
|
|
776
|
+
die(f"jarvis work verify: unknown flag(s) {', '.join('--' + f for f in unknown)} "
|
|
777
|
+
f"— it takes a name, and one of --start (start them in the background), "
|
|
778
|
+
f"--status (only say where a run stands) or no flag at all (start them "
|
|
779
|
+
f"here and wait).")
|
|
489
780
|
if not VERIFY:
|
|
490
781
|
print("no `verify` commands configured — set them in "
|
|
491
782
|
".claude/work.config.json so completion has something to prove "
|
|
@@ -493,21 +784,22 @@ def cmd_verify(args) -> int:
|
|
|
493
784
|
return 1
|
|
494
785
|
|
|
495
786
|
if args.get("detached"):
|
|
496
|
-
_execute(root, name)
|
|
787
|
+
_execute(root, name, adopt=True)
|
|
497
788
|
return 0
|
|
498
789
|
|
|
499
790
|
if args.get("status"):
|
|
500
791
|
return _verify_report(root, name)
|
|
501
792
|
|
|
502
|
-
if args.get("
|
|
793
|
+
if args.get("start"):
|
|
503
794
|
return _verify_start(root, name)
|
|
504
795
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
796
|
+
# The person's door claims the slot the same way the background one does, so
|
|
797
|
+
# 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)
|
|
508
800
|
return 1
|
|
509
801
|
|
|
510
|
-
results, ok, sha = _execute(root, name)
|
|
802
|
+
results, ok, sha = _execute(root, name, adopt=True)
|
|
511
803
|
_print_report(results, sha)
|
|
512
804
|
if name:
|
|
513
805
|
print(f" recorded on '{name}'")
|
package/harness/harness/git.py
CHANGED
|
@@ -590,6 +590,21 @@ def _left_alone(theirs) -> str:
|
|
|
590
590
|
"to land.")
|
|
591
591
|
|
|
592
592
|
|
|
593
|
+
def has_commit(repo, sha: str) -> bool:
|
|
594
|
+
"""Is `sha` still a commit this repo holds?
|
|
595
|
+
|
|
596
|
+
A push that rebases rewrites the commits it moves, so a sha a gate run recorded
|
|
597
|
+
can stop existing while the code it measured is untouched. Everything that reads
|
|
598
|
+
a recorded sha needs to tell that apart from the code having changed: they are
|
|
599
|
+
opposite situations and the first one cannot be diagnosed from a hash a reader
|
|
600
|
+
cannot open.
|
|
601
|
+
"""
|
|
602
|
+
if not sha or sha == "no-git":
|
|
603
|
+
return False
|
|
604
|
+
code, _, _ = _git(repo, "cat-file", "-e", f"{sha}^{{commit}}")
|
|
605
|
+
return code == 0
|
|
606
|
+
|
|
607
|
+
|
|
593
608
|
def only_board_moved(repo, sha: str) -> bool:
|
|
594
609
|
"""Is everything between `sha` and HEAD a board file?
|
|
595
610
|
|
|
@@ -441,7 +441,7 @@ the only way forward has hit a real gap: **park a question, do not shell out.**
|
|
|
441
441
|
| Create a task | `jarvis work new <name> --epic <e> [--priority] [--depends] [--owner] [--code] [--covers]` | either — `work_create` is the tool |
|
|
442
442
|
| List | `jarvis work list` | either — `work_list` is the tool |
|
|
443
443
|
| Pick up / complete | `jarvis work move <name> in-progress\|complete --delivered "…" --not-included "…"` | either — `work_take` / `work_complete` |
|
|
444
|
-
| Prove it | `jarvis work verify
|
|
444
|
+
| Prove it | `jarvis work verify <name>` (blocks) · `--start` (what the tool uses) · `--status` (asks, starts nothing) | either — `work_verify` |
|
|
445
445
|
| Record an approved plan | `jarvis work plan <name> [--file <path>]` | either |
|
|
446
446
|
| Hand off | `jarvis work handoff <name>`, then fill it | either — **no tool yet**; the prose is judgement |
|
|
447
447
|
| Open the NEXT session | `jarvis work kickoff <name>` — never hand-write the prompt | either — `work_wrap` embeds it |
|