@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.
@@ -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
- data = {
249
- "task": name,
250
- "pid": os.getpid(),
251
- "machine": peers.here(),
252
- "started": _now().isoformat(),
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
- return (f"a verify is already running here — '{running.get('task') or 'no task'}' "
310
- f"on {running.get('machine') or 'this machine'}, "
311
- f"{_elapsed(running)}s ago (pid {running.get('pid')}). "
312
- f"Two at once corrupt each other's result, so this one did not start. "
313
- f"Wait for it, or ask again.")
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
- gate = running.get("current") or "starting"
405
- done, gates = running.get("done") or [], running.get("gates") or []
406
- for_task = running.get("task") or ""
407
- whose = "" if for_task == name else f" (started for '{for_task or 'no task'}')"
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
- "running",
410
- f"running {_elapsed(running)}s: {gate}. {len(done)} of {len(gates)} done.{whose}",
411
- {"gate": gate, "elapsedSeconds": _elapsed(running), "gates": gates,
412
- "done": done, "results": _results_out(running)})
413
-
414
- done = read_run(root)
415
- # A finished run answers for the commit it ran at and no other. Once the CODE
416
- # moves, or once its answer has been read, it is history — and the next ask is a
417
- # new run rather than yesterday's verdict handed over again.
418
- #
419
- # "Moved" is the completion gate's own definition, not string equality, and the
420
- # difference is not academic: recording a result is itself a board commit, so a
421
- # run in a repo with `git.commit` on always ends at a different HEAD than it
422
- # started. Comparing shas directly meant every finished run looked stale to the
423
- # very caller waiting for it, and `--async` answered "started" forever.
424
- if (done and done.get("finished") and not done.get("read")
425
- and done.get("task") == name
426
- and _still_current(root.parent, done.get("sha") or "")):
427
- done["read"] = True
428
- _write_run(root, done)
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
- "finished", _describe(done),
431
- {"elapsedSeconds": _elapsed(done), "gates": done.get("gates") or [],
432
- "done": done.get("done") or [], "passed": bool(done.get("passed")),
433
- "commit": done.get("sha") or "", "results": _results_out(done)})
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
- running = live_run(root)
452
- if running:
453
- return _answer(
454
- "running", _busy(running),
455
- {"gate": running.get("current") or "starting",
456
- "elapsedSeconds": _elapsed(running),
457
- "gates": running.get("gates") or [], "done": running.get("done") or [],
458
- "results": _results_out(running)})
459
-
460
- child = subprocess.Popen(
461
- [sys.executable, os.path.abspath(sys.argv[0]), "verify", "--task", name,
462
- "--detached"],
463
- cwd=root.parent, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
464
- stderr=subprocess.DEVNULL, start_new_session=True)
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 **asking is not one of the ones that starts anything.**
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.
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
- The two used to be one call that started or attached depending on timing, and
483
- the ambiguity cost more than it saved: a session polling for a result started
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("async"):
793
+ if args.get("start"):
503
794
  return _verify_start(root, name)
504
795
 
505
- running = live_run(root)
506
- if running:
507
- print(f"error: {_busy(running)}", file=sys.stderr)
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}'")
@@ -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 --task <name>` (blocks) · `--async` (what the tool uses) | either — `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 |