@ai-dossier/sched 0.8.0 → 0.10.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
@@ -96,12 +96,24 @@ where every mechanical supervision decision is code, not remembered prose:
96
96
  falls back to the global `stall_timeout_ms` outright.
97
97
  5. **Immediate refill (AC5)** — a slot freed by a terminal state is refilled in the SAME
98
98
  tick; a runnable unit never waits while a slot is idle (pinned by a regression test).
99
+ Refill was always synchronous; what previously had no journal trace was the release
100
+ itself — see `slot-released` below (#525).
99
101
  6. **Journal (AC6)** — every event (assigned, spawned, exit-detected, external-advance,
100
102
  progress, stalled, redispatched, fence-written, fence-failed, unit-failed,
101
- dependents-blocked, suspect-dispatch, dispatch-unhealthy, …) is appended to
102
- `events.jsonl`; `sched status` shows the live phase per unit, plus each slot's `gen`
103
- and `fenced` state (#504). `label-blocked`/`label-check-failed` (#507) are the one pair journaled
104
- OUTSIDE the engine — `sched enqueue` appends them at enqueue time, before dispatch.
103
+ dependents-blocked, slot-released, suspect-dispatch, dispatch-unhealthy, …) is
104
+ appended to `events.jsonl`; `sched status` shows the live phase per unit, plus each
105
+ slot's `gen` and `fenced` state (#504). `label-blocked`/`label-check-failed` (#507)
106
+ are the one pair journaled OUTSIDE the engine — `sched enqueue` appends them at
107
+ enqueue time, before dispatch. `slot-released` (#525) marks the exact tick a held
108
+ slot reaches `idle` on a per-issue dispatch terminal path — verified completion,
109
+ external-advance, a direct failure, a blocked dependent's release, or a
110
+ detached-ship park — carrying the freed `slot` id and a closed `reason`
111
+ (`verify-complete` / `external-advance` / `unit-failed` / `report-failed` /
112
+ `dependents-blocked` / `parked`, the exported `SlotReleaseReason` union), journaled
113
+ right after that path's own cause event so an occupancy report reads release time
114
+ directly instead of inferring it from the next `assigned` on that slot. Not yet
115
+ journaled by `sched abandon` or by batch-slot release, which walk a slot to `idle`
116
+ through their own copies of the same edge table (tracked as a follow-up).
105
117
  7. **Dispatch-health pause (#505)** — an unverified exit within `SUSPECT_DISPATCH_WINDOW_MS`
106
118
  (60s) of a slot's last progress is `suspect-dispatch`: real work rarely produces zero
107
119
  milestones that fast, but an operator-billing quota/auth wall (a Claude Code weekly
@@ -133,8 +145,9 @@ Two engine-safety policies were explicit product decisions on #464:
133
145
  an outage holds in `verifying` until truth returns. Each pause is journaled as
134
146
  `ground-truth-unreachable`.
135
147
 
136
- Only `issue:<n>` units are dispatched today — batch member sequencing is a follow-up
137
- (#464 non-goal).
148
+ This applies to `issue:<n>` unit dispatch (`dispatchAssignments`). `batch:<id>` units run
149
+ through a separate pass with its own claim/reconcile logic — see
150
+ [Batch dispatch (#523)](#batch-dispatch-523) below.
138
151
 
139
152
  ### Zombie-run fencing (#504)
140
153
 
@@ -182,9 +195,13 @@ but the unprotected redispatch is never silent.
182
195
  What happens when a batch's aggregate suite goes red, or its PR will not merge
183
196
  (RFC-0001 §F.2/F.8/F.9).
184
197
 
185
- **Not yet wired into `sched start`** — `tick()` still dispatches only `issue:<n>` units
186
- (the batch execution loop is a follow-up, see above). These modules are the library
187
- surface that loop will call, and they are tested standalone against real scratch repos.
198
+ **Wired into `sched start` since #523** the `validating attributing fixing/evicting`
199
+ rail below is called directly from `batch-dispatch.ts`'s `runValidate`/`evictOffender`
200
+ (a red AGGREGATE suite, after every member individually went green). A member that never
201
+ went green in the first place (its own gate failed) evicts through a separate, simpler
202
+ rail that never touches this module — see
203
+ [Batch dispatch (#523)](#batch-dispatch-523). These modules remain independently tested
204
+ against real scratch repos.
188
205
 
189
206
  ```
190
207
  validating → attributing → fixing (ONE bounded attempt) → validating
@@ -270,6 +287,64 @@ suspect-dispatch streak (#505 above). The two fields are a single fact and must
270
287
  on load: no suspect dispatches were ever tracked under them, so `0`/`null` is the exact
271
288
  backfill, not a guess.
272
289
 
290
+ ## Batch dispatch (#523)
291
+
292
+ `batch-dispatch.ts`'s `runBatchTick` — called from `tick()` after the issue-level pass,
293
+ only when `batchExec`/`runBatchSuite` are both configured on `EngineDeps` — drives every
294
+ `batch:<id>` unit through:
295
+
296
+ ```
297
+ ready → executing(member i/N) ⟲ → validating → reviewing → shipping
298
+ → awaiting-merge → merged → deployed → reported → done
299
+ failure rails: executing → dissolving (a member self-reports blocked)
300
+ validating → attributing → (fixing | evicting) → validating → dissolving
301
+ ```
302
+
303
+ - **One shared worktree/branch per batch**, claimed once by a deterministic (no LLM)
304
+ `batch-setup` step: `git branch`/`push`/`worktree add` off `base_branch`, named
305
+ `batch/<id>-<date>`, plus a fresh `ai-dossier runstate mint` against the anchor issue.
306
+ - **Members run serially, one fresh `slot-cycle` agent at a time**, in the shared
307
+ worktree. A member's completion signal is `phase=review status=done mode=slot` on its
308
+ OWN issue (`slot-cycle` posts no phase of its own past `review` — ship is batch-owned);
309
+ its commit range on the batch branch is recomputed (`git log`) after every member and
310
+ kept on `BatchEntry.ranges` for eviction. An incremental gate (`ai-dossier cap run
311
+ typecheck.run` / `test.focused`, when the repo has a manifest) runs after each member
312
+ before advancing — a second, independent check that the member's self-reported "done"
313
+ is real.
314
+ - **The batch's single slot is claimed FRESH for each live step** (a member, the tail
315
+ agent, the report agent, a bounded fix agent) — never held across a wait. The aggregate
316
+ suite itself runs with NO slot claimed at all (deterministic engine work, not an LLM
317
+ step).
318
+ - **Two failure rails.** A member that never went green evicts directly (nothing to
319
+ attribute — see the #472 section above for what "directly" skips). A red AGGREGATE
320
+ suite (every member individually green, but integration-level conflict) routes through
321
+ the #472 attribution/fix/evict library.
322
+ - **The tail**, after the last member: the aggregate suite runs deterministically; green
323
+ spawns ONE bounded strong-tier agent that runs `review-issue` aggregate mode then
324
+ `ship-issue` batch mode (rebase-merge, a `Closes` list) and parks the PR exactly like a
325
+ detached full-cycle run; the engine's own PR watcher (a batch-granularity mirror of the
326
+ per-issue one) accepts the merge and dispatches a cheap mechanical-tier agent for
327
+ `report-issue`'s batch variant.
328
+ - **Scope cuts, recorded rather than discovered later:** no `git bisect` stage for an
329
+ ambiguous aggregate failure (an unattributable red suite dissolves instead); no
330
+ worktree-pool integration for batch-setup (cold `git worktree add` only); no per-phase
331
+ stall/escalation ladder for batch sub-agents (a dead-without-verification agent is
332
+ treated as blocked, not redispatched stronger).
333
+
334
+ Schema 1.7.0: `BatchEntry` gains `worktree` (absolute path of the shared batch worktree,
335
+ null until batch-setup lands), `ranges` (`MemberRange[]` — each member's commit range,
336
+ recomputed after every member completes) and `pr` (the batch PR parked on auto-merge,
337
+ persisted so a restart mid-watch still knows what to poll). 1.6.0 states migrate on load:
338
+ no batch was ever dispatched under them, so `null`/`[]`/`null` is the exact backfill, not
339
+ a guess. Config schema moves to 1.3.0: `dispatch` gains `member_prompt`,
340
+ `batch_tail_prompt` and `batch_report_prompt` (the three new agent prompt templates).
341
+
342
+ New journal events: `batch-setup-done`, `batch-setup-failed`, `member-advanced`. Member/
343
+ tail/report/fix-agent spawn, progress, completion and park events reuse the existing
344
+ unit-generic names (`assigned`/`spawned`/`unit-failed`/`external-advance`/`pr-parked`/
345
+ `merge-accepted`/`report-dispatched`/`teardown-done`/`teardown-failed`) with
346
+ `unit = batch:<id>`.
347
+
273
348
  ## API surface
274
349
 
275
350
  ```ts
@@ -284,9 +359,11 @@ import {
284
359
  runLoop, // the sched start loop (tick, sleep, repeat)
285
360
  type TickResult, // what one tick did (spawned/parked/merge-accepted/stale-reconciled/
286
361
  // dependents-unblocked/report-dispatched/teardown/completed/
287
- // redispatched/failed/blocked)
362
+ // redispatched/failed/blocked) — since #523 also carries
363
+ // `batch:<id>` unit ids (issue numbers for `blocked`)
288
364
  type EngineDeps, // inject everything the engine touches (store/journal/spawn/ground
289
- // truth/clock/repoDir/teardownExec/fencer)
365
+ // truth/clock/repoDir/teardownExec/fencer/batchExec/runBatchSuite/
366
+ // runBatchCapability — #523)
290
367
  createSpawnDeps, // real detached-spawn process I/O
291
368
  createExecGroundTruth, // runstate/gh/git ground truth via subprocesses (injectable exec);
292
369
  // since #468 also gh pr view PR state + setup info from comments
@@ -337,9 +414,24 @@ import {
337
414
  transitionIssue, transitionBatch, transitionSlot, // typed §D transitions
338
415
  TRANSITIONS, // the transition tables themselves (for previews)
339
416
  buildStatusReport, // machine-readable status incl. blocked/failed sets
340
- validateState, // strict persisted-state validation (1.0.0-1.5.0 files migrate)
417
+ validateState, // strict persisted-state validation (1.0.0-1.6.0 files migrate)
341
418
  IllegalTransitionError, EnqueueError, CorruptStateError, LockTimeoutError,
342
419
  SchedNotFoundError,
420
+ runBatchTick, // #523: one batch reconcile+refill pass; called by tick() after
421
+ // the issue pass — loads/saves state itself, holds no lock
422
+ // across the call
423
+ type BatchDispatchDeps, // inject store/journal/groundTruth/spawnDeps/exec/runSuite/
424
+ // runCapability(optional)/fsExists(optional)
425
+ type BatchTickResult, // spawned/completed/parked/mergeAccepted/failed (batch:<id> ids)
426
+ // + blocked (issue numbers, dissolve-requeued)
427
+ type CapOutcome, // ok | task-failed | automation-broken | capability-unavailable
428
+ buildMemberPrompt, buildBatchTailPrompt, buildBatchReportPrompt, // #523 prompt builders
429
+ DEFAULT_MEMBER_PROMPT_TEMPLATE, DEFAULT_BATCH_TAIL_PROMPT_TEMPLATE,
430
+ DEFAULT_BATCH_REPORT_PROMPT_TEMPLATE,
431
+ isMemberComplete, isMemberBlocked, // member milestone predicates (mode=slot gated)
432
+ isBatchTailParked, // batch-ship awaiting-merge + pr= — the batch park signal
433
+ isBatchPhaseDone, // <phase> done on the anchor (batch-review/batch-report)
434
+ batchOfUnit, // batch:<id> → <id>; null for issue units or malformed ids
343
435
  } from '@ai-dossier/sched';
344
436
  ```
345
437
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Batch dispatch (#523, RFC-0001 §C.4/D.2/D.3): the missing driver that
3
+ * executes `batch:<id>` units. #498 landed the batch failure-recovery library
4
+ * (attribution/bisect/eviction/dissolve, `recovery.ts`) and the batch state
5
+ * machine (`state.ts`); readiness/placement already treat a `ready` batch as
6
+ * a runnable unit (`readiness.ts`, `scheduler.ts`). Nothing dispatched one
7
+ * until now.
8
+ *
9
+ * Shape, mirroring `engine.ts`'s per-issue dispatch: claim a slot → spawn an
10
+ * agent → poll ground truth → verify → transition. Generalized to `BatchEntry`
11
+ * at batch-phase granularity instead of per-issue-phase granularity:
12
+ *
13
+ * ```
14
+ * ready → executing(member i/N) ⟲ → validating → reviewing → shipping
15
+ * → awaiting-merge → merged → deployed → reported → done
16
+ * failure rails (RFC F.2/F.8/F.9):
17
+ * executing → dissolving (a member self-reports blocked, RFC F.1)
18
+ * validating → attributing → (fixing | evicting) → validating
19
+ * → dissolving
20
+ * ```
21
+ *
22
+ * NO batch claim — not the first (`ready → executing`) nor any continuation
23
+ * (a later member, the tail agent, the report agent, the fix agent) — ever
24
+ * goes through `computeAssignments`/`runnableUnits`. Every one is a bespoke
25
+ * free-capacity-gated assignment, the same shape `engine.ts`'s
26
+ * `dispatchReportAgents` already uses (`runnableUnits` only ever offers a
27
+ * `status === 'ready'` batch, i.e. the moment BEFORE any claim). Between
28
+ * steps — a suite run, a PR merge wait — the slot is released to `idle` and
29
+ * holds no capacity (AC5): only a live member/tail/report/fix agent holds a
30
+ * slot.
31
+ *
32
+ * The aggregate suite itself is deterministic engine work, not an LLM step —
33
+ * it runs with no slot claimed at all, matching AC5's "member or batch-LLM-step"
34
+ * wording precisely.
35
+ *
36
+ * Two distinct failure rails, deliberately different:
37
+ * - A member's OWN agent reports itself blocked (its own gate never went
38
+ * green) — evicted directly, no attribution needed: the offender is already
39
+ * known, and either it has no commits yet (blocked before implementing) or
40
+ * its commits are exactly what gets reverted.
41
+ * - The AGGREGATE suite (run by the engine after every member individually
42
+ * went green) comes back red — an integration-level conflict no member's own
43
+ * gate caught. THIS is what `recovery.ts`'s attribution/fix/evict pipeline
44
+ * exists for (RFC F.2).
45
+ *
46
+ * Scope decisions recorded here, not silently cut: no `git bisect` stage for
47
+ * an ambiguous aggregate failure (bisect needs a per-project "run only these
48
+ * tests" command this module has no generic way to construct) — an
49
+ * unattributable red aggregate suite dissolves the batch rather than
50
+ * bisecting, which `attributing → dissolving` already models. No worktree-pool
51
+ * integration for batch-setup — cold `git worktree add` only, mirroring
52
+ * `teardown.ts`'s cold path. No per-phase stall/escalation ladder for batch
53
+ * sub-agents — a dead-without-verification agent is treated as blocked and
54
+ * evicted/reported rather than redispatched stronger. Both are documented
55
+ * follow-ups, not gaps discovered later.
56
+ */
57
+ import { type ResolvedDispatch, type SpawnDeps } from './dispatch';
58
+ import { type GroundTruth } from './groundtruth';
59
+ import { type Journal } from './journal';
60
+ import type { SchedStore } from './persist';
61
+ import type { ExecFn } from './project';
62
+ import { type SuiteResult } from './recovery';
63
+ import { type FsExists } from './teardown';
64
+ import type { SchedConfig } from './types';
65
+ /**
66
+ * The four `ai-dossier cap run` outcomes (docs/reference/capabilities.md):
67
+ * `ok` = the capability ran and passed; `task-failed` = it ran and the TASK
68
+ * itself failed (trust the result); `automation-broken` = do not trust the
69
+ * machinery (missing tool, timeout, bad manifest); `capability-unavailable` =
70
+ * no manifest / no such id / `lifecycle: shadow` — no fast path here.
71
+ */
72
+ export type CapOutcome = 'ok' | 'task-failed' | 'automation-broken' | 'capability-unavailable';
73
+ /** Everything batch dispatch needs from the outside world. */
74
+ export interface BatchDispatchDeps {
75
+ store: SchedStore;
76
+ journal: Journal;
77
+ groundTruth: GroundTruth;
78
+ spawnDeps: SpawnDeps;
79
+ now: () => Date;
80
+ /** Repo root — cwd for `git`/`ai-dossier` calls that are not batch-worktree-scoped. */
81
+ repoDir: string;
82
+ /** Exec for batch git/milestone-CLI operations (never throws — the `ExecFn` contract). */
83
+ exec: ExecFn;
84
+ /** Runs the aggregate suite inside a batch worktree; batches never leave `validating` without one. */
85
+ runSuite: (worktree: string) => SuiteResult;
86
+ /**
87
+ * Runs one `ai-dossier cap run <capabilityId>` in a batch worktree for the
88
+ * per-member incremental gate (#523 AC2 — "typecheck + focused tests via
89
+ * `cap run test.focused` when available"). Optional and degrade-not-crash,
90
+ * like `batchExec`/`runBatchSuite`: without it, or on `automation-broken`/
91
+ * `capability-unavailable`, the gate is skipped — the member's own
92
+ * `slot-cycle` run already attempted this fast path (with its own reasoning
93
+ * fallback) before ever posting `review done`, so a repo with no manifest
94
+ * loses nothing but the engine's independent re-check.
95
+ */
96
+ runCapability?: (worktree: string, capabilityId: string) => CapOutcome;
97
+ fsExists?: FsExists;
98
+ }
99
+ /** What one `runBatchTick` call did, merged into `engine.ts`'s `TickResult` by the caller. */
100
+ export interface BatchTickResult {
101
+ spawned: string[];
102
+ completed: string[];
103
+ parked: string[];
104
+ mergeAccepted: string[];
105
+ failed: string[];
106
+ /** Issue numbers requeued full-cycle by a dissolve — matches `TickResult.blocked`'s shape. */
107
+ blocked: number[];
108
+ }
109
+ /**
110
+ * One batch reconcile+refill pass. Called from `engine.ts`'s `tick()` after
111
+ * the issue-level pass — batches never compete with issues for a slot within
112
+ * the same tick because this pass runs strictly after `dispatchAssignments`
113
+ * already filled every slot it could (see the module doc: batch claims never
114
+ * go through `computeAssignments`/`runnableUnits` at all). Loads and saves
115
+ * state itself via `deps.store.withLock` — the caller holds no lock across
116
+ * this call. `deps.exec` and `deps.runSuite` are mandatory; `deps.
117
+ * runCapability` is independently optional (AC2's incremental gate is itself
118
+ * a "when available" fast path).
119
+ */
120
+ export declare function runBatchTick(deps: BatchDispatchDeps, config: SchedConfig, dispatch: ResolvedDispatch): BatchTickResult;
121
+ //# sourceMappingURL=batch-dispatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAWH,OAAO,EAKL,KAAK,gBAAgB,EACrB,KAAK,SAAS,EAEf,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,KAAK,WAAW,EAOjB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,OAAO,EAAa,MAAM,WAAW,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EASL,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAWpB,OAAO,EAAE,KAAK,QAAQ,EAA+B,MAAM,YAAY,CAAC;AACxE,OAAO,KAAK,EAKV,WAAW,EAIZ,MAAM,SAAS,CAAC;AAEjB;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,aAAa,GAAG,mBAAmB,GAAG,wBAAwB,CAAC;AAE/F,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,UAAU,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,WAAW,CAAC;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,GAAG,EAAE,MAAM,IAAI,CAAC;IAChB,uFAAuF;IACvF,OAAO,EAAE,MAAM,CAAC;IAChB,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAC;IACb,sGAAsG;IACtG,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,WAAW,CAAC;IAC5C;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,UAAU,CAAC;IACvE,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACrB;AAED,8FAA8F;AAC9F,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,8FAA8F;IAC9F,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AA8uCD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,iBAAiB,EACvB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,gBAAgB,GACzB,eAAe,CAsEjB"}