@appchy/jarvis 0.1.120 → 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 +15 -8
- package/dist/bin.js.map +1 -1
- package/harness/harness/gate.py +436 -97
- package/harness/harness/git.py +15 -0
- package/harness/harness/peers.py +42 -27
- package/harness/presets/appchy/PRESET.md +1 -1
- package/harness/test_work.py +337 -2
- package/harness/work.py +9 -6
- package/package.json +1 -1
package/harness/harness/gate.py
CHANGED
|
@@ -68,18 +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
|
"""
|
|
78
|
-
|
|
79
|
-
os.kill(int(pid), 0)
|
|
80
|
-
except (OSError, TypeError, ValueError):
|
|
81
|
-
return False
|
|
82
|
-
return True
|
|
79
|
+
return peers.alive(pid)
|
|
83
80
|
|
|
84
81
|
|
|
85
82
|
def read_run(root):
|
|
@@ -120,12 +117,115 @@ def _write_run(root, data) -> None:
|
|
|
120
117
|
p = _run_file(root)
|
|
121
118
|
tmp = p.with_suffix(".tmp")
|
|
122
119
|
try:
|
|
123
|
-
tmp.write_text(
|
|
120
|
+
tmp.write_text(_encode(data))
|
|
124
121
|
os.replace(tmp, p)
|
|
125
122
|
except OSError:
|
|
126
123
|
pass
|
|
127
124
|
|
|
128
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
|
+
|
|
133
|
+
def _begin(root, name, pid) -> dict:
|
|
134
|
+
"""The record a run declares itself with, written before the first gate.
|
|
135
|
+
|
|
136
|
+
`session` and `head` are here so the two doors can answer questions the file
|
|
137
|
+
could not: who to go and talk to, and whether the ground moved while the gates
|
|
138
|
+
were running. Neither is load-bearing — a missing session degrades to saying
|
|
139
|
+
nothing about who, which is what an enrichment is allowed to do.
|
|
140
|
+
"""
|
|
141
|
+
return {
|
|
142
|
+
"task": name,
|
|
143
|
+
"pid": pid,
|
|
144
|
+
"machine": peers.here(),
|
|
145
|
+
# The run belongs to a SESSION, not to the pid: the pid is a detached child
|
|
146
|
+
# that exits, and the session waiting for the answer is what outlives it.
|
|
147
|
+
"session": peers.me(),
|
|
148
|
+
"started": _now().isoformat(),
|
|
149
|
+
# The commit the gates STARTED on. `sha` below is where they ended, which is
|
|
150
|
+
# what the evidence records — and the two differ exactly when somebody
|
|
151
|
+
# committed mid-run, which is the thing nobody could see.
|
|
152
|
+
"head": _head(root.parent),
|
|
153
|
+
"gates": sorted(VERIFY),
|
|
154
|
+
"current": "",
|
|
155
|
+
"done": [],
|
|
156
|
+
"finished": None,
|
|
157
|
+
"passed": False,
|
|
158
|
+
"sha": "",
|
|
159
|
+
"results": [],
|
|
160
|
+
# Whether a finished result has already been handed to a caller. Reading one
|
|
161
|
+
# is what retires it: an agent that fixed something and asked again wants a
|
|
162
|
+
# fresh run, not the answer to the question it asked before the fix.
|
|
163
|
+
"read": False,
|
|
164
|
+
# The run this one replaced, when that run stopped without recording
|
|
165
|
+
# anything. Carried forward exactly one deep on purpose: the session whose
|
|
166
|
+
# run vanished needs to be told once, and keeping a chain of them would make
|
|
167
|
+
# this file a log of a thing that is over.
|
|
168
|
+
"lost": _lost(read_run(root)),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _lost(prior):
|
|
173
|
+
"""A prior run that stopped without ever recording, as the little a reader needs.
|
|
174
|
+
|
|
175
|
+
A run whose process is gone and which never finished is the one case the file
|
|
176
|
+
itself cannot report afterwards, because starting the next run overwrites it —
|
|
177
|
+
and that is precisely how a session learns its gates vanished by noticing the
|
|
178
|
+
slot naming somebody else's item.
|
|
179
|
+
"""
|
|
180
|
+
if not prior or prior.get("finished") or _alive(prior.get("pid")):
|
|
181
|
+
return None
|
|
182
|
+
return {k: prior.get(k) for k in
|
|
183
|
+
("task", "session", "machine", "started", "current", "done", "gates")}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _claim(root, name, pid) -> tuple:
|
|
187
|
+
"""Take the checkout's one verify slot, or report that somebody holds it.
|
|
188
|
+
|
|
189
|
+
**Created exclusively, so two starts in the same instant cannot both win.** The
|
|
190
|
+
check-then-spawn this replaces left the slot unheld for the whole of a Python
|
|
191
|
+
interpreter's startup — hundreds of milliseconds, not an instant — and two
|
|
192
|
+
sessions polling the same board is exactly the traffic that finds a window that
|
|
193
|
+
wide.
|
|
194
|
+
|
|
195
|
+
A slot held by a run that is over is taken over rather than waited for, which is
|
|
196
|
+
the same fail-open `read_run` takes: a lock that outlives its process would
|
|
197
|
+
strand every future verify in the checkout. Two callers racing to take over one
|
|
198
|
+
DEAD slot can still both proceed, because breaking a stale lock needs an
|
|
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.
|
|
211
|
+
"""
|
|
212
|
+
p = _run_file(root)
|
|
213
|
+
try:
|
|
214
|
+
fd = os.open(p, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
215
|
+
except FileExistsError:
|
|
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
|
|
224
|
+
with os.fdopen(fd, "w") as f:
|
|
225
|
+
f.write(_encode(_begin(root, name, pid)))
|
|
226
|
+
return True, None
|
|
227
|
+
|
|
228
|
+
|
|
129
229
|
def _elapsed(data) -> int:
|
|
130
230
|
try:
|
|
131
231
|
return max(0, int((_now() - datetime.fromisoformat(str(data["started"]))).total_seconds()))
|
|
@@ -238,30 +338,24 @@ def _record(root, name, results, ok, sha) -> bool:
|
|
|
238
338
|
return True
|
|
239
339
|
|
|
240
340
|
|
|
241
|
-
def _execute(root, name) -> tuple:
|
|
341
|
+
def _execute(root, name, adopt: bool = False) -> tuple:
|
|
242
342
|
"""Run the gates, tracking them in the run file, and record the outcome.
|
|
243
343
|
|
|
244
344
|
**Both doors go through here**, which is what makes the guard mean anything: a
|
|
245
345
|
blocking run that wrote no run file would be invisible to the next caller, and
|
|
246
346
|
the overlap this exists to prevent is exactly the one nobody declared.
|
|
347
|
+
|
|
348
|
+
`adopt` is the detached child taking over the slot its parent claimed on its
|
|
349
|
+
behalf, rather than writing a second record over it. Recreating it would throw
|
|
350
|
+
away the claim's own start time and the note of whatever run it displaced — and
|
|
351
|
+
would reopen the window the claim exists to close, since between the parent's
|
|
352
|
+
claim and a fresh record there would be a moment holding neither.
|
|
247
353
|
"""
|
|
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
|
-
}
|
|
354
|
+
held = read_run(root) if adopt else None
|
|
355
|
+
if held and not held.get("finished"):
|
|
356
|
+
data = dict(held, pid=os.getpid())
|
|
357
|
+
else:
|
|
358
|
+
data = _begin(root, name, os.getpid())
|
|
265
359
|
_write_run(root, data)
|
|
266
360
|
|
|
267
361
|
def progress(gate, so_far):
|
|
@@ -275,6 +369,12 @@ def _execute(root, name) -> tuple:
|
|
|
275
369
|
finished=_now().isoformat(), passed=ok, sha=sha,
|
|
276
370
|
results=[{"name": n, "status": s, "said": d, "log": g}
|
|
277
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
|
|
278
378
|
_write_run(root, data)
|
|
279
379
|
|
|
280
380
|
if name and not _record(root, name, results, ok, sha):
|
|
@@ -298,19 +398,68 @@ def _print_report(results, sha) -> None:
|
|
|
298
398
|
print(f" {line}")
|
|
299
399
|
|
|
300
400
|
|
|
301
|
-
def
|
|
302
|
-
"""
|
|
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.
|
|
404
|
+
|
|
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.
|
|
408
|
+
"""
|
|
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."""
|
|
415
|
+
gate = running.get("current") or ""
|
|
416
|
+
return (f"{_where(running) or 'starting'}{f' ({gate})' if gate else ''}, "
|
|
417
|
+
f"{_elapsed(running)}s in")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _whose(running) -> str:
|
|
421
|
+
"""Who to go and talk to about the run holding the slot, or nothing at all.
|
|
422
|
+
|
|
423
|
+
Nothing at all is a real answer and is left empty rather than filled with a
|
|
424
|
+
hedge: a run recorded by a build that did not note its session, or a client that
|
|
425
|
+
publishes no session list, has nobody a reader could reach, and a line saying so
|
|
426
|
+
is noise on a message that is already refusing something.
|
|
427
|
+
"""
|
|
428
|
+
who = str(running.get("session") or "")
|
|
429
|
+
if not who:
|
|
430
|
+
return ""
|
|
431
|
+
return peers.describe(who, str(running.get("machine") or ""))
|
|
432
|
+
|
|
433
|
+
|
|
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.
|
|
303
442
|
|
|
304
443
|
Never phrased as a failure. Two runs in one checkout corrupt each other's
|
|
305
444
|
result — one gate's build cleans the directory another's log check is about to
|
|
306
445
|
read — and the message a person meets has to say *that*, because the alternative
|
|
307
446
|
reads as a broken build and teaches them to re-run until it goes green.
|
|
447
|
+
|
|
448
|
+
**It says plainly that nothing is holding your place**, because a refusal that
|
|
449
|
+
only describes the obstacle reads as a queue, and a caller that believes it is
|
|
450
|
+
queued waits for a call that will never come. Holding a place would be a
|
|
451
|
+
scheduler; saying honestly that there is none is a sentence (founder, 2026-09-12).
|
|
308
452
|
"""
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
453
|
+
who = _whose(running)
|
|
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'}"
|
|
461
|
+
+ (f", started by {who}" if who else "")
|
|
462
|
+
+ f". {outcome} Nothing is holding your place — ask again, and start it then.")
|
|
314
463
|
|
|
315
464
|
|
|
316
465
|
def _describe(data) -> str:
|
|
@@ -326,6 +475,12 @@ def _describe(data) -> str:
|
|
|
326
475
|
return "\n".join([head] + [_failure(r) for r in failed])
|
|
327
476
|
|
|
328
477
|
|
|
478
|
+
#: Every flag `verify` answers to — its own three doors, plus the ones every command
|
|
479
|
+
#: here takes. Anything else is a typo or a caller built against a flag that has been
|
|
480
|
+
#: renamed, and both are told rather than run.
|
|
481
|
+
_DOORS = {"task", "start", "status", "detached", "project", "branch", "host"}
|
|
482
|
+
|
|
483
|
+
|
|
329
484
|
#: How many of a failing gate's kept lines reach a CALLER, as opposed to the run
|
|
330
485
|
#: file. Deliberately smaller: a caller pays for these in context on every failed
|
|
331
486
|
#: verify, and the point here is to say enough that the next step is obvious without
|
|
@@ -373,12 +528,45 @@ def _answer(state, message, data=None) -> int:
|
|
|
373
528
|
structured payload are the same computation rather than two.
|
|
374
529
|
"""
|
|
375
530
|
out = {"state": state, "gate": "", "elapsedSeconds": 0, "gates": [], "done": [],
|
|
376
|
-
"passed": False, "commit": "", "results": [], "message": message
|
|
531
|
+
"passed": False, "commit": "", "results": [], "message": message,
|
|
532
|
+
# WHY there is nothing to report, when there is nothing. One sentence for
|
|
533
|
+
# five causes is what made a held slot, a moved commit and a vanished run
|
|
534
|
+
# all read as "no gates have run" — and three of those are not the
|
|
535
|
+
# caller's doing in any sense.
|
|
536
|
+
"reason": "",
|
|
537
|
+
# The run holding this checkout, which is not necessarily the caller's.
|
|
538
|
+
# Every other field here is about the caller's ITEM; this one is about the
|
|
539
|
+
# CHECKOUT, and keeping them apart in the shape is what stops a true fact
|
|
540
|
+
# about the tree being read as a verdict on the work.
|
|
541
|
+
"run": None}
|
|
377
542
|
out.update(data or {})
|
|
378
543
|
print(json.dumps(out))
|
|
379
544
|
return 0
|
|
380
545
|
|
|
381
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
|
+
|
|
564
|
+
def _run_out(running) -> dict:
|
|
565
|
+
return {"task": running.get("task") or "", "gate": running.get("current") or "",
|
|
566
|
+
"done": running.get("done") or [], "gates": running.get("gates") or [],
|
|
567
|
+
"elapsedSeconds": _elapsed(running), "by": _whose(running)}
|
|
568
|
+
|
|
569
|
+
|
|
382
570
|
def _results_out(data) -> list:
|
|
383
571
|
return [{"name": r.get("name", ""), "passed": r.get("status") == "PASS",
|
|
384
572
|
"skipped": r.get("status") == "SKIP", "said": r.get("said") or ""}
|
|
@@ -401,42 +589,157 @@ def _verify_report(root, name) -> int:
|
|
|
401
589
|
"""
|
|
402
590
|
running = live_run(root)
|
|
403
591
|
if running:
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
return _answer(
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
592
|
+
# WHOSE run it is changes the answer, not merely its wording. A run proving
|
|
593
|
+
# somebody else's item leaves this caller with no evidence and no slot, which
|
|
594
|
+
# is a different situation from watching its own gates go — and reporting
|
|
595
|
+
# both as `running` is how a session read a held checkout as its own progress.
|
|
596
|
+
if (running.get("task") or "") == name:
|
|
597
|
+
out = _run_out(running)
|
|
598
|
+
done, gates = out["done"], out["gates"]
|
|
599
|
+
return _answer(
|
|
600
|
+
"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})
|
|
606
|
+
# Every gate-shaped field stays EMPTY. The caller has no run, and filling
|
|
607
|
+
# them with another item's progress is the misreading this state exists to
|
|
608
|
+
# make impossible.
|
|
609
|
+
#
|
|
610
|
+
# A run of the caller's OWN that vanished is told here too, and this is the
|
|
611
|
+
# branch that matters most: the session whose gates stopped without recording
|
|
612
|
+
# asks, finds the slot naming somebody else's item, and that is the whole of
|
|
613
|
+
# what it had to go on. Being behind a stranger's run and having lost your own
|
|
614
|
+
# are two facts, and it needs both.
|
|
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 ""})
|
|
619
|
+
|
|
620
|
+
return _verify_verdict(root, name, read_run(root))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _verify_verdict(root, name, done) -> int:
|
|
624
|
+
"""Where the caller's own item stands, once nothing is running.
|
|
625
|
+
|
|
626
|
+
A finished run answers for the commit it ran at and no other. Once the CODE
|
|
627
|
+
moves, or once its answer has been read, it is history — and the next ask is a
|
|
628
|
+
new run rather than yesterday's verdict handed over again.
|
|
629
|
+
|
|
630
|
+
"Moved" is the completion gate's own definition, not string equality, and the
|
|
631
|
+
difference is not academic: recording a result is itself a board commit, so a run
|
|
632
|
+
in a repo with `git.commit` on always ends at a different HEAD than it started.
|
|
633
|
+
Comparing shas directly meant every finished run looked stale to the very caller
|
|
634
|
+
waiting for it, and the starting door answered "started" forever.
|
|
635
|
+
|
|
636
|
+
**Every way of having no usable result says WHICH, and they are not variations of
|
|
637
|
+
one sentence.** Measured across three sessions in one checkout: a held slot, a
|
|
638
|
+
commit landing mid-run, a run whose process vanished and a result already handed
|
|
639
|
+
over all reported as *no gates have run for this item*. Three of the four are
|
|
640
|
+
nobody's mistake, and a caller told the same thing in all four cases can only
|
|
641
|
+
guess which it is looking at — which is what turned roughly three wasted
|
|
642
|
+
nine-gate runs a day into something nothing caught.
|
|
643
|
+
"""
|
|
644
|
+
mine = done if done and (done.get("task") or "") == name else None
|
|
645
|
+
lost = _abandoned(done, mine, name)
|
|
646
|
+
|
|
647
|
+
if mine and mine.get("finished") and not mine.get("read"):
|
|
648
|
+
sha = mine.get("sha") or ""
|
|
649
|
+
if _still_current(root.parent, sha):
|
|
650
|
+
mine["read"] = True
|
|
651
|
+
_write_run(root, mine)
|
|
652
|
+
return _answer(
|
|
653
|
+
"finished", _describe(mine),
|
|
654
|
+
{"elapsedSeconds": _elapsed(mine), "gates": mine.get("gates") or [],
|
|
655
|
+
"done": mine.get("done") or [], "passed": bool(mine.get("passed")),
|
|
656
|
+
"commit": sha, "results": _results_out(mine)})
|
|
657
|
+
said, why = _stale(root.parent, mine)
|
|
658
|
+
return _answer("idle", said,
|
|
659
|
+
{"commit": sha, "gates": sorted(VERIFY), "reason": why})
|
|
660
|
+
|
|
661
|
+
if lost:
|
|
662
|
+
return _idle(_gone(name, lost), "lost")
|
|
663
|
+
|
|
664
|
+
if mine and mine.get("read"):
|
|
665
|
+
return _idle(
|
|
666
|
+
f"The gates for '{name}' finished and that result has already been "
|
|
667
|
+
f"reported once — asking again does not re-run them. Start them again to "
|
|
668
|
+
f"prove the code as it stands now.", "read")
|
|
669
|
+
|
|
670
|
+
if done and done.get("finished"):
|
|
671
|
+
return _idle(
|
|
672
|
+
f"No gates have run for '{name or 'no task'}'. The last run in this "
|
|
673
|
+
f"checkout proved '{done.get('task') or 'no task'}', which says nothing "
|
|
674
|
+
f"about yours. Start them explicitly — asking never starts a run.", "other")
|
|
675
|
+
|
|
676
|
+
return _idle(
|
|
437
677
|
f"No gates have run for '{name or 'no task'}' at this commit. Start them "
|
|
438
|
-
f"explicitly — asking never starts a run.",
|
|
439
|
-
|
|
678
|
+
f"explicitly — asking never starts a run.", "none")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _gone(name, lost) -> str:
|
|
682
|
+
"""A run of the caller's that stopped without recording, in its own words.
|
|
683
|
+
|
|
684
|
+
Said from two places — on its own when nothing is running, and appended to a held
|
|
685
|
+
slot — because the session it is addressed to reaches it either way and the fact
|
|
686
|
+
does not change with the company it keeps.
|
|
687
|
+
"""
|
|
688
|
+
where = _where(lost)
|
|
689
|
+
return (f"A verify for '{name or 'no task'}' started and stopped without "
|
|
690
|
+
f"recording anything"
|
|
691
|
+
+ (f" — it was at {where}" if where else "")
|
|
692
|
+
+ ". Nothing was written, so there is no result to read: start them again.")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _abandoned(done, mine, name):
|
|
696
|
+
"""A run for `name` that stopped without recording — whether it is still the run
|
|
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
|
|
706
|
+
lost = (done or {}).get("lost")
|
|
707
|
+
return lost if isinstance(lost, dict) and (lost.get("task") or "") == name else None
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _vanished(repo, sha: str) -> bool:
|
|
711
|
+
"""Whether the commit a result named is no longer in this repo at all."""
|
|
712
|
+
return bool(sha) and sha != "no-git" and not git.has_commit(repo, sha)
|
|
713
|
+
|
|
714
|
+
|
|
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.
|
|
723
|
+
|
|
724
|
+
A hash the reader cannot open is worse than no hash, so a commit that a push
|
|
725
|
+
rebased out of the history is said to be GONE rather than reported as the code
|
|
726
|
+
having moved: the code may well be identical, and the two call for different next
|
|
727
|
+
steps.
|
|
728
|
+
"""
|
|
729
|
+
sha, head = (done.get("sha") or "")[:12], _head(repo)[:12]
|
|
730
|
+
ran = done.get("head") or ""
|
|
731
|
+
task = done.get("task") or "no task"
|
|
732
|
+
if _vanished(repo, done.get("sha") or ""):
|
|
733
|
+
return (f"The gates for '{task}' passed at {sha}, and that commit is no "
|
|
734
|
+
f"longer in this branch's history — a push rebased it away. The code "
|
|
735
|
+
f"it measured may be untouched, but nothing can be proven against a "
|
|
736
|
+
f"commit that is gone: start them again."), "gone"
|
|
737
|
+
drifted = (f" Those gates began at {ran[:12]} and ended at {sha}, so something "
|
|
738
|
+
f"was committed while they were running." if ran and not sha.startswith(ran[:12])
|
|
739
|
+
else "")
|
|
740
|
+
return (f"The gates for '{task}' ran at {sha} and the code has moved since — "
|
|
741
|
+
f"HEAD is {head}. That result does not describe what is in the tree now, "
|
|
742
|
+
f"so it cannot be used: start them again.{drifted}"), "moved"
|
|
440
743
|
|
|
441
744
|
|
|
442
745
|
def _verify_start(root, name) -> int:
|
|
@@ -448,20 +751,31 @@ def _verify_start(root, name) -> int:
|
|
|
448
751
|
A refusal that names the task holding the checkout is the honest answer, and
|
|
449
752
|
the report door is one call away.
|
|
450
753
|
"""
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
754
|
+
# The slot is taken BEFORE the child is spawned, and by this process, because a
|
|
755
|
+
# child cannot claim anything until a Python interpreter has finished starting —
|
|
756
|
+
# and a slot left open for those few hundred milliseconds is a slot two polling
|
|
757
|
+
# sessions will both walk through. The child adopts it and stamps its own pid.
|
|
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:
|
|
763
|
+
# `busy` and never `running`: this caller has no run, and the fields that
|
|
764
|
+
# would describe one stay empty so the slot's state cannot be read as its own.
|
|
765
|
+
return _answer("busy", _busy(held, asked=name), {"run": _run_out(held)})
|
|
766
|
+
|
|
767
|
+
try:
|
|
768
|
+
subprocess.Popen(
|
|
769
|
+
[sys.executable, os.path.abspath(sys.argv[0]), "verify", "--task", name,
|
|
770
|
+
"--detached"],
|
|
771
|
+
cwd=root.parent, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
772
|
+
stderr=subprocess.DEVNULL, start_new_session=True)
|
|
773
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
774
|
+
# Nothing is going to run, so the slot is given back rather than left to
|
|
775
|
+
# expire with this process — a checkout that reads as busy for no reason is
|
|
776
|
+
# the failure this whole item is about.
|
|
777
|
+
_run_file(root).unlink(missing_ok=True)
|
|
778
|
+
return _idle(f"The gates could not be started: {e}", "none")
|
|
465
779
|
return _answer(
|
|
466
780
|
"started",
|
|
467
781
|
f"Gates started ({len(VERIFY)}): {', '.join(sorted(VERIFY))}. They take "
|
|
@@ -472,20 +786,39 @@ def _verify_start(root, name) -> int:
|
|
|
472
786
|
def cmd_verify(args) -> int:
|
|
473
787
|
"""Execute the verify commands and RECORD the result against the task.
|
|
474
788
|
|
|
475
|
-
Four doors, and **
|
|
476
|
-
Blocking is the person's: typing the command is the explicit act, so it runs
|
|
477
|
-
the gates and prints the table. `--async` starts a background run for the tool
|
|
478
|
-
surface, because a call that takes 174 seconds cannot be a relayed round trip.
|
|
479
|
-
`--status` only reports, and is what a caller waiting for a result uses.
|
|
480
|
-
`--detached` is the child `--async` spawns and is nobody's to type.
|
|
789
|
+
Four doors, and **each is named for whether it starts anything.**
|
|
481
790
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
791
|
+
| door | starts a run |
|
|
792
|
+
|---|---|
|
|
793
|
+
| no flag | yes, here, printing the table when it is done |
|
|
794
|
+
| `--start` | yes, in the background, answering at once |
|
|
795
|
+
| `--status` | no — it only says where the run stands |
|
|
796
|
+
| `--detached` | the child `--start` spawns, and nobody else's to type |
|
|
797
|
+
|
|
798
|
+
The flag that starts in the background was called `--async` and is renamed
|
|
799
|
+
rather than aliased. It only ever started a run, but it READ like "ask me in the
|
|
800
|
+
background" — and a session polling with it set a fresh nine-gate run going every
|
|
801
|
+
time the previous one ended. `--start` cannot be misread, and matches what the
|
|
802
|
+
tool surface has always called the same thing.
|
|
803
|
+
|
|
804
|
+
Starting and asking used to be ONE call that started or attached depending on
|
|
805
|
+
timing, and the ambiguity cost more than it saved: a session polling for a result
|
|
806
|
+
started the run it was waiting for, and one call attached to a run belonging to
|
|
485
807
|
another task and would have reported that verdict here.
|
|
486
808
|
"""
|
|
487
809
|
root = find_work_root()
|
|
488
810
|
name = (args.get("task") or "").strip()
|
|
811
|
+
# A flag this door does not know is REFUSED rather than ignored. The parser turns
|
|
812
|
+
# any `--word` into a flag, so an unread one fell through to the blocking door —
|
|
813
|
+
# meaning a caller that typed the wrong thing silently waited minutes for a run
|
|
814
|
+
# it never asked to start, which is the surprise every door here is being made to
|
|
815
|
+
# stop having. A typo and a renamed flag both land here and both say so.
|
|
816
|
+
unknown = sorted(set(args) - _DOORS)
|
|
817
|
+
if unknown:
|
|
818
|
+
die(f"jarvis work verify: unknown flag(s) {', '.join('--' + f for f in unknown)} "
|
|
819
|
+
f"— it takes a name, and one of --start (start them in the background), "
|
|
820
|
+
f"--status (only say where a run stands) or no flag at all (start them "
|
|
821
|
+
f"here and wait).")
|
|
489
822
|
if not VERIFY:
|
|
490
823
|
print("no `verify` commands configured — set them in "
|
|
491
824
|
".claude/work.config.json so completion has something to prove "
|
|
@@ -493,21 +826,27 @@ def cmd_verify(args) -> int:
|
|
|
493
826
|
return 1
|
|
494
827
|
|
|
495
828
|
if args.get("detached"):
|
|
496
|
-
_execute(root, name)
|
|
829
|
+
_execute(root, name, adopt=True)
|
|
497
830
|
return 0
|
|
498
831
|
|
|
499
832
|
if args.get("status"):
|
|
500
833
|
return _verify_report(root, name)
|
|
501
834
|
|
|
502
|
-
if args.get("
|
|
835
|
+
if args.get("start"):
|
|
503
836
|
return _verify_start(root, name)
|
|
504
837
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
838
|
+
# The person's door claims the slot the same way the background one does, so
|
|
839
|
+
# neither can be the hole in a guard the other honours.
|
|
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)
|
|
508
847
|
return 1
|
|
509
848
|
|
|
510
|
-
results, ok, sha = _execute(root, name)
|
|
849
|
+
results, ok, sha = _execute(root, name, adopt=True)
|
|
511
850
|
_print_report(results, sha)
|
|
512
851
|
if name:
|
|
513
852
|
print(f" recorded on '{name}'")
|