@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.
@@ -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
 
@@ -46,6 +46,37 @@ def here() -> str:
46
46
  return git.machine()
47
47
 
48
48
 
49
+ def alive(pid) -> bool:
50
+ """Whether a process is still there. **One answer to this, for every caller.**
51
+
52
+ Signal 0 asks the kernel without touching the process. Strictly positive, because
53
+ signal 0 to pid 0 asks about the whole process group and to a negative pid about
54
+ another one — either would answer "alive" about something that is not the process
55
+ in question at all.
56
+
57
+ **A pid somebody else owns answers `PermissionError`, and that is ALIVE**: the
58
+ kernel refused to signal a process it just confirmed exists. The verify lock read
59
+ the same refusal as *dead* while this read it as alive, so a second run could
60
+ start over a live one — two readings of one pid, which is the disagreement every
61
+ shared answer in this module exists to prevent. It lives here rather than beside
62
+ either caller because a pid is a machine fact and this file is where those are.
63
+
64
+ **Known limit: a recycled pid reads as alive.** A process killed hard leaves its
65
+ artifacts behind, so nothing on the machine can corroborate the number, and
66
+ reading a start time needs a child process this module is deliberately not
67
+ allowed to spawn. What bounds it is where the answer is used.
68
+ """
69
+ try:
70
+ if int(pid) <= 0:
71
+ return False
72
+ os.kill(int(pid), 0)
73
+ except PermissionError:
74
+ return True
75
+ except (OSError, TypeError, ValueError):
76
+ return False
77
+ return True
78
+
79
+
49
80
  #: Where an agent client publishes the sessions it currently has running. Claude
50
81
  #: Code is the only one that publishes anything today; the shape is one JSON file
51
82
  #: per process carrying its own session id, its addressable name and its pid.
@@ -76,20 +107,15 @@ def _live():
76
107
  different and much stronger claim: something published a list and this session
77
108
  was not on it.
78
109
 
79
- A pid is checked rather than trusted. The directory is cleaned up on exit, so in
80
- practice it holds only live runs, but a process killed hard leaves its file
81
- behind and a stale entry here would resurrect exactly the ghost this exists to
82
- catch. Signal 0 asks the kernel whether the pid is there without touching it.
83
-
84
- **Known limit: a recycled pid reads as alive.** If a session is killed hard AND
85
- the operating system later hands its number to some unrelated program, this says
86
- running about a session that is gone. It is not closed here, and the alternatives
87
- were worse: every artifact that could corroborate the pid the messaging socket
88
- included — is left behind by the same hard kill, so pairing two stale files
89
- proves nothing, and reading a process's start time needs a child process this
90
- module is deliberately not allowed to spawn. What bounds it instead is where the answer is used: one
91
- row on a status screen reading "running" instead of "ended", never a board write,
92
- which is the constraint the rule about a client's facts already sets.
110
+ A pid is checked rather than trusted, through `alive()` the one answer this
111
+ module gives to that question, shared with the verify lock. The directory is
112
+ cleaned up on exit, so in practice it holds only live runs, but a process killed
113
+ hard leaves its file behind, and a stale entry here would resurrect exactly the
114
+ ghost this exists to catch.
115
+
116
+ `alive()` carries the recycled-pid limit. What bounds it is where the answer is
117
+ used: one row on a status screen reading "running" instead of "ended", never a
118
+ board write, which is the constraint the rule about a client's facts already sets.
93
119
 
94
120
  The result is cached per directory for the life of the process. The harness is a
95
121
  short-lived command, so re-globbing and re-signalling once per printed row is
@@ -110,20 +136,9 @@ def _live():
110
136
  continue # a half-written file is not a dead session
111
137
  if not isinstance(entry, dict):
112
138
  continue
113
- run, pid = str(entry.get("sessionId", "")), entry.get("pid")
114
- if not run:
115
- continue
116
- try:
117
- # Strictly positive: signal 0 to pid 0 asks about the whole process
118
- # group and to a negative pid about another one, so either would answer
119
- # "alive" about something that is not this session at all.
120
- if int(pid) <= 0:
121
- continue
122
- os.kill(int(pid), 0)
123
- except (TypeError, ValueError, ProcessLookupError):
139
+ run = str(entry.get("sessionId", ""))
140
+ if not run or not alive(entry.get("pid")):
124
141
  continue
125
- except PermissionError:
126
- pass # alive and owned by somebody else, which is still alive
127
142
  out[run] = entry
128
143
  _RUNNING[key] = out
129
144
  return out
@@ -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 |
@@ -3248,13 +3248,101 @@ def test_starting_while_a_run_is_live_names_whose_it_is_rather_than_attaching():
3248
3248
  "machine": "here", "started": gate._now().isoformat(),
3249
3249
  "gates": ["tests"], "current": "tests", "done": [],
3250
3250
  "finished": None, "read": False})
3251
- out = _said(lambda: gate.cmd_verify({"task": "beta", "async": True}))
3252
- assert out["state"] == "running", out
3251
+ out = _said(lambda: gate.cmd_verify({"task": "beta", "start": True}))
3252
+ # `busy` and not `running`: the slot is taken and it is somebody
3253
+ # else's. Reporting both as `running` is how a session read a held
3254
+ # checkout as its own gates making progress.
3255
+ assert out["state"] == "busy", out
3256
+ assert out["run"]["task"] == "alpha", out
3253
3257
  assert "alpha" in out["message"], out["message"]
3258
+ # Not a queue, and it says so rather than letting a refused caller
3259
+ # believe a call is coming.
3260
+ assert "Nothing is holding your place" in out["message"], out["message"]
3254
3261
  finally:
3255
3262
  gate.VERIFY = old
3256
3263
 
3257
3264
 
3265
+ def test_a_held_slot_never_reports_as_the_callers_own_progress():
3266
+ # The measured half of this: asking for progress answered `18s, 0 of 9` and then
3267
+ # `322s, 8 of 9` two minutes apart, for two different runs, and a caller could
3268
+ # tell neither a fresh start from somebody else's long one nor whether its own
3269
+ # request had taken effect. Every gate-shaped field describes the CALLER's item,
3270
+ # so on a slot that is not theirs they are empty and the run sits on its own.
3271
+ with tempfile.TemporaryDirectory() as tmp:
3272
+ v = _tree(tmp)
3273
+ e = _epic(v, "an-epic")
3274
+ _task(e / "in-progress", "alpha", body="# T\n")
3275
+ _task(e / "in-progress", "beta", body="# T\n")
3276
+ with _work_dir(tmp) as root:
3277
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true", "lint": "true"}
3278
+ try:
3279
+ gate._write_run(root, {"task": "alpha", "pid": os.getpid(),
3280
+ "machine": "here", "session": peers.me(),
3281
+ "started": gate._now().isoformat(),
3282
+ "gates": ["lint", "tests"], "current": "tests",
3283
+ "done": ["lint"], "finished": None, "read": False})
3284
+ out = _said(lambda: gate.cmd_verify({"task": "beta", "status": True}))
3285
+ assert out["state"] == "busy", out
3286
+ assert out["gate"] == "" and out["done"] == [] and out["gates"] == [], \
3287
+ "another item's progress was reported as this caller's"
3288
+ assert out["elapsedSeconds"] == 0 and out["passed"] is False, out
3289
+ # …and what IS true about the checkout is said, in one place.
3290
+ assert out["run"]["task"] == "alpha", out
3291
+ assert out["run"]["gate"] == "tests" and out["run"]["done"] == ["lint"], out
3292
+ assert "'beta'" in out["message"], out["message"]
3293
+
3294
+ # The same run, asked about by the item it IS proving, is progress.
3295
+ mine = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3296
+ assert mine["state"] == "running", mine
3297
+ assert mine["done"] == ["lint"], mine
3298
+ finally:
3299
+ gate.VERIFY = old
3300
+
3301
+
3302
+ def test_a_refused_verify_names_a_reachable_holder_when_there_is_one():
3303
+ # The sibling refusal's join, made for the completion gate, applied to the one
3304
+ # mechanism in this epic that actually refuses: the id was always in the run file
3305
+ # and led nowhere, so the only move left was to ask again on a timer.
3306
+ with tempfile.TemporaryDirectory() as tmp:
3307
+ v = _tree(tmp)
3308
+ e = _epic(v, "an-epic")
3309
+ _task(e / "in-progress", "alpha", body="# T\n")
3310
+ with _work_dir(tmp) as root, tempfile.TemporaryDirectory() as sess:
3311
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3312
+ keep = os.environ.get(peers.SESSIONS)
3313
+ os.environ[peers.SESSIONS] = sess
3314
+ peers._RUNNING.clear()
3315
+ try:
3316
+ Path(sess, "1.json").write_text(json.dumps(
3317
+ {"sessionId": "aaaabbbb-0000-0000-0000-000000000000",
3318
+ "name": "jarvis-22", "pid": os.getpid(), "cwd": str(root.parent)}))
3319
+ gate._write_run(root, {
3320
+ "task": "alpha", "pid": os.getpid(), "machine": peers.here(),
3321
+ "session": "aaaabbbb-0000-0000-0000-000000000000",
3322
+ "started": gate._now().isoformat(), "gates": ["tests"],
3323
+ "current": "tests", "done": [], "finished": None, "read": False})
3324
+ said = gate._busy(gate.live_run(root))
3325
+ assert "jarvis-22" in said, said
3326
+ assert "SendMessage" in said, said
3327
+ # How far through, because "started 190s ago" cannot answer whether
3328
+ # this is nearly over or has just begun.
3329
+ assert "gate 1 of 1" in said, said
3330
+
3331
+ # A run that recorded no session says nothing about who — an
3332
+ # enrichment we can lose, never a guess we invent.
3333
+ gate._write_run(root, {**gate.read_run(root), "session": ""})
3334
+ quiet = gate._busy(gate.live_run(root))
3335
+ assert "jarvis-22" not in quiet and "started by" not in quiet, quiet
3336
+ assert "corrupt each other" in quiet, quiet
3337
+ finally:
3338
+ gate.VERIFY = old
3339
+ peers._RUNNING.clear()
3340
+ if keep is None:
3341
+ os.environ.pop(peers.SESSIONS, None)
3342
+ else:
3343
+ os.environ[peers.SESSIONS] = keep
3344
+
3345
+
3258
3346
  def test_a_finished_run_for_another_task_is_not_handed_over_as_this_ones():
3259
3347
  # The wrong-verdict half. A result proven for one item says nothing about
3260
3348
  # another, and reporting it would put a green gate on work nobody ran.
@@ -3271,8 +3359,255 @@ def test_a_finished_run_for_another_task_is_not_handed_over_as_this_ones():
3271
3359
  out = _said(lambda: gate.cmd_verify({"task": "beta", "status": True}))
3272
3360
  assert out["state"] == "idle", out
3273
3361
  assert out["passed"] is False, out
3362
+ # …and it says WHICH kind of nothing this is. "No gates have run"
3363
+ # covering both "nobody ran anything" and "the last run proved
3364
+ # somebody else's item" is what a caller cannot act on.
3365
+ assert out["reason"] == "other", out
3366
+ assert "'alpha'" in out["message"], out["message"]
3367
+ finally:
3368
+ gate.VERIFY = old
3369
+
3370
+
3371
+ def test_a_run_that_stopped_without_recording_says_so_rather_than_reading_as_never_run():
3372
+ # The half nobody could prove. A run whose process is gone leaves a file every
3373
+ # door correctly treats as absent, and the next start OVERWRITES it — so the
3374
+ # session that lost four minutes of gates learned it only by noticing the slot
3375
+ # now named somebody else's item. Reported from the receiving end, 2026-09-12.
3376
+ with tempfile.TemporaryDirectory() as tmp:
3377
+ v = _tree(tmp)
3378
+ e = _epic(v, "an-epic")
3379
+ _task(e / "in-progress", "alpha", body="# T\n")
3380
+ _task(e / "in-progress", "beta", body="# T\n")
3381
+ with _work_dir(tmp) as root:
3382
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3383
+ try:
3384
+ dead = {"task": "alpha", "pid": 2 ** 31 - 1, "machine": "here",
3385
+ "session": "aaaabbbb-0000-0000-0000-000000000000",
3386
+ "started": gate._now().isoformat(),
3387
+ "gates": ["lint", "tests"], "current": "tests",
3388
+ "done": ["lint"], "finished": None, "read": False}
3389
+ gate._write_run(root, dead)
3390
+ out = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3391
+ assert out["state"] == "idle" and out["reason"] == "lost", out
3392
+ assert "gate 2 of 2" in out["message"], out["message"]
3393
+ assert "without recording" in out["message"], out["message"]
3394
+
3395
+ # And it survives the run that replaces it, which is the case that
3396
+ # actually happened: the slot is claimed by another item, and the
3397
+ # session whose gates vanished is still told — being behind a
3398
+ # stranger's run and having lost your own are two facts, and the
3399
+ # only thing it had to go on was the slot naming somebody else.
3400
+ gate._write_run(root, gate._begin(root, "beta", os.getpid()))
3401
+ assert gate.read_run(root)["lost"]["task"] == "alpha", gate.read_run(root)
3402
+ still = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3403
+ assert still["state"] == "busy" and still["reason"] == "lost", still
3404
+ assert still["run"]["task"] == "beta", still
3405
+ assert "without recording" in still["message"], still["message"]
3406
+
3407
+ # A run that FINISHED is not a lost one, however long ago it was.
3408
+ gate._write_run(root, {**dead, "finished": gate._now().isoformat(),
3409
+ "passed": True, "sha": "", "results": []})
3410
+ done = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3411
+ assert done["state"] == "finished", done
3412
+ finally:
3413
+ gate.VERIFY = old
3414
+
3415
+
3416
+ def test_a_result_the_code_moved_past_names_both_commits():
3417
+ # `moved` and `no gates have run` were one sentence, and this is the mode that
3418
+ # cost the most: a gate run only counted if nobody committed for four minutes,
3419
+ # and three sessions were committing every few minutes. The result was recorded
3420
+ # against a HEAD that no longer existed and read as never having run.
3421
+ with tempfile.TemporaryDirectory() as tmp:
3422
+ try:
3423
+ repo = _git_repo(tmp, push=False)
3424
+ ran = _git(repo, "rev-parse", "HEAD").stdout.strip()
3425
+ (repo / "src.txt").write_text("the code moved\n")
3426
+ _git(repo, "add", "-A"); _git(repo, "commit", "-qm", "somebody committed")
3427
+ head = _git(repo, "rev-parse", "HEAD").stdout.strip()
3428
+
3429
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3430
+ with _work_dir(str(repo / "work")) as root:
3431
+ try:
3432
+ gate._write_run(root, {
3433
+ "task": "alpha", "pid": os.getpid(), "machine": "here",
3434
+ "session": "", "started": gate._now().isoformat(),
3435
+ "head": ran, "gates": ["tests"], "current": "",
3436
+ "done": ["tests"], "finished": gate._now().isoformat(),
3437
+ "passed": True, "sha": ran, "read": False,
3438
+ "results": [{"name": "tests", "status": "PASS", "said": ""}]})
3439
+ out = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3440
+ assert out["reason"] == "moved", out
3441
+ assert ran[:12] in out["message"], out["message"]
3442
+ assert head[:12] in out["message"], out["message"]
3443
+ assert out["passed"] is False, "a stale green must not read as proven"
3444
+
3445
+ # A commit a push rebased away is a DIFFERENT answer: the code it
3446
+ # measured may be identical, and naming a hash nobody can open is
3447
+ # worse than saying it is gone.
3448
+ gate._write_run(root, {**gate.read_run(root), "read": False,
3449
+ "sha": "0" * 40, "head": "0" * 40})
3450
+ gone = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3451
+ assert gone["reason"] == "gone", gone
3452
+ assert "rebased" in gone["message"], gone["message"]
3453
+ finally:
3454
+ gate.VERIFY = old
3455
+ finally:
3456
+ config.apply(config.DEFAULTS)
3457
+
3458
+
3459
+ def test_the_slot_is_held_from_before_the_child_starts():
3460
+ # The check-then-spawn this replaces left the slot unheld for the whole of a
3461
+ # Python interpreter's startup — hundreds of milliseconds, not an instant — so
3462
+ # two sessions polling one board could both walk through it and the second run's
3463
+ # build would delete the artifact the first one's log check was about to read.
3464
+ with tempfile.TemporaryDirectory() as tmp:
3465
+ v = _tree(tmp)
3466
+ e = _epic(v, "an-epic")
3467
+ _task(e / "in-progress", "alpha", body="# T\n")
3468
+ with _work_dir(tmp) as root:
3469
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3470
+ spawned = []
3471
+ real = gate.subprocess.Popen
3472
+ try:
3473
+ # The claim has to be in place BEFORE anything is spawned, or a lost
3474
+ # race still leaves a child running gates nothing is tracking. Only
3475
+ # the verify child is intercepted — everything else here, `git`
3476
+ # included, still runs for real.
3477
+ class Sham:
3478
+ pid = os.getpid()
3479
+
3480
+ def watch(argv, **kw):
3481
+ if "--detached" not in argv:
3482
+ return real(argv, **kw)
3483
+ spawned.append(gate.live_run(root))
3484
+ return Sham()
3485
+ gate.subprocess.Popen = watch
3486
+ out = _said(lambda: gate.cmd_verify({"task": "alpha", "start": True}))
3487
+ assert out["state"] == "started", out
3488
+ assert spawned and spawned[0] and spawned[0]["task"] == "alpha", \
3489
+ "the slot was open while the child was being spawned"
3490
+
3491
+ # A second start over a live claim is refused, and starts nothing.
3492
+ spawned.clear()
3493
+ again = _said(lambda: gate.cmd_verify({"task": "alpha", "start": True}))
3494
+ assert again["state"] == "busy", again
3495
+ assert spawned == [], "a refused start spawned a run anyway"
3496
+
3497
+ # A slot whose process is gone is taken over rather than waited for:
3498
+ # a lock that outlives its holder strands every future verify.
3499
+ gate._write_run(root, {**gate.read_run(root), "pid": 2 ** 31 - 1})
3500
+ free = _said(lambda: gate.cmd_verify({"task": "alpha", "start": True}))
3501
+ assert free["state"] == "started", free
3502
+ finally:
3503
+ gate.subprocess.Popen = real
3504
+ gate.VERIFY = old
3505
+
3506
+
3507
+ def test_a_lost_run_is_retired_by_the_run_that_records_one():
3508
+ # The carried note outlived what it was about: a run for this item died, the next
3509
+ # run for the same item passed and was reported, and every ask after the first
3510
+ # went back to saying nothing had been written — about gates that had just gone
3511
+ # green. A note that survives its own subject is the failure class this whole
3512
+ # item exists to remove, so it must not be reintroduced by the fix.
3513
+ with tempfile.TemporaryDirectory() as tmp:
3514
+ v = _tree(tmp)
3515
+ e = _epic(v, "an-epic")
3516
+ _task(e / "in-progress", "alpha", body="# T\n")
3517
+ with _work_dir(tmp) as root:
3518
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3519
+ try:
3520
+ gate._write_run(root, {"task": "alpha", "pid": 2 ** 31 - 1,
3521
+ "started": gate._now().isoformat(),
3522
+ "gates": ["tests"], "done": [],
3523
+ "finished": None, "read": False})
3524
+ # A real run for the same item, which picks the note up and then
3525
+ # RECORDS — so the note is spent, and the record must not be left
3526
+ # saying both "passed for alpha" and "alpha's run wrote nothing".
3527
+ # Cleared where it stops being true rather than stepped around by
3528
+ # whoever reads it next.
3529
+ gate.cmd_verify({"task": "alpha"})
3530
+ assert gate.read_run(root)["passed"] is True
3531
+ assert gate.read_run(root)["lost"] is None, gate.read_run(root)
3532
+
3533
+ first = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3534
+ assert first["state"] == "finished" and first["passed"] is True, first
3535
+ again = _said(lambda: gate.cmd_verify({"task": "alpha", "status": True}))
3536
+ assert again["reason"] == "read", again
3537
+ assert "without recording" not in again["message"], again["message"]
3538
+ finally:
3539
+ gate.VERIFY = old
3540
+
3541
+
3542
+ def test_a_slot_the_harness_cannot_write_is_not_reported_as_somebody_elses_run():
3543
+ # Only "the file is already there" means somebody holds it. Any other failure to
3544
+ # write is the harness's own, and a caller told `a verify is already running here
3545
+ # (pid None)` goes looking for a session that does not exist — a door reporting
3546
+ # its own failure as the caller's situation.
3547
+ with tempfile.TemporaryDirectory() as tmp:
3548
+ v = _tree(tmp)
3549
+ _epic(v, "an-epic")
3550
+ with _work_dir(tmp) as root:
3551
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3552
+ real = gate.os.open
3553
+ try:
3554
+ def refuse(path, flags, *rest):
3555
+ if str(path).endswith(gate.RUN):
3556
+ raise PermissionError(13, "Permission denied")
3557
+ return real(path, flags, *rest)
3558
+ gate.os.open = refuse
3559
+ out = _said(lambda: gate.cmd_verify({"task": "alpha", "start": True}))
3560
+ assert out["state"] != "busy", out
3561
+ assert out["run"] is None, out
3562
+ assert "Permission denied" in out["message"], out["message"]
3563
+ assert "not another session" in out["message"], out["message"]
3564
+ finally:
3565
+ gate.os.open = real
3566
+ gate.VERIFY = old
3567
+
3568
+
3569
+ def test_a_pid_somebody_else_owns_is_alive_not_dead():
3570
+ # Two readings of one pid: `peers._live` treats a kernel refusal as alive and
3571
+ # this treated it as dead, so a run owned by another user read as over and a
3572
+ # second run could start over a live one. One question, one answer.
3573
+ real = gate.os.kill
3574
+ try:
3575
+ def refuse(pid, sig):
3576
+ raise PermissionError(1, "Operation not permitted")
3577
+ gate.os.kill = refuse
3578
+ assert gate._alive(4242) is True
3579
+ finally:
3580
+ gate.os.kill = real
3581
+ # pid 0 asks about the whole process group and a negative one about another
3582
+ # group, so either would answer "alive" about something that is not this run.
3583
+ assert gate._alive(0) is False and gate._alive(-1) is False
3584
+
3585
+
3586
+ def test_the_doors_that_start_are_told_apart_from_the_one_that_asks():
3587
+ # `--async` only ever started a run, and it READ like "ask me in the background":
3588
+ # a session polling with it set a fresh nine-gate run going every time the
3589
+ # previous one ended. The parser turns any `--word` into a flag, so an unread one
3590
+ # fell through to the BLOCKING door — the caller waiting minutes for a run it
3591
+ # never asked to start. A typo and a renamed flag both say so now.
3592
+ with tempfile.TemporaryDirectory() as tmp:
3593
+ v = _tree(tmp)
3594
+ _epic(v, "an-epic")
3595
+ with _work_dir(tmp):
3596
+ old, gate.VERIFY = gate.VERIFY, {"tests": "true"}
3597
+ try:
3598
+ for flag in ("async", "stat", "wait"):
3599
+ try:
3600
+ gate.cmd_verify({"task": "alpha", flag: True})
3601
+ except SystemExit as e:
3602
+ assert e.code, f"--{flag} exited 0"
3603
+ else:
3604
+ raise AssertionError(f"--{flag} was accepted and ignored")
3274
3605
  finally:
3275
3606
  gate.VERIFY = old
3607
+ # The help names each door by whether it STARTS, which is the only distinction
3608
+ # that matters when one of them costs four minutes.
3609
+ help_ = entry.__doc__ or ""
3610
+ assert "--start" in help_ and "--status only SAYS" in help_, help_
3276
3611
 
3277
3612
 
3278
3613
  def test_a_second_verify_is_refused_while_one_is_running():
package/harness/work.py CHANGED
@@ -91,10 +91,13 @@ cannot finish has somewhere to put the reason instead of stalling or guessing:
91
91
  status what shipped · what waits on you · what is at risk
92
92
  method the METHOD in full — how work is done here. The session
93
93
  block names it; this is the body, on request.
94
- verify [--task <name>] [--async] RUN verify.* (shell=False) and record the result;
95
- --async starts them and returns, then reports
96
- progress and finally the result. One at a time
97
- per checkout: two corrupt each other.
94
+ verify <name> [--start | --status] RUN verify.* (shell=False) and record the result.
95
+ Which door you pick decides whether a run STARTS:
96
+ (no flag) starts them here and waits for the table
97
+ --start starts them in the background, answers at once
98
+ --status only SAYS where the run stands — starts nothing
99
+ One run at a time per checkout: two corrupt each
100
+ other. Refused while one is going, and told whose.
98
101
  observed <task> --ac AC-01 --saw "…" an eyes-on, for what a test cannot prove
99
102
  ask <task> --question "…" [--options "a | b"] [--owner who] [--durable]
100
103
  park a question, move on — exits 0, never blocks
@@ -326,7 +329,7 @@ def _writes(cmd: str, flags: dict) -> bool:
326
329
  """Does THIS invocation change the board?
327
330
 
328
331
  The command name answers it for all but one. `verify` writes the result of a run
329
- it EXECUTES, and two of its four doors execute nothing: `--async` starts a
332
+ it EXECUTES, and two of its four doors execute nothing: `--start` starts a
330
333
  detached child that commits its own result, and `--status` only reports. Both
331
334
  write to `work/.verify`, which is gitignored, so neither has anything of its own
332
335
  to land — and both were driving a commit that swept up whatever the person had
@@ -336,7 +339,7 @@ def _writes(cmd: str, flags: dict) -> bool:
336
339
  """
337
340
  if cmd not in git.WRITES:
338
341
  return False
339
- if cmd == "verify" and (flags.get("async") or flags.get("status")):
342
+ if cmd == "verify" and (flags.get("start") or flags.get("status")):
340
343
  return False
341
344
  return True
342
345
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appchy/jarvis",
3
- "version": "0.1.120",
3
+ "version": "0.1.122",
4
4
  "description": "Jarvis — local AI coding assistant CLI",
5
5
  "private": false,
6
6
  "type": "module",