@ai-dossier/sched 0.16.0 → 0.17.1

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
@@ -34,14 +34,18 @@ ai-dossier sched resume
34
34
  ai-dossier sched abandon --issue 42 --reason "operator abort"
35
35
  ai-dossier sched abandon --batch b1 # dissolve; members requeue as full-cycle
36
36
  ai-dossier sched stats --issues 4..9 # per-issue tokens/cost from ~/.dossier/runs.jsonl (#524)
37
+ ai-dossier sched stats --batch b1 --project owner-repo # batch member/tail/report/fix costs from raw dispatch logs (#564)
37
38
  ```
38
39
 
39
- Every subcommand except `stats` takes `--project <slug>` (default: `owner-repo` of the
40
- current directory, falling back to the repo basename — fleet-cycle's convention) and
41
- `--json`. `stats` reads `~/.dossier/runs.jsonl`, a single global file, not the
42
- per-project state — it takes `--json` and `--issues` only; see "runs.jsonl telemetry"
43
- below for the resulting cross-repo caveat (the same issue number in two repos sums
44
- together).
40
+ Every subcommand except `stats` (without `--batch`) takes `--project <slug>` (default:
41
+ `owner-repo` of the current directory, falling back to the repo basename — fleet-cycle's
42
+ convention) and `--json`. `stats` without `--batch` reads `~/.dossier/runs.jsonl`, a
43
+ single global file, not the per-project state — it takes `--json` and `--issues` only;
44
+ see "runs.jsonl telemetry" below for the resulting cross-repo caveat (the same issue
45
+ number in two repos sums together). `stats --batch <id>` instead takes `--project` like
46
+ every other subcommand and reads that project's `~/.dossier/sched/<project>/runs/`
47
+ directory directly, reconstructing costs from the raw dispatch logs rather than
48
+ `runs.jsonl` (#564) — see "Batch members (#564)" below.
45
49
 
46
50
  Since #507, `enqueue` additionally reads each candidate issue's live GitHub labels (one
47
51
  `gh issue view --json labels` call per issue, resolved against the current directory's repo
@@ -172,6 +176,17 @@ containing a `blocked` batch with "unknown batch status", bricking that project'
172
176
  `SchedStore.load()`. Resume or abandon any blocked batch before downgrading past this
173
177
  version.
174
178
 
179
+ Config schema moves to 1.7.0 (#563): a new top-level `dissolve_policy` key —
180
+ `{ fraction, min_evictions_before_dissolve }`, both required when the key is present —
181
+ overrides the batch dissolve threshold (default `{ fraction: 1/3,
182
+ min_evictions_before_dissolve: 1 }`, RFC-0001 §F.8's ⅓ with no additional floor). An
183
+ absent key falls back wholesale to the default; an invalid one degrades the WHOLE config
184
+ file to built-in defaults, same as every other config field (`loadConfig`'s
185
+ degrade-to-defaults contract). This release also adds the `partial` `dissolveBatch`
186
+ strategy and the `batch-preserved` journal event (see Batch failure recovery above) — both
187
+ are behavioral, not persisted-shape changes, so they carry no schema-version bump of their
188
+ own.
189
+
175
190
  Two engine-safety policies were explicit product decisions on #464:
176
191
 
177
192
  - **Pid identity is hybrid-verified (decision 1, option C).** Every spawn records the
@@ -248,7 +263,11 @@ against real scratch repos.
248
263
  ```
249
264
  validating → attributing → fixing (ONE bounded attempt) → validating
250
265
  → evicting (revert the member's commits) → validating
251
- > of members evicted, or a revert conflict → dissolving → members requeued
266
+ evictions > max(ceil(N × fraction), min_evictions_before_dissolve), or a
267
+ revert conflict → dissolving → members requeued (`dissolve_policy`, #563)
268
+ same threshold crossed, but the survivors' re-run suite came back green
269
+ → reviewing (batch preserved; only the evicted
270
+ members requeue — strategy=partial, #563)
252
271
  suite report unreadable, after the fallback retry when one applied
253
272
  → blocked → validating (nothing requeued/reverted; #562)
254
273
  awaiting-merge (CONFLICTING | auto-merge-blocked)
@@ -280,13 +299,24 @@ awaiting-merge (CONFLICTING | auto-merge-blocked)
280
299
  aborted so the worktree is clean and the batch dissolves — the reverts that already
281
300
  landed ride along on the abandoned branch, which is why it is abandoned rather than
282
301
  reused. An eviction group that reaches an already-shipped member dissolves instead of
283
- reverting merged work.
302
+ reverting merged work. Crossing the dissolve trigger no longer always dissolves (#563):
303
+ if the re-run suite came back green for the survivors — and every evicted member's
304
+ commits were actually found and reverted — the batch is PRESERVED instead: trimmed to
305
+ its survivors and carried straight to `reviewing`, only the evicted members requeue. A
306
+ red or unreadable re-run, or an evicted member whose commits were never found on the
307
+ branch, still dissolves in full.
284
308
  4. **Dissolve (AC3)** — `dissolveBatch` marks the batch `dissolved` and requeues every
285
- UNSHIPPED member: `full` (each as its own full-cycle run) or `halved` (one or two fresh
309
+ UNSHIPPED member: `full` (each as its own full-cycle run), `halved` (one or two fresh
286
310
  `forming` half-batches — a single remaining member yields one — entries retagged,
287
- eviction groups inherited where they survive the split). Shipped and terminal members
288
- keep their outcome; nothing green is discarded, and no git runs — the batch branch is
289
- simply left behind unmerged, since sched deletes nothing.
311
+ eviction groups inherited where they survive the split), or `partial` (#563 — never
312
+ marks the batch `dissolved` at all: drops the evicted members from `batch.members` and
313
+ its `eviction_groups`/`ranges`, transitions `validating reviewing`, and requeues
314
+ nothing itself, since the caller's eviction loop already did; falls through to `full`
315
+ if that would leave zero survivors, so an empty batch never ships). Shipped and
316
+ terminal members keep their outcome; nothing green is discarded, and no git runs — the
317
+ batch branch is simply left behind unmerged, since sched deletes nothing. Every dissolve
318
+ decision — all three strategies — journals its policy inputs (`N=`, `evictions=`,
319
+ `threshold=`), so it is explainable without re-deriving the formula.
290
320
  5. **PR conflict (AC4)** — `handlePrConflict` rebases the batch branch, re-runs the suite
291
321
  and re-ships ONCE. A second occurrence, a conflicting rebase, a failed fetch, an
292
322
  unusable `base_branch`, a checkout that is not on the batch branch, or a red suite
@@ -299,12 +329,13 @@ awaiting-merge (CONFLICTING | auto-merge-blocked)
299
329
  with no `anchor` or no `run_id` cannot post — the CLI requires both — so the milestone
300
330
  it could not post is journaled in full instead of vanishing.
301
331
 
302
- Eleven journal events carry the detail: `suite-failed`, `attributed`, `fix-dispatched`,
332
+ Twelve journal events carry the detail: `suite-failed`, `attributed`, `fix-dispatched`,
303
333
  `fix-resolved`, `member-evicted`, `revert-conflict`, `batch-rebased`, `batch-dissolved`,
304
- `batch-blocked` (#562 — the suite report was unreadable), `batch-split` and
305
- `milestone-post-failed`, plus `git-failed` for any git command that returned non-zero (the
306
- injected `ExecFn` collapses every git failure into `null`, so the command that produced one
307
- is always recorded).
334
+ `batch-preserved` (#563 — the dissolve threshold was crossed but the survivors' re-run
335
+ suite came back green, so the batch ships them instead of dissolving), `batch-blocked`
336
+ (#562 the suite report was unreadable), `batch-split` and `milestone-post-failed`, plus
337
+ `git-failed` for any git command that returned non-zero (the injected `ExecFn` collapses
338
+ every git failure into `null`, so the command that produced one is always recorded).
308
339
 
309
340
  Schema 1.3.0 carries the new state: `BatchEntry` gains `anchor`, `branch`, `run_id`,
310
341
  `eviction_groups`, `evictions`, `fix_attempts` and `rebase_attempts`; `QueueEntry` gains
@@ -483,8 +514,8 @@ import {
483
514
  beginFixAttempt, // the ONE bounded mid-tier fix dispatch instruction
484
515
  resolveFixAttempt, // record its outcome, back to validating
485
516
  evictMembers, // revert + requeue with evidence + suite re-run + dissolve check
486
- checkDissolveTrigger, // pure: > of members evicted
487
- dissolveBatch, // full or halved dissolve; preserves everything green
517
+ checkDissolveTrigger, // pure: evicted > max(ceil(N × fraction), min floor) — dissolve_policy, #563
518
+ dissolveBatch, // full | halved | partial (#563); preserves everything green
488
519
  blockBatch, // #562: unreadable suite report → blocked; no requeue, no revert
489
520
  type BlockOptions, // { reason, milestonePhase? } for blockBatch
490
521
  handlePrConflict, // rebase + re-ship once, then dissolve into halves
@@ -515,6 +546,11 @@ import {
515
546
  type SchedRunLogInput, // buildSchedRunLogEntry's input shape
516
547
  dispatchLogPath, // <runsDir>/<unit>.log — shared by spawn (offset) and record (read)
517
548
  fileSizeOrZero, // byte size of the dispatch log at spawn time, or 0
549
+ // #564: reconstruct a batch's dispatch costs from raw per-unit logs on
550
+ // disk, for batches with no runs.jsonl coverage (pre-#564, or torn down)
551
+ listBatchDispatchLogs, // every raw dispatch log found for a batch id, parsed from its filename
552
+ buildBatchRunLogEntries, // ...to RunLogEntry rows, same shape a live dispatch produces
553
+ type BatchLogEntry, // one parsed log entry (member/tail/report/fix)
518
554
  runBatchTick, // #523: one batch reconcile+refill pass; called by tick() after
519
555
  // the issue pass — loads/saves state itself, holds no lock
520
556
  // across the call
@@ -551,7 +587,8 @@ telemetry" below.
551
587
  ├── config.json # durable intent: max_slots, stall_timeout_ms, reconcile_interval_ms,
552
588
  │ # pr_poll_interval_ms, dispatch (incl. report_prompt,
553
589
  │ # phase_stall_timeout_ms, fence_takeover_timeout_ms, tiers — #527,
554
- │ # suite_command — #562), auto_upgrade — #537
590
+ │ # suite_command — #562), auto_upgrade — #537,
591
+ │ # dissolve_policy — #563
555
592
  ├── events.jsonl # append-only event journal (the operator's flight recorder)
556
593
  ├── runs/ # per-unit agent output logs (issue-<n>.log)
557
594
  └── .sched-lock/ # cross-process directory mutex (pid; stolen from dead holders)
@@ -607,7 +644,25 @@ journaled `run-log-no-usage` with a `reason` — `log-unreadable`, `log-empty`,
607
644
  `no-usage-events` — so a row of dashes in `sched stats` can be explained without
608
645
  re-deriving it. A successful append is journaled `run-log-recorded`; a failed one,
609
646
  `run-log-failed` with the target file. Dispatches ended by `sched abandon` release the
610
- slot without recording, so they are not costed.
647
+ slot without recording, so they are not costed. (`finalizeRunLogEntry` in `run-log.ts`
648
+ is the single implementation of this journal-then-append tail, shared by
649
+ `recordDispatchRunLog` here and batch dispatch's `recordMemberRunLog` below — #564.)
650
+
651
+ **Batch members (#564).** `batch-dispatch.ts` spawns members/tail/report/fix agents
652
+ directly (`deps.spawnDeps.spawn()`), bypassing `recordDispatchRunLog` above entirely —
653
+ `runs.jsonl` had zero coverage for batches even after #524/#531 shipped the per-issue
654
+ capture. `recordMemberRunLog` (`batch-dispatch.ts`) closes that gap for MEMBER
655
+ dispatches, attributed to the same `issue:<n>` unit scheme ordinary dispatches use, so a
656
+ member's cost shows up in the default `sched stats` view with no new read-side logic.
657
+ Tail/report/fix agents still never write to `runs.jsonl` (wiring that in needs each of
658
+ their spawn functions to stamp `SlotEntry.spawned_at` first, same as the original
659
+ member bug); `sched stats --batch <id>` (`packages/sched/src/batch-stats.ts`) instead
660
+ recovers their cost — and any historical batch's, predating #564 or already torn down —
661
+ by reading the raw dispatch logs on disk directly, the same recovery a human previously
662
+ did by hand (`docs/reports/batch-pilot-2-execution.md` §13). Tokens/cost/model reproduce
663
+ exactly; `Duration`/`Tier` are always `-` for a `--batch`-reconstructed row (a raw log
664
+ carries neither the dispatch's spawn time nor its tier) — a structural limit of
665
+ after-the-fact recovery, not a missing-data bug.
611
666
 
612
667
  - **Crash safety**: a process killed between writes leaves the previous complete state,
613
668
  never a partial file; restart resumes identically (proved by `restart.test.ts`) —
@@ -114,6 +114,11 @@ export interface BatchDispatchDeps {
114
114
  */
115
115
  runCapability?: (worktree: string, capabilityId: string) => CapOutcome;
116
116
  fsExists?: FsExists;
117
+ /**
118
+ * Home directory for `~/.dossier/runs.jsonl` (#564) — mirrors `EngineDeps.homeDir`.
119
+ * Undefined defers to `appendSchedRunLog`'s own `os.homedir()` default.
120
+ */
121
+ homeDir?: string;
117
122
  }
118
123
  /** What one `runBatchTick` call did, merged into `engine.ts`'s `TickResult` by the caller. */
119
124
  export interface BatchTickResult {
@@ -1 +1 @@
1
- {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAiBH,OAAO,EAKL,KAAK,gBAAgB,EAErB,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,EAUL,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAWpB,OAAO,EAAE,KAAK,QAAQ,EAA2D,MAAM,YAAY,CAAC;AACpG,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;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sGAAsG;IACtG,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,WAAW,CAAC;IAC5C;;;;;;;;;;;;;;OAcG;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;AA67CD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,iBAAiB,EACvB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,gBAAgB,GACzB,eAAe,CAsEjB"}
1
+ {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAiBH,OAAO,EASL,KAAK,gBAAgB,EAErB,KAAK,SAAS,EACf,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,EAUL,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAYpB,OAAO,EAAE,KAAK,QAAQ,EAA2D,MAAM,YAAY,CAAC;AACpG,OAAO,KAAK,EAKV,WAAW,EAIZ,MAAM,SAAS,CAAC;AAGjB;;;;;;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;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,sGAAsG;IACtG,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,WAAW,CAAC;IAC5C;;;;;;;;;;;;;;OAcG;IACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,UAAU,CAAC;IACvE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;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;AAiiDD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,iBAAiB,EACvB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,gBAAgB,GACzB,eAAe,CAsEjB"}
@@ -104,9 +104,11 @@ const dispatch_1 = require("./dispatch");
104
104
  const groundtruth_1 = require("./groundtruth");
105
105
  const journal_1 = require("./journal");
106
106
  const recovery_1 = require("./recovery");
107
+ const run_log_1 = require("./run-log");
107
108
  const scheduler_1 = require("./scheduler");
108
109
  const state_1 = require("./state");
109
110
  const teardown_1 = require("./teardown");
111
+ const types_1 = require("./types");
110
112
  function emptyResult() {
111
113
  return { spawned: [], completed: [], parked: [], mergeAccepted: [], failed: [], blocked: [] };
112
114
  }
@@ -190,13 +192,14 @@ function safeSuite(deps, batchId, worktree) {
190
192
  return { ok: false, failing: [], readable: false, detail };
191
193
  }
192
194
  }
193
- function recoveryDeps(deps, batch, now) {
195
+ function recoveryDeps(deps, config, batch, now) {
194
196
  return {
195
197
  exec: deps.exec,
196
198
  repoDir: batch.worktree ?? deps.repoDir,
197
199
  journal: deps.journal,
198
200
  postMilestone: (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir }),
199
201
  runSuite: batch.worktree !== null ? () => deps.runSuite(batch.worktree) : undefined,
202
+ dissolvePolicy: (0, types_1.resolveDissolvePolicy)(config.dissolve_policy),
200
203
  now: () => now,
201
204
  };
202
205
  }
@@ -467,7 +470,7 @@ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
467
470
  const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, tier, memberIssue);
468
471
  const cmd = spawnSpec.cmd;
469
472
  const prompt = (0, dispatch_1.buildMemberPrompt)(dispatch.memberPrompt, memberIssue, batchId, batch.worktree);
470
- const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-m${batch.executing_member}-${memberIssue}.log`);
473
+ const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
471
474
  let pid;
472
475
  try {
473
476
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -486,6 +489,10 @@ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
486
489
  pid_start: deps.spawnDeps.processStart(pid),
487
490
  phase: 'member',
488
491
  last_progress_at: now.toISOString(),
492
+ // #564: `engine.ts`'s own `spawnUnit` stamps this at spawn time
493
+ // (line ~745) — `spawnMember` never did, so `recordMemberRunLog`'s
494
+ // `slot.spawned_at === null` guard silently skipped every member.
495
+ spawned_at: now.toISOString(),
489
496
  };
490
497
  const next = slot.status === 'assigned' || slot.status === 'recovering'
491
498
  ? (0, state_1.transitionSlot)(withStatus, slot.id, 'running', patch, now)
@@ -615,6 +622,18 @@ function claimAndSpawn(deps, config, batchId, phase, now, spawn) {
615
622
  function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
616
623
  claimAndSpawn(deps, config, batchId, 'member', now, (state, slot) => spawnMember(deps, dispatch, state, slot, batchId, now, result));
617
624
  }
625
+ /**
626
+ * Tail/report/fix dispatches (this function, `spawnReportAgent`,
627
+ * `reconcileFixSlot`) are NOT recorded live to `runs.jsonl` — only member
628
+ * dispatches got that treatment in #564 (`spawnMember`'s call into
629
+ * `recordMemberRunLog`). `sched stats --batch <id>` (`batch-stats.ts`) is
630
+ * the only way to see their cost today, reconstructed from the raw log
631
+ * after the fact. Wiring in live recording for these later means repeating
632
+ * `spawnMember`'s own #564 fix first: none of these three spawn functions'
633
+ * patches stamp `SlotEntry.spawned_at` either, so a `recordXRunLog` guarded
634
+ * on `spawned_at !== null` (mirroring `recordMemberRunLog`) would silently
635
+ * no-op forever, exactly like the original bug.
636
+ */
618
637
  function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
619
638
  claimAndSpawn(deps, config, batchId, 'reviewing', now, (state, slot) => {
620
639
  const batch = (0, state_1.findBatch)(state, batchId);
@@ -628,7 +647,7 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
628
647
  const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, 'strong', batch.anchor);
629
648
  const cmd = spawnSpec.cmd;
630
649
  const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, batch.members, batch.worktree);
631
- const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-tail.log`);
650
+ const logFile = (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId);
632
651
  let pid;
633
652
  try {
634
653
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -678,7 +697,7 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
678
697
  const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, 'mechanical', batch.anchor);
679
698
  const cmd = spawnSpec.cmd;
680
699
  const prompt = (0, dispatch_1.buildBatchReportPrompt)(dispatch.batchReportPrompt, batchId, batch.anchor, prNumber);
681
- const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-report.log`);
700
+ const logFile = (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId);
682
701
  let pid;
683
702
  try {
684
703
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -747,7 +766,7 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
747
766
  if (!batch || batch.worktree === null)
748
767
  return;
749
768
  const suite = safeSuite(deps, batchId, batch.worktree);
750
- const rDeps = recoveryDeps(deps, batch, now);
769
+ const rDeps = recoveryDeps(deps, config, batch, now);
751
770
  const poster = (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir });
752
771
  if (suite.ok) {
753
772
  if (batch.anchor !== null && batch.run_id !== null) {
@@ -810,7 +829,7 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
810
829
  return;
811
830
  }
812
831
  claimAndSpawn(deps, config, batchId, 'fixing', now, (s, slot) => {
813
- const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-fix-${offender}.log`);
832
+ const logFile = (0, dispatch_1.batchFixLogPath)(deps.store.runsDir, batchId, offender);
814
833
  let pid;
815
834
  try {
816
835
  pid = deps.spawnDeps.spawn(fixDispatch.command, fixDispatch.prompt, logFile);
@@ -839,12 +858,12 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
839
858
  return next;
840
859
  });
841
860
  }
842
- function evictOffender(deps, _config, batchId, offender, attribution, now, result) {
861
+ function evictOffender(deps, config, batchId, offender, attribution, now, result) {
843
862
  const state = deps.store.load();
844
863
  const batch = (0, state_1.findBatch)(state, batchId);
845
864
  if (!batch)
846
865
  return;
847
- const rDeps = recoveryDeps(deps, batch, now);
866
+ const rDeps = recoveryDeps(deps, config, batch, now);
848
867
  const outcome = (0, recovery_1.evictMembers)(state, batchId, { issues: [offender], reason: 'suite-red-after-fix', attribution, ranges: batch.ranges }, rDeps);
849
868
  deps.store.withLock((s) => ({
850
869
  state: applyBatchAndIssues(s, outcome.state, batchId, outcome.requeued),
@@ -900,13 +919,58 @@ function advanceMemberOrValidate(deps, config, dispatch, batchId, memberCount, c
900
919
  * (self-reported blocked, and the incremental gate below).
901
920
  */
902
921
  function evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIssue, reason, now, result) {
903
- const dissolved = evictMemberDirectly(deps, batchId, memberIssue, reason, now);
922
+ const dissolved = evictMemberDirectly(deps, config, batchId, memberIssue, reason, now);
904
923
  if (dissolved) {
905
924
  result.failed.push(unit(batchId));
906
925
  return;
907
926
  }
908
927
  advanceMemberOrValidate(deps, config, dispatch, batchId, batch.members.length, batch.executing_member, memberIssue, now, result);
909
928
  }
929
+ /**
930
+ * Record one member dispatch's tokens/cost to `runs.jsonl` (#564) — the
931
+ * `batch-dispatch.ts` analogue of `engine.ts`'s `recordDispatchRunLog`.
932
+ * Batch members never go through `engine.ts`'s per-unit spawn/record path
933
+ * (`spawnMember` calls `deps.spawnDeps.spawn()` directly), so #524's capture
934
+ * never covered them; this closes that gap using the exact same
935
+ * `buildSchedRunLogEntry`/`appendSchedRunLog` machinery, attributed to
936
+ * `issue:<memberIssue>` — the SAME unit scheme ordinary issue dispatches use,
937
+ * so a member's cost shows up in the default `sched stats` view with no new
938
+ * unit format for the read side to special-case.
939
+ *
940
+ * Exactly-once per dispatch, mirroring `recordDispatchRunLog`'s own
941
+ * invariant: called from both of `reconcileMemberSlot`'s exit branches
942
+ * (member complete, member blocked/dead) — a batch never redispatches the
943
+ * same member slot (eviction requeues it as an independent full-cycle run
944
+ * instead), so unlike `engine.ts`'s per-unit log, a member's log file is
945
+ * always one-shot and reading from offset 0 is always correct.
946
+ */
947
+ function recordMemberRunLog(deps, dispatch, state, batchId, batch, memberIssue, slot, now) {
948
+ if (slot.status !== 'running' || slot.spawned_at === null) {
949
+ journalEvent(deps, 'run-log-skipped', unit(batchId), {
950
+ issue: memberIssue,
951
+ reason: slot.spawned_at === null ? 'never-spawned' : `already-recorded-${slot.status}`,
952
+ slot: slot.id,
953
+ });
954
+ return;
955
+ }
956
+ const tier = (0, state_1.findEntry)(state, memberIssue)?.tier ?? 'mid';
957
+ const { cmd, model } = (0, dispatch_1.resolveTierSpawn)(dispatch, tier, memberIssue);
958
+ const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
959
+ const logContent = (0, run_log_1.readDispatchLog)(logFile, 0);
960
+ const runEntry = (0, run_log_1.buildSchedRunLogEntry)({
961
+ unit: `issue:${memberIssue}`,
962
+ role: 'batch-member',
963
+ cmd0: cmd[0],
964
+ cmd,
965
+ logContent,
966
+ spawnedAt: slot.spawned_at,
967
+ completedAt: now,
968
+ configuredModel: model,
969
+ cwd: deps.repoDir,
970
+ tier,
971
+ });
972
+ (0, run_log_1.finalizeRunLogEntry)(runEntry, logContent, deps.homeDir, (event, extra) => journalEvent(deps, event, unit(batchId), extra), { issue: memberIssue, log: logFile });
973
+ }
910
974
  function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result) {
911
975
  const state0 = deps.store.load();
912
976
  const batch = (0, state_1.findBatch)(state0, batchId);
@@ -924,6 +988,10 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
924
988
  issue: memberIssue,
925
989
  detail: 'member review done',
926
990
  }), now);
991
+ // The member's own agent process is done regardless of what the
992
+ // incremental gate below decides — record its telemetry once here (#564)
993
+ // rather than at each of this branch's two later exit points.
994
+ recordMemberRunLog(deps, dispatch, state0, batchId, batch, memberIssue, slot, now);
927
995
  // Incremental gate (#523 AC2): typecheck + focused tests via `cap run`,
928
996
  // when the repo has a manifest for them — a second, independent check
929
997
  // that the member's own self-reported "done" is real, matching this
@@ -973,6 +1041,7 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
973
1041
  : dead
974
1042
  ? 'agent-exited-unverified'
975
1043
  : 'member-blocked';
1044
+ recordMemberRunLog(deps, dispatch, state0, batchId, batch, memberIssue, slot, now);
976
1045
  journalEvent(deps, 'unit-failed', unit(batchId), {
977
1046
  issue: memberIssue,
978
1047
  reason,
@@ -997,7 +1066,8 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
997
1066
  * and a commit range to revert; a member evicted here has neither). Returns
998
1067
  * whether the batch dissolved.
999
1068
  */
1000
- function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
1069
+ function evictMemberDirectly(deps, config, batchId, memberIssue, reason, now) {
1070
+ const dissolvePolicy = (0, types_1.resolveDissolvePolicy)(config.dissolve_policy);
1001
1071
  // Pass 1 (pure — requeue + record the eviction): safe to run entirely
1002
1072
  // inside the lock, unlike `dissolveBatch` below, which shells out
1003
1073
  // (`deps.exec`/`postMilestone`) and so must NOT hold the lock while it runs.
@@ -1029,7 +1099,10 @@ function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
1029
1099
  ],
1030
1100
  }, now);
1031
1101
  const updated = (0, state_1.findBatch)(next, batchId);
1032
- return { state: next, result: updated !== undefined && (0, recovery_1.checkDissolveTrigger)(updated) };
1102
+ return {
1103
+ state: next,
1104
+ result: updated !== undefined && (0, recovery_1.checkDissolveTrigger)(updated, dissolvePolicy),
1105
+ };
1033
1106
  });
1034
1107
  if (!triggered)
1035
1108
  return false;
@@ -1040,7 +1113,7 @@ function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
1040
1113
  const batch = (0, state_1.findBatch)(state, batchId);
1041
1114
  if (!batch)
1042
1115
  return false;
1043
- const rDeps = recoveryDeps(deps, batch, now);
1116
+ const rDeps = recoveryDeps(deps, config, batch, now);
1044
1117
  const outcome = (0, recovery_1.dissolveBatch)(state, batchId, { strategy: 'full', reason: 'eviction-threshold' }, rDeps);
1045
1118
  deps.store.withLock((s) => ({
1046
1119
  state: applyBatchAndIssues(s, outcome.state, batchId, outcome.requeued),
@@ -1061,7 +1134,7 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
1061
1134
  return;
1062
1135
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
1063
1136
  const suite = safeSuite(deps, batchId, batch.worktree);
1064
- const rDeps = recoveryDeps(deps, batch, now);
1137
+ const rDeps = recoveryDeps(deps, config, batch, now);
1065
1138
  const { state: resolved } = (0, recovery_1.resolveFixAttempt)(deps.store.load(), batchId, offenderRecord.issue, suite.ok ? 'green' : 'red', rDeps);
1066
1139
  deps.store.withLock((s) => ({
1067
1140
  state: applyBatchAndIssues(s, resolved, batchId, []),
@@ -1295,7 +1368,7 @@ function runBatchTick(deps, config, dispatch) {
1295
1368
  ? [...b.fix_attempts].reverse().find((a) => a.outcome === 'dispatched')
1296
1369
  : undefined;
1297
1370
  if (b && offenderRecord) {
1298
- const rDeps = recoveryDeps(deps, b, now);
1371
+ const rDeps = recoveryDeps(deps, config, b, now);
1299
1372
  const { state: resolved } = (0, recovery_1.resolveFixAttempt)(state, batch.id, offenderRecord.issue, 'red', rDeps);
1300
1373
  deps.store.withLock((s) => ({
1301
1374
  state: applyBatchAndIssues(s, resolved, batch.id, []),