@ai-dossier/cli 0.20.2 → 0.22.0

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/README.md CHANGED
@@ -360,6 +360,11 @@ ai-dossier runstate last --issue 440 --json
360
360
 
361
361
  # Ask where a run should resume from
362
362
  ai-dossier runstate verify --issue 440
363
+
364
+ # Supersede a stalled run before redispatching it, then work as the takeover
365
+ ai-dossier runstate fence --issue 440 --run r-440-ab56 \
366
+ --phase implement --takeover slot-2-r1 # -> gen=1
367
+ ai-dossier runstate check --issue 440 --run r-440-ab56 --gen 1 # exit 3 = superseded
363
368
  ```
364
369
 
365
370
  ### Subcommands
@@ -369,19 +374,23 @@ ai-dossier runstate verify --issue 440
369
374
  | `post` | Validates and posts one milestone comment via `gh issue comment` | yes |
370
375
  | `last` | Prints the most recent milestone on the issue, parsed | no |
371
376
  | `verify` | Runs the gate's resume verification and prints `resume_from` + `resume_context` | no |
377
+ | `fence` | Supersedes a run: posts the takeover record and prints the generation it installed (#504) | yes |
378
+ | `check` | Reports whether a run generation has been superseded; exits `3` when it has (#504) | only with `--comment` |
372
379
  | `mint` | Prints a fresh run id (`r-<issue>-<hex>`) | no |
373
380
  | `stats` | Reports per-phase durations derived from the trail's `at=` stamps; aggregates across a `--issues` selection | no |
374
381
 
375
- `last`, `verify`, and `stats` are strictly read-only — they only run `gh issue view`,
382
+ `last`, `verify`, and `stats` are strictly read-only (as is `check`, unless `--comment` is passed) — they only run `gh issue view`,
376
383
  `gh pr view`, `git ls-remote`/`rev-parse`, and `stat`, so they work fine without push
377
384
  access to the repository.
378
385
 
379
386
  `--issue <n>` (a positive integer — the number only, not a URL or a `#`-prefixed string)
380
- is required by `post`, `last`, `verify`, and `mint`; `stats` takes either `--issue <n>` or
381
- `--issues <list>`, exactly one of the two. `post`, `last`, `verify`, and `stats`
382
- additionally take `--repo <owner/name>` (a bare slug, not a URL; defaults to the
383
- repository `gh` resolves for the current directory) and `--json`. `mint` takes `--issue`
384
- and nothing else.
387
+ is required by `post`, `last`, `verify`, `fence`, `check`, and `mint`; `stats` takes
388
+ either `--issue <n>` or `--issues <list>`, exactly one of the two. `post`, `last`,
389
+ `verify`, `fence`, `check`, and `stats` additionally take `--repo <owner/name>` (a bare
390
+ slug, not a URL; defaults to the repository `gh` resolves for the current directory) and
391
+ `--json`; `fence` also takes `--dry-run`. `fence` requires `--run`, `--phase` and
392
+ `--takeover`; `check` requires `--run` and takes `--gen` and `--comment`. `mint` takes
393
+ `--issue` and nothing else.
385
394
 
386
395
  `last` prints the milestone's own `key=value` lines, so the output is already in the
387
396
  shape a shell or a dossier reads:
@@ -408,10 +417,11 @@ prints `No runstate milestones on issue #440.` (or `null` under `--json`) and ex
408
417
  |---|---|
409
418
  | `--issue <n>` | Issue to comment on (required) |
410
419
  | `--phase <p>` | `classify`, `gate`, `setup`, `plan`, `implement`, `review`, `ship`, `report`, or a batch phase — `batch-setup`, `batch-validate`, `batch-review`, `batch-ship`, `batch-report` (required) |
411
- | `--status <s>` | `done`, `partial`, `blocked`, `awaiting-merge` (required) |
420
+ | `--status <s>` | `done`, `partial`, `blocked`, `awaiting-merge` (required). `superseded` is rejected here — a fence is written by [`runstate fence`](#run-fencing-504) |
412
421
  | `--run <id>` | Run id (`r-<issue>-<hex>`) — mint one with `runstate mint`; full-cycle runs mint it at the gate phase (required) |
413
422
  | `--kv <key=value...>` | Phase-specific key, repeatable (and variadic: `--kv a=1 b=2` works too) |
414
423
  | `--next <phase>` | Override the computed `next=` line — a phase name or `done` |
424
+ | `--gen <n>` | Run generation this agent owns (default 0). A post below the trail's fenced generation is refused — see [Run fencing](#run-fencing-504) |
415
425
  | `--repo <owner/name>` | Target repository (defaults to the current one) |
416
426
  | `--dry-run` | Print the comment body instead of posting it |
417
427
  | `--json` | Machine-readable output |
@@ -496,6 +506,72 @@ batch-report → done.
496
506
  > but its `verify` derives a bogus `resume_from` from a *blocked* classify/batch
497
507
  > milestone — don't point an old CLI at batch anchor issues.
498
508
 
509
+ ### Run fencing (#504)
510
+
511
+ The stall-recovery ladder redispatches the *same* run, so a takeover inherits the run id
512
+ and its trail. Nothing used to tell the run it replaced that it had been replaced — and a
513
+ scheduler can only kill a process it can see. In the #472 race the original agent was
514
+ alive the whole time (throttled, cwd outside the worktree); both runs implemented the
515
+ issue in full and both kept posting to one trail.
516
+
517
+ A **generation** fences the trail. `runstate fence` posts a `status=superseded` milestone —
518
+ valid on every phase, carrying `gen=<n>` and `takeover=<label>` — and prints the generation
519
+ it installed:
520
+
521
+ ```bash
522
+ $ ai-dossier runstate fence --issue 440 --run r-440-ab56 --phase implement --takeover slot-2-r1
523
+ ✅ fenced r-440-ab56 gen=1 takeover=slot-2-r1 → https://github.com/o/r/issues/440#issuecomment-9
524
+ ```
525
+
526
+ From then on `post` refuses anything from a lower generation, **before** writing to the
527
+ issue:
528
+
529
+ ```bash
530
+ $ ai-dossier runstate post --issue 440 --phase implement --status done --run r-440-ab56 …
531
+ ❌ Run r-440-ab56 was SUPERSEDED — refusing to post this implement/done milestone from generation 0.
532
+ It was fenced at generation 1 (takeover 'slot-2-r1', fenced at implement on 2026-08-30T11:00:00Z).
533
+ …
534
+ Fix: stop working on this issue and exit. … If you ARE the takeover, pass --gen 1.
535
+ ```
536
+
537
+ Generation 0 is the default, which is what makes this work on a fleet that has not been
538
+ upgraded: an agent that knows nothing about fencing posts at 0 and is locked out the
539
+ moment generation 1 exists. `>` not `>=` — the takeover posting at exactly its own
540
+ generation is live, and a recovery-of-recovery at generation 2 fences the first takeover
541
+ in turn.
542
+
543
+ `runstate check` is the same question without a write, for a workflow to run before
544
+ implement, review, and ship:
545
+
546
+ ```bash
547
+ $ ai-dossier runstate check --issue 440 --run r-440-ab56 --gen 1
548
+ fenced=false
549
+ gen=1
550
+ current_gen=1
551
+ ```
552
+
553
+ It exits **3** when the caller has been superseded (distinct from `1`, so a script can
554
+ tell "you were replaced" from "you called this wrong"), and a refused `post` uses the same
555
+ code — one exit code means one thing everywhere. `--comment` additionally leaves one short,
556
+ deduplicated abort comment on the issue, so a run that stops mid-cycle is explained rather
557
+ than merely absent. `verify` reports the owning generation as `generation=`.
558
+
559
+ Both commands harden the read side, because a milestone is an issue comment and anyone who
560
+ can comment can author one: only comments from an account with **write access** count as a
561
+ fence (a stranger must not be able to halt a run), a `takeover=` label that does not match
562
+ the write-path grammar is dropped rather than echoed into an agent's instruction stream,
563
+ and a generation past `1000` is treated as forged rather than honoured.
564
+
565
+ > **Compatibility.** Posting and reading fences requires CLI ≥ 0.21.0. An older CLI rejects
566
+ > `--status superseded` outright on `post`, has no concept of generations (so it never
567
+ > refuses a superseded post), and its `verify` reads a fence milestone as ordinary progress
568
+ > — with no `head=` to confirm, it derives a bogus `resume_from`, typically all the way back
569
+ > to `setup`. Upgrade the fleet before relying on fencing.
570
+
571
+ Both `post` and `check` **fail open** if the trail cannot be read: a milestone is a
572
+ phase's only durable record, and losing one to a transient `gh` outage is a worse failure
573
+ than the race this prevents. The degradation is warned about on stderr, never silent.
574
+
499
575
  ### mode=slot and batch= on plan/implement/review
500
576
 
501
577
  Slot-cycle members post the ordinary `plan`/`implement`/`review` milestones with two
@@ -519,11 +595,16 @@ carries the key nowhere are bucketed as `unknown`.
519
595
  The `Statuses` column is a closed set: a status not listed for a phase is rejected, so
520
596
  `report` cannot be `blocked` and only the ship phases (`ship`, `batch-ship`) may be
521
597
  `awaiting-merge`. Any phase that *can* report `status=blocked` must also carry
522
- `reason=<short-slug>` when it does.
598
+ `reason=<short-slug>` when it does. One status sits outside the table: `superseded` is
599
+ valid on **every** phase — a takeover can happen anywhere — and requires `gen=` and
600
+ `takeover=`. It is written by [`runstate fence`](#run-fencing-504), never by hand, and
601
+ `post` refuses it.
523
602
 
524
603
  `next=` is computed for you: the linear order `gate → setup → plan → implement → review →
525
- ship → report → done`, except that `blocked` ends the run (`next=done`) and the two
526
- non-terminal statuses stay in their own phase — `ship`/`awaiting-merge` is followed by a
604
+ ship → report → done`, except that `blocked` ends the run (`next=done`) and the three
605
+ non-terminal statuses stay in their own phase — `superseded` points back at its own phase,
606
+ so a takeover that dies leaves a resumable trail rather than a terminal one;
607
+ `ship`/`awaiting-merge` is followed by a
527
608
  second `ship` milestone, and a `partial` review still has agents to run. Use `--next` to
528
609
  override.
529
610
 
@@ -556,6 +637,8 @@ Every `--kv` pair is checked before anything is posted:
556
637
  | `test_scope` | one of `focused`, `broad`, `unknown` |
557
638
  | `est_files` | non-negative integer, e.g. `3` |
558
639
  | `est_diff` | non-negative integer (lines), e.g. `400` |
640
+ | `gen` | non-negative integer run generation, e.g. `1` — see [Run fencing](#run-fencing-504) |
641
+ | `takeover` | a single flag-safe token naming what took the run over — letters/digits then `. _ : @ / -`, e.g. `slot-2-r1` or `r-504-fc02` |
559
642
  | `confidence` | decimal `0`–`1`, e.g. `0.85` (RFC-0001 E.2 compares it to 0.6) |
560
643
  | `areas` | comma-separated lowercase slugs, e.g. `cli,docs` |
561
644
  | `deps` | `none`, or comma-separated issue numbers, e.g. `474,480` |
@@ -568,8 +651,11 @@ Every `--kv` pair is checked before anything is posted:
568
651
  ### When `gh` or `git` fails
569
652
 
570
653
  Every subcommand exits **1** on failure with the cause *and its fix* on stderr, so a
571
- calling dossier can branch on the exit code and an agent knows what to do next. The three
572
- causes that look identical from the outside are reported as three different things:
654
+ calling dossier can branch on the exit code and an agent knows what to do next. The one
655
+ exception is supersession: `check` — and a `post` refused as superseded — exit **3**, a
656
+ distinct code so a script can tell "you were replaced" from "you called this wrong" (see
657
+ [Run fencing](#run-fencing-504)). The three causes that look identical from the outside
658
+ are reported as three different things:
573
659
 
574
660
  ```
575
661
  $ ai-dossier runstate last --issue 440
@@ -611,6 +697,7 @@ worktree (#499):
611
697
  $ ai-dossier runstate verify --issue 440
612
698
  resume_from=implement
613
699
  run_id=r-440-ab56
700
+ generation=0
614
701
  verified=branch,head
615
702
  local_worktree=absent
616
703
  resume_context={"branch":"feature/440-runstate","worktree":"/repo/worktrees/feature-440-runstate",...}
@@ -625,6 +712,13 @@ resume_context={"branch":"feature/440-runstate","worktree":"/repo/worktrees/feat
625
712
  | `ship-teardown` | The PR is already merged; re-enter `ship` at post-merge cleanup |
626
713
  | `done` | The `report` milestone is posted and the issue is closed (`note=already complete`) |
627
714
 
715
+ A `superseded` milestone resumes at its **own** phase, with
716
+ `note=fenced by '<takeover>' at generation N …`: the takeover was announced but has posted
717
+ nothing yet, and it may never post, so the state is resumable rather than terminal. The
718
+ note tells a resumer that is *not* that takeover to install generation N+1 with
719
+ `runstate fence` before posting — adopting N would put a second writer alongside a takeover
720
+ that may be alive and working.
721
+
628
722
  A failed check sends the resume *backwards*, never forwards: if the branch is gone from
629
723
  the remote, a `plan`/`implement`/`review` milestone still yields `resume_from=setup`. If
630
724
  the branch verifies but the milestone's own `head=` cannot be confirmed on
@@ -820,7 +914,14 @@ against ground truth.
820
914
  output journaled to `runs/<unit>.log`. On every ~60s tick it reconciles: an agent that
821
915
  exited is **not** complete until `ai-dossier runstate last` / `gh` ground truth confirms
822
916
  it (unverified exits and stalls are redispatched one tier stronger — mechanical → mid →
823
- strong, cap 2 — then the unit fails and its transitive dependents are blocked);
917
+ strong, cap 2 — then the unit fails and its transitive dependents are blocked). Since
918
+ #504 that redispatch is **fenced**: before the replacement spawns, the engine posts a
919
+ `status=superseded` milestone (`runstate fence`) so the agent it replaced — which may
920
+ still be alive and unreachable — is refused by `runstate post`, and the replacement is
921
+ told its generation in its prompt. A fence that cannot be written journals
922
+ `fence-failed` with its cause and the redispatch proceeds unfenced, so an unprotected
923
+ redispatch is degraded but never silent; `sched status` shows each slot's `gen` and
924
+ `fenced` columns.
824
925
  externally-advanced state and orphaned pids after a restart are detected; a freed slot
825
926
  is refilled in the same tick. Since #468 the default dispatch prompt is
826
927
  detached-ship (the agent parks its PR on `auto-merge` and stops): a parked
@@ -870,14 +971,18 @@ against ground truth.
870
971
  State is written atomically (tmp + fsync + rename), so a process killed between writes
871
972
  always leaves the previous complete state, and a scheduler restart resumes identically
872
973
  from `state.json` (pre-#464/#468/#472/#500/#505 state files — schema
873
- 1.0.0/1.1.0/1.2.0/1.3.0/1.4.0 — migrate to 1.5.0 on load). A corrupt state file is a loud
974
+ 1.0.0/1.1.0/1.2.0/1.3.0/1.4.0/1.5.0 — migrate to 1.6.0 on load). A corrupt state file is a loud
874
975
  error naming the file — never a silent queue reset. Concurrency is serialized by a
875
976
  `.sched-lock` directory mutex (stolen from dead holders). `config.json` holds
876
977
  `max_slots` (default 3, bounds concurrently-live units), `stall_timeout_ms` (default
877
- 1 800 000), `reconcile_interval_ms` (default 60 000), and the optional `dispatch`
878
- section (including `report_prompt` for the #468 report agent); `pr_poll_interval_ms`
879
- (default 150 000) sets the parked-PR poll cadence; an issue with an unmerged dependency —
880
- or a batch behind an unmerged batch — is never runnable.
978
+ 1 800 000 — but the `implement` phase defaults to 5 400 000, overridable per phase via
979
+ `dispatch.phase_stall_timeout_ms: {"<phase>": <ms>}`, #495), `reconcile_interval_ms`
980
+ (default 60 000), and the optional `dispatch` section (including `report_prompt` for
981
+ the #468 report agent and `fence_takeover_timeout_ms`, default 900 000 — the short stall
982
+ allowance for a freshly-fenced takeover that has posted nothing, #504; `dispatch.prompt`
983
+ substitutes `{issue}` and `{gen}`); `pr_poll_interval_ms` (default 150 000) sets the
984
+ parked-PR poll cadence; an issue with an unmerged dependency — or a batch behind an unmerged batch —
985
+ is never runnable.
881
986
 
882
987
  Library consumers: see [`@ai-dossier/sched`](../packages/sched/README.md).
883
988
 
@@ -7,6 +7,16 @@
7
7
  * hoped for.
8
8
  */
9
9
  import type { Command } from 'commander';
10
- /** Registers the `runstate` command tree (post, last, verify, mint, stats). */
10
+ /**
11
+ * Exit code the fence guards use for "this run has been superseded".
12
+ *
13
+ * Distinct from `fail`'s 1 on purpose: a fenced run is a correct, expected answer that a
14
+ * caller should act on by aborting quietly, not a usage error to be retried. A shell
15
+ * `if ! ai-dossier runstate check …` can tell the two apart — as can a supervisor
16
+ * distinguishing "you were replaced" from "you called this wrong". Both `check` and a
17
+ * refused `post` use it, so one exit code means one thing everywhere.
18
+ */
19
+ export declare const FENCED_EXIT_CODE = 3;
20
+ /** Registers the `runstate` command tree (post, last, verify, fence, check, mint, stats). */
11
21
  export declare function registerRunstateCommand(program: Command): void;
12
22
  //# sourceMappingURL=runstate.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runstate.d.ts","sourceRoot":"","sources":["../../src/commands/runstate.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAywBzC,+EAA+E;AAC/E,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAU9D"}
1
+ {"version":3,"file":"runstate.d.ts","sourceRoot":"","sources":["../../src/commands/runstate.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAoZzC;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAwzBlC,6FAA6F;AAC7F,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAY9D"}