@ai-dossier/sched 0.17.0 → 0.18.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.
Files changed (52) hide show
  1. package/README.md +92 -9
  2. package/dist/batch-dispatch.d.ts +16 -8
  3. package/dist/batch-dispatch.d.ts.map +1 -1
  4. package/dist/batch-dispatch.js +107 -15
  5. package/dist/batch-dispatch.js.map +1 -1
  6. package/dist/batch-stats.d.ts +62 -0
  7. package/dist/batch-stats.d.ts.map +1 -0
  8. package/dist/batch-stats.js +185 -0
  9. package/dist/batch-stats.js.map +1 -0
  10. package/dist/dispatch.d.ts +13 -0
  11. package/dist/dispatch.d.ts.map +1 -1
  12. package/dist/dispatch.js +25 -0
  13. package/dist/dispatch.js.map +1 -1
  14. package/dist/engine.d.ts +4 -2
  15. package/dist/engine.d.ts.map +1 -1
  16. package/dist/engine.js +49 -44
  17. package/dist/engine.js.map +1 -1
  18. package/dist/enqueue.d.ts +22 -0
  19. package/dist/enqueue.d.ts.map +1 -1
  20. package/dist/enqueue.js +33 -0
  21. package/dist/enqueue.js.map +1 -1
  22. package/dist/index.d.ts +4 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +14 -4
  25. package/dist/index.js.map +1 -1
  26. package/dist/persist.d.ts.map +1 -1
  27. package/dist/persist.js +10 -1
  28. package/dist/persist.js.map +1 -1
  29. package/dist/readiness.d.ts +38 -4
  30. package/dist/readiness.d.ts.map +1 -1
  31. package/dist/readiness.js +57 -8
  32. package/dist/readiness.js.map +1 -1
  33. package/dist/recovery.d.ts.map +1 -1
  34. package/dist/recovery.js +5 -0
  35. package/dist/recovery.js.map +1 -1
  36. package/dist/run-log.d.ts +19 -0
  37. package/dist/run-log.d.ts.map +1 -1
  38. package/dist/run-log.js +46 -0
  39. package/dist/run-log.js.map +1 -1
  40. package/dist/scheduler.d.ts +29 -4
  41. package/dist/scheduler.d.ts.map +1 -1
  42. package/dist/scheduler.js +62 -4
  43. package/dist/scheduler.js.map +1 -1
  44. package/dist/state.d.ts +5 -2
  45. package/dist/state.d.ts.map +1 -1
  46. package/dist/state.js +29 -1
  47. package/dist/state.js.map +1 -1
  48. package/dist/types.d.ts +40 -3
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/types.js +15 -3
  51. package/dist/types.js.map +1 -1
  52. package/package.json +1 -1
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
@@ -183,6 +187,12 @@ strategy and the `batch-preserved` journal event (see Batch failure recovery abo
183
187
  are behavioral, not persisted-shape changes, so they carry no schema-version bump of their
184
188
  own.
185
189
 
190
+ Config schema moves to 1.8.0 (#565): a new top-level `default_batch_priority` key
191
+ (integer) — the `BatchEntry.priority` a batch gets when created with no explicit
192
+ `batch_priority` (see Unit priority below). Absent → `DEFAULT_BATCH_PRIORITY` (10); an
193
+ invalid value degrades the whole config file to built-in defaults, same contract as every
194
+ other field.
195
+
186
196
  Two engine-safety policies were explicit product decisions on #464:
187
197
 
188
198
  - **Pid identity is hybrid-verified (decision 1, option C).** Every spawn records the
@@ -377,6 +387,49 @@ load, backfilling `null`: no label re-check ever ran under them, so the first ti
377
387
  the upgrade polls immediately rather than waiting out a throttle window it has no
378
388
  evidence for — the exact backfill, not a guess.
379
389
 
390
+ Schema 1.10.0 (#565): `QueueEntry` gains `priority` (integer, default 0) and `BatchEntry`
391
+ gains `priority` (integer, default `DEFAULT_BATCH_PRIORITY` = 10) — see Unit priority
392
+ below. 1.9.0 and earlier states migrate on load, backfilling absent OR explicit `null` to
393
+ those same defaults: nothing before this field existed was ever weighted differently, so
394
+ the backfill is exact, not a guess.
395
+
396
+ ## Unit priority (#565)
397
+
398
+ `readiness.ts`'s `runnableUnits` ranks EVERY candidate — issues and batches together — by
399
+ `priority` desc, then readiness age (`updated_at`) asc, then a numeric tiebreak (an
400
+ issue's own number, or a batch's anchor issue) asc, via the exported `compareByPriority`
401
+ comparator and its `entryRank`/`batchRank` helpers. A batch's default priority
402
+ (`DEFAULT_BATCH_PRIORITY`, 10, or the configured `default_batch_priority`) outranks a
403
+ full-cycle entry's default (0) so a ready batch is offered a free slot before a
404
+ same-readiness issue competing for it — closing the gap
405
+ `docs/reports/batch-pilot-2-execution.md` §13.4 found (an operator manually deferring
406
+ full-cycle entries by hand so a batch could claim its slot).
407
+
408
+ Applying that ordering to who actually DISPATCHES took more than sorting: `engine.ts`'s
409
+ `dispatchAssignments` (the issue-only dispatch pass) now runs `computeAssignments` as a
410
+ READ-ONLY dry run over both kinds each tick, discards the returned state, and applies
411
+ only the issue winners — reserving a free slot for a higher-priority ready batch instead
412
+ of handing it to a same-tick issue. `batch-dispatch.ts`'s own ready-batch claim loop
413
+ (which never goes through `computeAssignments`/`runnableUnits` itself) later that same
414
+ tick claims the capacity the reservation left free, sorted by the same comparator when
415
+ more than one batch is ready. The reservation is gated on the batch pass actually being
416
+ configured (`batchExec`/`runBatchSuite`) — without that gate, a `ready` batch with
417
+ nothing able to claim it would withdraw capacity from issues forever instead of one tick.
418
+
419
+ `sched enqueue --priority <n>` sets a full-cycle entry's own priority; with `--mode slot`
420
+ it instead sets the BATCH's priority — a batch-level fact like `anchor`/`run_id`,
421
+ agreement-checked on a later join (an incremental `--more-members-expected` call, or a
422
+ manifest split across `--from-manifest` calls, must never silently re-point it). `sched
423
+ reprioritize --issue <n>|--batch <id> --priority <n>` adjusts a queued unit's weight in
424
+ place — no abandon/re-enqueue round trip, which would also reset every other field
425
+ `enqueueEntries` does not accept as a re-supply — and deliberately does not bump
426
+ `updated_at` (the readiness-age tiebreak), journaling a `reprioritized` event with the
427
+ previous value instead. `sched status`'s Queue and Batches tables both show a `priority`
428
+ column; a slot-mode member's own priority is never read by the scheduler (only the BATCH
429
+ row governs assignment), so the Queue table renders `-` for it rather than a number that
430
+ looks load-bearing but is not. A batch dissolve (`recovery.ts`'s `dissolveBatch`) carries
431
+ the parent batch's priority forward onto both split halves.
432
+
380
433
  ## Batch dispatch (#523)
381
434
 
382
435
  `batch-dispatch.ts`'s `runBatchTick` — called from `tick()` after the issue-level pass,
@@ -453,7 +506,13 @@ import {
453
506
  enqueueEntries, // validated queue appends (cycles, dupes, mode/batch rules)
454
507
  parseManifest, // batch-prep JSON → EnqueueInput[]
455
508
  computeAssignments, // pure: fill idle slots with runnable units, bounded by max_slots
456
- runnableUnits, // pure: which units may run right now (dep-gated)
509
+ runnableUnits, // pure: which units may run right now (dep-gated), in assignment
510
+ // order — priority desc → readiness age → issue/anchor (#565)
511
+ compareByPriority, // the priority/age/tiebreak comparator runnableUnits sorts with —
512
+ // also used directly by batch-dispatch.ts's ready-batch claim loop
513
+ entryRank, batchRank, // PriorityRank of a QueueEntry / BatchEntry (#565)
514
+ reprioritizeIssue, // sched reprioritize --issue: adjust priority in place, no
515
+ reprioritizeBatch, // abandon/re-enqueue round trip; refuses a terminal unit
457
516
  tick, // one engine cycle: reconcile + verify + refill + spawn,
458
517
  // and since #468: park-watch, teardown, report dispatch
459
518
  runLoop, // the sched start loop (tick, sleep, repeat)
@@ -528,7 +587,8 @@ import {
528
587
  transitionIssue, transitionBatch, transitionSlot, // typed §D transitions
529
588
  TRANSITIONS, // the transition tables themselves (for previews)
530
589
  buildStatusReport, // machine-readable status incl. blocked/failed sets
531
- validateState, // strict persisted-state validation (1.0.0-1.8.0 files migrate)
590
+ validateState, // strict persisted-state validation (1.0.0-1.9.0 files migrate)
591
+ DEFAULT_ISSUE_PRIORITY, DEFAULT_BATCH_PRIORITY, // priority defaults (0 / 10, #565)
532
592
  IllegalTransitionError, EnqueueError, CorruptStateError, LockTimeoutError,
533
593
  SchedNotFoundError,
534
594
  EngineTooOldError, // state schema newer than installed engine — not corruption (#537)
@@ -542,6 +602,11 @@ import {
542
602
  type SchedRunLogInput, // buildSchedRunLogEntry's input shape
543
603
  dispatchLogPath, // <runsDir>/<unit>.log — shared by spawn (offset) and record (read)
544
604
  fileSizeOrZero, // byte size of the dispatch log at spawn time, or 0
605
+ // #564: reconstruct a batch's dispatch costs from raw per-unit logs on
606
+ // disk, for batches with no runs.jsonl coverage (pre-#564, or torn down)
607
+ listBatchDispatchLogs, // every raw dispatch log found for a batch id, parsed from its filename
608
+ buildBatchRunLogEntries, // ...to RunLogEntry rows, same shape a live dispatch produces
609
+ type BatchLogEntry, // one parsed log entry (member/tail/report/fix)
545
610
  runBatchTick, // #523: one batch reconcile+refill pass; called by tick() after
546
611
  // the issue pass — loads/saves state itself, holds no lock
547
612
  // across the call
@@ -635,7 +700,25 @@ journaled `run-log-no-usage` with a `reason` — `log-unreadable`, `log-empty`,
635
700
  `no-usage-events` — so a row of dashes in `sched stats` can be explained without
636
701
  re-deriving it. A successful append is journaled `run-log-recorded`; a failed one,
637
702
  `run-log-failed` with the target file. Dispatches ended by `sched abandon` release the
638
- slot without recording, so they are not costed.
703
+ slot without recording, so they are not costed. (`finalizeRunLogEntry` in `run-log.ts`
704
+ is the single implementation of this journal-then-append tail, shared by
705
+ `recordDispatchRunLog` here and batch dispatch's `recordMemberRunLog` below — #564.)
706
+
707
+ **Batch members (#564).** `batch-dispatch.ts` spawns members/tail/report/fix agents
708
+ directly (`deps.spawnDeps.spawn()`), bypassing `recordDispatchRunLog` above entirely —
709
+ `runs.jsonl` had zero coverage for batches even after #524/#531 shipped the per-issue
710
+ capture. `recordMemberRunLog` (`batch-dispatch.ts`) closes that gap for MEMBER
711
+ dispatches, attributed to the same `issue:<n>` unit scheme ordinary dispatches use, so a
712
+ member's cost shows up in the default `sched stats` view with no new read-side logic.
713
+ Tail/report/fix agents still never write to `runs.jsonl` (wiring that in needs each of
714
+ their spawn functions to stamp `SlotEntry.spawned_at` first, same as the original
715
+ member bug); `sched stats --batch <id>` (`packages/sched/src/batch-stats.ts`) instead
716
+ recovers their cost — and any historical batch's, predating #564 or already torn down —
717
+ by reading the raw dispatch logs on disk directly, the same recovery a human previously
718
+ did by hand (`docs/reports/batch-pilot-2-execution.md` §13). Tokens/cost/model reproduce
719
+ exactly; `Duration`/`Tier` are always `-` for a `--batch`-reconstructed row (a raw log
720
+ carries neither the dispatch's spawn time nor its tier) — a structural limit of
721
+ after-the-fact recovery, not a missing-data bug.
639
722
 
640
723
  - **Crash safety**: a process killed between writes leaves the previous complete state,
641
724
  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 {
@@ -127,14 +132,17 @@ export interface BatchTickResult {
127
132
  }
128
133
  /**
129
134
  * One batch reconcile+refill pass. Called from `engine.ts`'s `tick()` after
130
- * the issue-level pass — batches never compete with issues for a slot within
131
- * the same tick because this pass runs strictly after `dispatchAssignments`
132
- * already filled every slot it could (see the module doc: batch claims never
133
- * go through `computeAssignments`/`runnableUnits` at all). Loads and saves
134
- * state itself via `deps.store.withLock` the caller holds no lock across
135
- * this call. `deps.exec` and `deps.runSuite` are mandatory; `deps.
136
- * runCapability` is independently optional (AC2's incremental gate is itself
137
- * a "when available" fast path).
135
+ * the issue-level pass — this pass never claims a slot the issue pass already
136
+ * gave to an issue (see the module doc: a batch's OWN claim never goes
137
+ * through `computeAssignments`/`runnableUnits`). It is not, however, run on
138
+ * leftovers: `dispatchAssignments` (#565) reserves capacity ahead of time for
139
+ * any ready batch that outranks a competing issue in `runnableUnits`'
140
+ * priority order, so a higher-priority batch is not starved by same-tick
141
+ * issue dispatch see that function's doc for the reservation mechanics.
142
+ * Loads and saves state itself via `deps.store.withLock` — the caller holds
143
+ * no lock across this call. `deps.exec` and `deps.runSuite` are mandatory;
144
+ * `deps.runCapability` is independently optional (AC2's incremental gate is
145
+ * itself a "when available" fast path).
138
146
  */
139
147
  export declare function runBatchTick(deps: BatchDispatchDeps, config: SchedConfig, dispatch: ResolvedDispatch): BatchTickResult;
140
148
  //# sourceMappingURL=batch-dispatch.d.ts.map
@@ -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;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;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;AAw8CD;;;;;;;;;;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;AAExC,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;AAsiDD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,iBAAiB,EACvB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,gBAAgB,GACzB,eAAe,CAsFjB"}
@@ -103,7 +103,9 @@ const attribution_1 = require("./attribution");
103
103
  const dispatch_1 = require("./dispatch");
104
104
  const groundtruth_1 = require("./groundtruth");
105
105
  const journal_1 = require("./journal");
106
+ const readiness_1 = require("./readiness");
106
107
  const recovery_1 = require("./recovery");
108
+ const run_log_1 = require("./run-log");
107
109
  const scheduler_1 = require("./scheduler");
108
110
  const state_1 = require("./state");
109
111
  const teardown_1 = require("./teardown");
@@ -469,7 +471,7 @@ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
469
471
  const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, tier, memberIssue);
470
472
  const cmd = spawnSpec.cmd;
471
473
  const prompt = (0, dispatch_1.buildMemberPrompt)(dispatch.memberPrompt, memberIssue, batchId, batch.worktree);
472
- const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-m${batch.executing_member}-${memberIssue}.log`);
474
+ const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
473
475
  let pid;
474
476
  try {
475
477
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -488,6 +490,10 @@ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
488
490
  pid_start: deps.spawnDeps.processStart(pid),
489
491
  phase: 'member',
490
492
  last_progress_at: now.toISOString(),
493
+ // #564: `engine.ts`'s own `spawnUnit` stamps this at spawn time
494
+ // (line ~745) — `spawnMember` never did, so `recordMemberRunLog`'s
495
+ // `slot.spawned_at === null` guard silently skipped every member.
496
+ spawned_at: now.toISOString(),
491
497
  };
492
498
  const next = slot.status === 'assigned' || slot.status === 'recovering'
493
499
  ? (0, state_1.transitionSlot)(withStatus, slot.id, 'running', patch, now)
@@ -529,6 +535,11 @@ function claimAndSetup(deps, config, dispatch, batchId, now, result) {
529
535
  const batch = (0, state_1.findBatch)(state, batchId);
530
536
  if (!batch)
531
537
  return;
538
+ // #565 AC2: "journaled on each assignment" — the issue-dispatch path
539
+ // (`engine.ts`'s `dispatchAssignments`) already does this on its own
540
+ // 'assigned' event; mirrored here so a batch's claim carries the same
541
+ // audit trail.
542
+ journalEvent(deps, 'assigned', unit(batchId), { slot: claimedSlot, priority: batch.priority });
532
543
  const setup = runBatchSetup(deps, batch, now);
533
544
  const poster = (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir });
534
545
  if (!setup.ok) {
@@ -617,6 +628,18 @@ function claimAndSpawn(deps, config, batchId, phase, now, spawn) {
617
628
  function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
618
629
  claimAndSpawn(deps, config, batchId, 'member', now, (state, slot) => spawnMember(deps, dispatch, state, slot, batchId, now, result));
619
630
  }
631
+ /**
632
+ * Tail/report/fix dispatches (this function, `spawnReportAgent`,
633
+ * `reconcileFixSlot`) are NOT recorded live to `runs.jsonl` — only member
634
+ * dispatches got that treatment in #564 (`spawnMember`'s call into
635
+ * `recordMemberRunLog`). `sched stats --batch <id>` (`batch-stats.ts`) is
636
+ * the only way to see their cost today, reconstructed from the raw log
637
+ * after the fact. Wiring in live recording for these later means repeating
638
+ * `spawnMember`'s own #564 fix first: none of these three spawn functions'
639
+ * patches stamp `SlotEntry.spawned_at` either, so a `recordXRunLog` guarded
640
+ * on `spawned_at !== null` (mirroring `recordMemberRunLog`) would silently
641
+ * no-op forever, exactly like the original bug.
642
+ */
620
643
  function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
621
644
  claimAndSpawn(deps, config, batchId, 'reviewing', now, (state, slot) => {
622
645
  const batch = (0, state_1.findBatch)(state, batchId);
@@ -630,7 +653,7 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
630
653
  const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, 'strong', batch.anchor);
631
654
  const cmd = spawnSpec.cmd;
632
655
  const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, batch.members, batch.worktree);
633
- const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-tail.log`);
656
+ const logFile = (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId);
634
657
  let pid;
635
658
  try {
636
659
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -680,7 +703,7 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
680
703
  const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, 'mechanical', batch.anchor);
681
704
  const cmd = spawnSpec.cmd;
682
705
  const prompt = (0, dispatch_1.buildBatchReportPrompt)(dispatch.batchReportPrompt, batchId, batch.anchor, prNumber);
683
- const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-report.log`);
706
+ const logFile = (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId);
684
707
  let pid;
685
708
  try {
686
709
  pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
@@ -812,7 +835,7 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
812
835
  return;
813
836
  }
814
837
  claimAndSpawn(deps, config, batchId, 'fixing', now, (s, slot) => {
815
- const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-fix-${offender}.log`);
838
+ const logFile = (0, dispatch_1.batchFixLogPath)(deps.store.runsDir, batchId, offender);
816
839
  let pid;
817
840
  try {
818
841
  pid = deps.spawnDeps.spawn(fixDispatch.command, fixDispatch.prompt, logFile);
@@ -909,6 +932,51 @@ function evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIs
909
932
  }
910
933
  advanceMemberOrValidate(deps, config, dispatch, batchId, batch.members.length, batch.executing_member, memberIssue, now, result);
911
934
  }
935
+ /**
936
+ * Record one member dispatch's tokens/cost to `runs.jsonl` (#564) — the
937
+ * `batch-dispatch.ts` analogue of `engine.ts`'s `recordDispatchRunLog`.
938
+ * Batch members never go through `engine.ts`'s per-unit spawn/record path
939
+ * (`spawnMember` calls `deps.spawnDeps.spawn()` directly), so #524's capture
940
+ * never covered them; this closes that gap using the exact same
941
+ * `buildSchedRunLogEntry`/`appendSchedRunLog` machinery, attributed to
942
+ * `issue:<memberIssue>` — the SAME unit scheme ordinary issue dispatches use,
943
+ * so a member's cost shows up in the default `sched stats` view with no new
944
+ * unit format for the read side to special-case.
945
+ *
946
+ * Exactly-once per dispatch, mirroring `recordDispatchRunLog`'s own
947
+ * invariant: called from both of `reconcileMemberSlot`'s exit branches
948
+ * (member complete, member blocked/dead) — a batch never redispatches the
949
+ * same member slot (eviction requeues it as an independent full-cycle run
950
+ * instead), so unlike `engine.ts`'s per-unit log, a member's log file is
951
+ * always one-shot and reading from offset 0 is always correct.
952
+ */
953
+ function recordMemberRunLog(deps, dispatch, state, batchId, batch, memberIssue, slot, now) {
954
+ if (slot.status !== 'running' || slot.spawned_at === null) {
955
+ journalEvent(deps, 'run-log-skipped', unit(batchId), {
956
+ issue: memberIssue,
957
+ reason: slot.spawned_at === null ? 'never-spawned' : `already-recorded-${slot.status}`,
958
+ slot: slot.id,
959
+ });
960
+ return;
961
+ }
962
+ const tier = (0, state_1.findEntry)(state, memberIssue)?.tier ?? 'mid';
963
+ const { cmd, model } = (0, dispatch_1.resolveTierSpawn)(dispatch, tier, memberIssue);
964
+ const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
965
+ const logContent = (0, run_log_1.readDispatchLog)(logFile, 0);
966
+ const runEntry = (0, run_log_1.buildSchedRunLogEntry)({
967
+ unit: `issue:${memberIssue}`,
968
+ role: 'batch-member',
969
+ cmd0: cmd[0],
970
+ cmd,
971
+ logContent,
972
+ spawnedAt: slot.spawned_at,
973
+ completedAt: now,
974
+ configuredModel: model,
975
+ cwd: deps.repoDir,
976
+ tier,
977
+ });
978
+ (0, run_log_1.finalizeRunLogEntry)(runEntry, logContent, deps.homeDir, (event, extra) => journalEvent(deps, event, unit(batchId), extra), { issue: memberIssue, log: logFile });
979
+ }
912
980
  function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result) {
913
981
  const state0 = deps.store.load();
914
982
  const batch = (0, state_1.findBatch)(state0, batchId);
@@ -926,6 +994,10 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
926
994
  issue: memberIssue,
927
995
  detail: 'member review done',
928
996
  }), now);
997
+ // The member's own agent process is done regardless of what the
998
+ // incremental gate below decides — record its telemetry once here (#564)
999
+ // rather than at each of this branch's two later exit points.
1000
+ recordMemberRunLog(deps, dispatch, state0, batchId, batch, memberIssue, slot, now);
929
1001
  // Incremental gate (#523 AC2): typecheck + focused tests via `cap run`,
930
1002
  // when the repo has a manifest for them — a second, independent check
931
1003
  // that the member's own self-reported "done" is real, matching this
@@ -975,6 +1047,7 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
975
1047
  : dead
976
1048
  ? 'agent-exited-unverified'
977
1049
  : 'member-blocked';
1050
+ recordMemberRunLog(deps, dispatch, state0, batchId, batch, memberIssue, slot, now);
978
1051
  journalEvent(deps, 'unit-failed', unit(batchId), {
979
1052
  issue: memberIssue,
980
1053
  reason,
@@ -1234,21 +1307,40 @@ function teardownBatch(deps, batchId) {
1234
1307
  // --- Entry point ---
1235
1308
  /**
1236
1309
  * One batch reconcile+refill pass. Called from `engine.ts`'s `tick()` after
1237
- * the issue-level pass — batches never compete with issues for a slot within
1238
- * the same tick because this pass runs strictly after `dispatchAssignments`
1239
- * already filled every slot it could (see the module doc: batch claims never
1240
- * go through `computeAssignments`/`runnableUnits` at all). Loads and saves
1241
- * state itself via `deps.store.withLock` the caller holds no lock across
1242
- * this call. `deps.exec` and `deps.runSuite` are mandatory; `deps.
1243
- * runCapability` is independently optional (AC2's incremental gate is itself
1244
- * a "when available" fast path).
1310
+ * the issue-level pass — this pass never claims a slot the issue pass already
1311
+ * gave to an issue (see the module doc: a batch's OWN claim never goes
1312
+ * through `computeAssignments`/`runnableUnits`). It is not, however, run on
1313
+ * leftovers: `dispatchAssignments` (#565) reserves capacity ahead of time for
1314
+ * any ready batch that outranks a competing issue in `runnableUnits`'
1315
+ * priority order, so a higher-priority batch is not starved by same-tick
1316
+ * issue dispatch see that function's doc for the reservation mechanics.
1317
+ * Loads and saves state itself via `deps.store.withLock` — the caller holds
1318
+ * no lock across this call. `deps.exec` and `deps.runSuite` are mandatory;
1319
+ * `deps.runCapability` is independently optional (AC2's incremental gate is
1320
+ * itself a "when available" fast path).
1245
1321
  */
1246
1322
  function runBatchTick(deps, config, dispatch) {
1247
1323
  const result = emptyResult();
1248
1324
  const now = deps.now();
1249
- for (const batch of deps.store.load().batches) {
1250
- if (batch.status === 'ready' && slotFor(deps.store.load(), batch.id) === undefined) {
1251
- claimAndSetup(deps, config, dispatch, batch.id, now, result);
1325
+ // #565: when more ready batches exist than free capacity, claim them in
1326
+ // the same priority order `runnableUnits` would (desc priority asc
1327
+ // readiness age anchor) — this loop never goes through
1328
+ // `computeAssignments`/`runnableUnits` itself (see the module doc), so it
1329
+ // applies the shared comparator/rank helpers directly. The ORDER and
1330
+ // MEMBERSHIP of `readyOrder` are frozen from a snapshot taken once up
1331
+ // front — a batch that becomes `ready` mid-pass waits for the next tick;
1332
+ // only the per-batch `slotFor`/`status` re-check inside the loop reads
1333
+ // fresh state (one `store.load()` per iteration, in case an earlier claim
1334
+ // in this same pass changed things).
1335
+ const readyOrder = [...deps.store.load().batches]
1336
+ .filter((b) => b.status === 'ready')
1337
+ .sort((a, b) => (0, readiness_1.compareByPriority)((0, readiness_1.batchRank)(a), (0, readiness_1.batchRank)(b)))
1338
+ .map((b) => b.id);
1339
+ for (const batchId of readyOrder) {
1340
+ const state = deps.store.load();
1341
+ const batch = (0, state_1.findBatch)(state, batchId);
1342
+ if (batch && batch.status === 'ready' && slotFor(state, batchId) === undefined) {
1343
+ claimAndSetup(deps, config, dispatch, batchId, now, result);
1252
1344
  }
1253
1345
  }
1254
1346
  for (const batch of deps.store.load().batches) {