@ai-dossier/sched 0.15.0 → 0.17.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
@@ -172,6 +172,17 @@ containing a `blocked` batch with "unknown batch status", bricking that project'
172
172
  `SchedStore.load()`. Resume or abandon any blocked batch before downgrading past this
173
173
  version.
174
174
 
175
+ Config schema moves to 1.7.0 (#563): a new top-level `dissolve_policy` key —
176
+ `{ fraction, min_evictions_before_dissolve }`, both required when the key is present —
177
+ overrides the batch dissolve threshold (default `{ fraction: 1/3,
178
+ min_evictions_before_dissolve: 1 }`, RFC-0001 §F.8's ⅓ with no additional floor). An
179
+ absent key falls back wholesale to the default; an invalid one degrades the WHOLE config
180
+ file to built-in defaults, same as every other config field (`loadConfig`'s
181
+ degrade-to-defaults contract). This release also adds the `partial` `dissolveBatch`
182
+ strategy and the `batch-preserved` journal event (see Batch failure recovery above) — both
183
+ are behavioral, not persisted-shape changes, so they carry no schema-version bump of their
184
+ own.
185
+
175
186
  Two engine-safety policies were explicit product decisions on #464:
176
187
 
177
188
  - **Pid identity is hybrid-verified (decision 1, option C).** Every spawn records the
@@ -248,7 +259,11 @@ against real scratch repos.
248
259
  ```
249
260
  validating → attributing → fixing (ONE bounded attempt) → validating
250
261
  → evicting (revert the member's commits) → validating
251
- > of members evicted, or a revert conflict → dissolving → members requeued
262
+ evictions > max(ceil(N × fraction), min_evictions_before_dissolve), or a
263
+ revert conflict → dissolving → members requeued (`dissolve_policy`, #563)
264
+ same threshold crossed, but the survivors' re-run suite came back green
265
+ → reviewing (batch preserved; only the evicted
266
+ members requeue — strategy=partial, #563)
252
267
  suite report unreadable, after the fallback retry when one applied
253
268
  → blocked → validating (nothing requeued/reverted; #562)
254
269
  awaiting-merge (CONFLICTING | auto-merge-blocked)
@@ -280,13 +295,24 @@ awaiting-merge (CONFLICTING | auto-merge-blocked)
280
295
  aborted so the worktree is clean and the batch dissolves — the reverts that already
281
296
  landed ride along on the abandoned branch, which is why it is abandoned rather than
282
297
  reused. An eviction group that reaches an already-shipped member dissolves instead of
283
- reverting merged work.
298
+ reverting merged work. Crossing the dissolve trigger no longer always dissolves (#563):
299
+ if the re-run suite came back green for the survivors — and every evicted member's
300
+ commits were actually found and reverted — the batch is PRESERVED instead: trimmed to
301
+ its survivors and carried straight to `reviewing`, only the evicted members requeue. A
302
+ red or unreadable re-run, or an evicted member whose commits were never found on the
303
+ branch, still dissolves in full.
284
304
  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
305
+ UNSHIPPED member: `full` (each as its own full-cycle run), `halved` (one or two fresh
286
306
  `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.
307
+ eviction groups inherited where they survive the split), or `partial` (#563 — never
308
+ marks the batch `dissolved` at all: drops the evicted members from `batch.members` and
309
+ its `eviction_groups`/`ranges`, transitions `validating reviewing`, and requeues
310
+ nothing itself, since the caller's eviction loop already did; falls through to `full`
311
+ if that would leave zero survivors, so an empty batch never ships). Shipped and
312
+ terminal members keep their outcome; nothing green is discarded, and no git runs — the
313
+ batch branch is simply left behind unmerged, since sched deletes nothing. Every dissolve
314
+ decision — all three strategies — journals its policy inputs (`N=`, `evictions=`,
315
+ `threshold=`), so it is explainable without re-deriving the formula.
290
316
  5. **PR conflict (AC4)** — `handlePrConflict` rebases the batch branch, re-runs the suite
291
317
  and re-ships ONCE. A second occurrence, a conflicting rebase, a failed fetch, an
292
318
  unusable `base_branch`, a checkout that is not on the batch branch, or a red suite
@@ -299,12 +325,13 @@ awaiting-merge (CONFLICTING | auto-merge-blocked)
299
325
  with no `anchor` or no `run_id` cannot post — the CLI requires both — so the milestone
300
326
  it could not post is journaled in full instead of vanishing.
301
327
 
302
- Eleven journal events carry the detail: `suite-failed`, `attributed`, `fix-dispatched`,
328
+ Twelve journal events carry the detail: `suite-failed`, `attributed`, `fix-dispatched`,
303
329
  `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).
330
+ `batch-preserved` (#563 — the dissolve threshold was crossed but the survivors' re-run
331
+ suite came back green, so the batch ships them instead of dissolving), `batch-blocked`
332
+ (#562 the suite report was unreadable), `batch-split` and `milestone-post-failed`, plus
333
+ `git-failed` for any git command that returned non-zero (the injected `ExecFn` collapses
334
+ every git failure into `null`, so the command that produced one is always recorded).
308
335
 
309
336
  Schema 1.3.0 carries the new state: `BatchEntry` gains `anchor`, `branch`, `run_id`,
310
337
  `eviction_groups`, `evictions`, `fix_attempts` and `rebase_attempts`; `QueueEntry` gains
@@ -365,8 +392,14 @@ failure rails: executing → dissolving (a member self-reports blocked)
365
392
  ```
366
393
 
367
394
  - **One shared worktree/branch per batch**, claimed once by a deterministic (no LLM)
368
- `batch-setup` step: `git branch`/`push`/`worktree add` off `base_branch`, named
369
- `batch/<id>-<date>`, plus a fresh `ai-dossier runstate mint` against the anchor issue.
395
+ `batch-setup` step, named `batch/<id>-<date>`, plus a fresh `ai-dossier runstate mint`
396
+ against the anchor issue. Tries a pool claim first (`npx worktree-pool claim`
397
+ already warm by construction, `BatchEntry.pool_claimed`); on the cold `git branch`/
398
+ `push`/`worktree add` path, batch-setup warms the worktree itself before returning
399
+ (#561) — `cap run worktree.prepare` when the repo's manifest declares it, else
400
+ package-manager-detected install/build (`@ai-dossier/worktree-pool`'s command
401
+ resolution — respects the repo's `.worktree-pool.json` `project_subdir`/
402
+ `warm_commands` even in repos that never use the pool for anything else).
370
403
  - **Members run serially, one fresh `slot-cycle` agent at a time**, in the shared
371
404
  worktree. A member's completion signal is `phase=review status=done mode=slot` on its
372
405
  OWN issue (`slot-cycle` posts no phase of its own past `review` — ship is batch-owned);
@@ -391,23 +424,26 @@ failure rails: executing → dissolving (a member self-reports blocked)
391
424
  `report-issue`'s batch variant.
392
425
  - **Scope cuts, recorded rather than discovered later:** no `git bisect` stage for an
393
426
  ambiguous aggregate failure (an unattributable red suite dissolves instead); no
394
- worktree-pool integration for batch-setup (cold `git worktree add` only); no per-phase
395
- stall/escalation ladder for batch sub-agents (a dead-without-verification agent is
396
- treated as blocked, not redispatched stronger).
427
+ per-phase stall/escalation ladder for batch sub-agents (a dead-without-verification
428
+ agent is treated as blocked, not redispatched stronger).
397
429
 
398
430
  Schema 1.7.0: `BatchEntry` gains `worktree` (absolute path of the shared batch worktree,
399
431
  null until batch-setup lands), `ranges` (`MemberRange[]` — each member's commit range,
400
432
  recomputed after every member completes) and `pr` (the batch PR parked on auto-merge,
401
433
  persisted so a restart mid-watch still knows what to poll). 1.6.0 states migrate on load:
402
434
  no batch was ever dispatched under them, so `null`/`[]`/`null` is the exact backfill, not
403
- a guess. Config schema moves to 1.3.0: `dispatch` gains `member_prompt`,
404
- `batch_tail_prompt` and `batch_report_prompt` (the three new agent prompt templates).
405
-
406
- New journal events: `batch-setup-done`, `batch-setup-failed`, `member-advanced`. Member/
407
- tail/report/fix-agent spawn, progress, completion and park events reuse the existing
408
- unit-generic names (`assigned`/`spawned`/`unit-failed`/`external-advance`/`pr-parked`/
409
- `merge-accepted`/`report-dispatched`/`teardown-done`/`teardown-failed`) with
410
- `unit = batch:<id>`.
435
+ a guess. `BatchEntry` also gains `pool_claimed` (#561) — pre-#561 states carry no such
436
+ key at all, and backfill it to `false` on load (no batch was ever pool-claimed before
437
+ batch-setup had pool integration). Config schema moves to 1.3.0: `dispatch` gains
438
+ `member_prompt`, `batch_tail_prompt` and `batch_report_prompt` (the three new agent
439
+ prompt templates).
440
+
441
+ New journal events: `batch-setup-done`, `batch-setup-failed`, `member-advanced`,
442
+ `batch-warmup-done`, `batch-warmup-failed` (#561 — the cold-path warm step only; a pool
443
+ claim emits neither). Member/tail/report/fix-agent spawn, progress, completion and park
444
+ events reuse the existing unit-generic names (`assigned`/`spawned`/`unit-failed`/
445
+ `external-advance`/`pr-parked`/`merge-accepted`/`report-dispatched`/`teardown-done`/
446
+ `teardown-failed`) with `unit = batch:<id>`.
411
447
 
412
448
  ## API surface
413
449
 
@@ -474,8 +510,8 @@ import {
474
510
  beginFixAttempt, // the ONE bounded mid-tier fix dispatch instruction
475
511
  resolveFixAttempt, // record its outcome, back to validating
476
512
  evictMembers, // revert + requeue with evidence + suite re-run + dissolve check
477
- checkDissolveTrigger, // pure: > of members evicted
478
- dissolveBatch, // full or halved dissolve; preserves everything green
513
+ checkDissolveTrigger, // pure: evicted > max(ceil(N × fraction), min floor) — dissolve_policy, #563
514
+ dissolveBatch, // full | halved | partial (#563); preserves everything green
479
515
  blockBatch, // #562: unreadable suite report → blocked; no requeue, no revert
480
516
  type BlockOptions, // { reason, milestonePhase? } for blockBatch
481
517
  handlePrConflict, // rebase + re-ship once, then dissolve into halves
@@ -542,7 +578,8 @@ telemetry" below.
542
578
  ├── config.json # durable intent: max_slots, stall_timeout_ms, reconcile_interval_ms,
543
579
  │ # pr_poll_interval_ms, dispatch (incl. report_prompt,
544
580
  │ # phase_stall_timeout_ms, fence_takeover_timeout_ms, tiers — #527,
545
- │ # suite_command — #562), auto_upgrade — #537
581
+ │ # suite_command — #562), auto_upgrade — #537,
582
+ │ # dissolve_policy — #563
546
583
  ├── events.jsonl # append-only event journal (the operator's flight recorder)
547
584
  ├── runs/ # per-unit agent output logs (issue-<n>.log)
548
585
  └── .sched-lock/ # cross-process directory mutex (pid; stolen from dead holders)
@@ -47,12 +47,18 @@
47
47
  * an ambiguous aggregate failure (bisect needs a per-project "run only these
48
48
  * tests" command this module has no generic way to construct) — an
49
49
  * unattributable red aggregate suite dissolves the batch rather than
50
- * bisecting, which `attributing → dissolving` already models. No worktree-pool
51
- * integration for batch-setupcold `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.
50
+ * bisecting, which `attributing → dissolving` already models. No per-phase
51
+ * stall/escalation ladder for batch sub-agentsa dead-without-verification
52
+ * agent is treated as blocked and evicted/reported rather than redispatched
53
+ * stronger. Both are documented follow-ups, not gaps discovered later.
54
+ *
55
+ * `runBatchSetup` (#561) tries a pool claim first (already warm, mirroring
56
+ * `teardown.ts`'s `poolReturn` — same `npx`-through-`deps.exec` pattern, never
57
+ * a direct in-process `claim()` import, which resolves its git root from
58
+ * `process.cwd()` and would break this module's `deps.repoDir` testability
59
+ * contract); on the cold `git worktree add` path it warms the worktree itself
60
+ * before returning, so a member's first command is never the one that
61
+ * discovers `node_modules` is missing (`env-cold`, `docs/agent-traps.md`).
56
62
  */
57
63
  import { type ResolvedDispatch, type SpawnDeps } from './dispatch';
58
64
  import { type GroundTruth } from './groundtruth';
@@ -81,17 +87,30 @@ export interface BatchDispatchDeps {
81
87
  repoDir: string;
82
88
  /** Exec for batch git/milestone-CLI operations (never throws — the `ExecFn` contract). */
83
89
  exec: ExecFn;
90
+ /**
91
+ * Exec for the cold-path warm-up install/build (#561), on its own budget —
92
+ * a real `npm ci` + build routinely exceeds the git-op timeout `exec` is
93
+ * tuned for. Falls back to `exec` when not supplied (existing callers/tests
94
+ * are unaffected; production wiring should still give this its own longer
95
+ * timeout, e.g. `@ai-dossier/worktree-pool`'s `WARM_COMMAND_TIMEOUT_MS`).
96
+ */
97
+ warmExec?: ExecFn;
84
98
  /** Runs the aggregate suite inside a batch worktree; batches never leave `validating` without one. */
85
99
  runSuite: (worktree: string) => SuiteResult;
86
100
  /**
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.
101
+ * Runs one `ai-dossier cap run <capabilityId>` in a batch worktree. Two call
102
+ * sites, different degrade contracts:
103
+ * - the per-member incremental gate (#523 AC2 — "typecheck + focused tests
104
+ * via `cap run test.focused` when available"): degrade-not-crash without
105
+ * this hook, or on `automation-broken`/`capability-unavailable`, the gate
106
+ * is skipped (the member's own `slot-cycle` run already attempted this
107
+ * fast path before ever posting `review done`, so a repo with no manifest
108
+ * loses nothing but the engine's independent re-check).
109
+ * - batch-setup's `worktree.prepare` warm step (#561, `warmColdBatchWorktree`):
110
+ * `undefined`/`capability-unavailable`/`automation-broken` fall through to
111
+ * package-manager detection, but a declared-and-`task-failed` capability
112
+ * hard-fails the whole batch setup — a repo that owns its warm-up should
113
+ * never be silently second-guessed by a fallback underneath it.
95
114
  */
96
115
  runCapability?: (worktree: string, capabilityId: string) => CapOutcome;
97
116
  fsExists?: FsExists;
@@ -1 +1 @@
1
- {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAWH,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,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;AA4xCD;;;;;;;;;;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,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"}
@@ -48,12 +48,18 @@
48
48
  * an ambiguous aggregate failure (bisect needs a per-project "run only these
49
49
  * tests" command this module has no generic way to construct) — an
50
50
  * unattributable red aggregate suite dissolves the batch rather than
51
- * bisecting, which `attributing → dissolving` already models. No worktree-pool
52
- * integration for batch-setupcold `git worktree add` only, mirroring
53
- * `teardown.ts`'s cold path. No per-phase stall/escalation ladder for batch
54
- * sub-agents a dead-without-verification agent is treated as blocked and
55
- * evicted/reported rather than redispatched stronger. Both are documented
56
- * follow-ups, not gaps discovered later.
51
+ * bisecting, which `attributing → dissolving` already models. No per-phase
52
+ * stall/escalation ladder for batch sub-agentsa dead-without-verification
53
+ * agent is treated as blocked and evicted/reported rather than redispatched
54
+ * stronger. Both are documented follow-ups, not gaps discovered later.
55
+ *
56
+ * `runBatchSetup` (#561) tries a pool claim first (already warm, mirroring
57
+ * `teardown.ts`'s `poolReturn` — same `npx`-through-`deps.exec` pattern, never
58
+ * a direct in-process `claim()` import, which resolves its git root from
59
+ * `process.cwd()` and would break this module's `deps.repoDir` testability
60
+ * contract); on the cold `git worktree add` path it warms the worktree itself
61
+ * before returning, so a member's first command is never the one that
62
+ * discovers `node_modules` is missing (`env-cold`, `docs/agent-traps.md`).
57
63
  */
58
64
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
59
65
  if (k2 === undefined) k2 = k;
@@ -90,7 +96,9 @@ var __importStar = (this && this.__importStar) || (function () {
90
96
  })();
91
97
  Object.defineProperty(exports, "__esModule", { value: true });
92
98
  exports.runBatchTick = runBatchTick;
99
+ const fs = __importStar(require("node:fs"));
93
100
  const path = __importStar(require("node:path"));
101
+ const worktree_pool_1 = require("@ai-dossier/worktree-pool");
94
102
  const attribution_1 = require("./attribution");
95
103
  const dispatch_1 = require("./dispatch");
96
104
  const groundtruth_1 = require("./groundtruth");
@@ -99,6 +107,7 @@ const recovery_1 = require("./recovery");
99
107
  const scheduler_1 = require("./scheduler");
100
108
  const state_1 = require("./state");
101
109
  const teardown_1 = require("./teardown");
110
+ const types_1 = require("./types");
102
111
  function emptyResult() {
103
112
  return { spawned: [], completed: [], parked: [], mergeAccepted: [], failed: [], blocked: [] };
104
113
  }
@@ -182,13 +191,14 @@ function safeSuite(deps, batchId, worktree) {
182
191
  return { ok: false, failing: [], readable: false, detail };
183
192
  }
184
193
  }
185
- function recoveryDeps(deps, batch, now) {
194
+ function recoveryDeps(deps, config, batch, now) {
186
195
  return {
187
196
  exec: deps.exec,
188
197
  repoDir: batch.worktree ?? deps.repoDir,
189
198
  journal: deps.journal,
190
199
  postMilestone: (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir }),
191
200
  runSuite: batch.worktree !== null ? () => deps.runSuite(batch.worktree) : undefined,
201
+ dissolvePolicy: (0, types_1.resolveDissolvePolicy)(config.dissolve_policy),
192
202
  now: () => now,
193
203
  };
194
204
  }
@@ -230,11 +240,93 @@ function releaseSlot(state, batchId, now) {
230
240
  }
231
241
  // --- Batch setup (ready → executing, member 1) ---
232
242
  /**
233
- * `batch/<id>-<YYYYMMDD>` off `base_branch`, a worktree at
234
- * `<repoDir>/worktrees/batch-<id>-<YYYYMMDD>` (cold git only no pool
235
- * integration in this version, see the module doc), and a fresh runstate run
236
- * id minted against the anchor. All-or-nothing: any failed step reports the
237
- * step name and nothing is partially recorded on the batch.
243
+ * A pool claim already returns a warm worktree (deps installed, built), so
244
+ * only the cold `git worktree add` path needs its own warm step (#561).
245
+ *
246
+ * Tries `worktree.prepare` via the optional `runCapability` hook first (a
247
+ * repo that declares it in its manifest knows its own warm-up best) — a
248
+ * declared-and-`task-failed` capability hard-fails the whole batch setup
249
+ * (deliberately NOT degrade-not-crash, unlike every other `runCapability`
250
+ * call site in this file: a repo that owns `worktree.prepare` should never
251
+ * be silently second-guessed by falling back underneath it). `undefined`,
252
+ * `capability-unavailable` (no such id declared) AND `automation-broken`
253
+ * (broken manifest, missing tool, timeout — "do not trust the machinery",
254
+ * not "the task failed") all fall through to the generic path below.
255
+ *
256
+ * The generic path reuses `@ai-dossier/worktree-pool`'s package-manager
257
+ * detection, guarded on `package.json` actually being present — `npm
258
+ * install` (and the pnpm/yarn/bun equivalents) hard-fail with no
259
+ * `package.json` at all, so an unguarded call would turn "nothing to warm"
260
+ * into a false failure for every non-Node repo. The guard is bypassed when
261
+ * `.worktree-pool.json` declares explicit `warm_commands` — the escape hatch
262
+ * for a repo whose warm-up isn't `npm install`-shaped at all.
263
+ *
264
+ * Warm commands run on `deps.warmExec ?? deps.exec` — a separate, longer
265
+ * budget than the git/milestone calls `deps.exec` is tuned for (a cold
266
+ * install+build routinely exceeds a git-op timeout; see `BatchDispatchDeps`).
267
+ *
268
+ * Journals `batch-warmup-done`/`batch-warmup-failed` with the elapsed time
269
+ * appended to `detail` either way (satisfying AC1 even on the no-op branch —
270
+ * `JournalEvent` has no dedicated `duration_ms` field).
271
+ */
272
+ function warmColdBatchWorktree(deps, batch, worktree, now,
273
+ /** Prefixed onto the journaled detail — e.g. `pool-claim-invalid:` when the cold path was taken because a pool claim returned unusable output, rather than the ordinary "no warm spares" case. */
274
+ poolNote = '') {
275
+ const warmStart = deps.now();
276
+ const elapsedMs = () => deps.now().getTime() - warmStart.getTime();
277
+ const fail = (tag) => {
278
+ deps.journal.append((0, journal_1.unitEvent)('batch-warmup-failed', unit(batch.id), {
279
+ detail: `${poolNote}${tag} ${elapsedMs()}ms`,
280
+ }), now);
281
+ return { ok: false, reason: `warmup-failed:${tag}` };
282
+ };
283
+ const done = (tag) => {
284
+ deps.journal.append((0, journal_1.unitEvent)('batch-warmup-done', unit(batch.id), {
285
+ detail: `${poolNote}${tag} ${elapsedMs()}ms`,
286
+ }), now);
287
+ return { ok: true };
288
+ };
289
+ const capOutcome = deps.runCapability?.(worktree, 'worktree.prepare');
290
+ if (capOutcome === 'ok')
291
+ return done('cap:worktree.prepare');
292
+ if (capOutcome === 'task-failed')
293
+ return fail('cap:worktree.prepare:task-failed');
294
+ // `undefined` (no hook injected) / `capability-unavailable` (not declared)
295
+ // / `automation-broken` (declared but the machinery itself is untrustworthy)
296
+ // all fall through to the generic package-manager path below.
297
+ const fsExists = deps.fsExists ?? ((p) => fs.existsSync(p));
298
+ const cfg = (0, worktree_pool_1.readPoolFileConfig)(deps.repoDir);
299
+ const projectDir = (0, worktree_pool_1.resolveProjectDir)(worktree, cfg.project_subdir);
300
+ // `.worktree-pool.json`'s `project_subdir` is repo config, not attacker
301
+ // input, but a `../`-shaped value would otherwise run install/build
302
+ // OUTSIDE the batch worktree entirely — skip rather than warm the wrong
303
+ // directory (or fail the whole batch over a misconfigured pool file).
304
+ if (projectDir !== worktree && !projectDir.startsWith(worktree + path.sep)) {
305
+ return done('skipped:project-dir-outside-worktree');
306
+ }
307
+ const hasExplicitWarmCommands = (cfg.warm_commands?.length ?? 0) > 0;
308
+ if (!hasExplicitWarmCommands && !fsExists(path.join(projectDir, 'package.json'))) {
309
+ return done('skipped:no-package-json');
310
+ }
311
+ const commands = (0, worktree_pool_1.resolveWarmCommands)(projectDir, cfg);
312
+ for (const [i, cmd] of commands.entries()) {
313
+ const [bin, ...args] = cmd;
314
+ if (bin === undefined)
315
+ return fail(`pm:${i + 1}/${commands.length}:empty-command`);
316
+ if ((deps.warmExec ?? deps.exec)(bin, args, projectDir) === null) {
317
+ return fail(`pm:${i + 1}/${commands.length}:${bin}`);
318
+ }
319
+ }
320
+ const pm = commands[0]?.[0] ?? 'none';
321
+ return done(`pm:${pm}:${commands.length}cmds`);
322
+ }
323
+ /**
324
+ * `batch/<id>-<YYYYMMDD>` off `base_branch`, and a fresh runstate run id
325
+ * minted against the anchor. The worktree is either a pool claim (already
326
+ * warm) or a cold `git worktree add` that this function then warms itself
327
+ * (#561, see the module doc) — either way, the worktree is warm before this
328
+ * returns `ok: true`. All-or-nothing: any failed step reports the step name
329
+ * and nothing is partially recorded on the batch.
238
330
  */
239
331
  function runBatchSetup(deps, batch, now) {
240
332
  if (batch.anchor === null)
@@ -257,6 +349,36 @@ function runBatchSetup(deps, batch, now) {
257
349
  if (runId === null || runId.trim() === '')
258
350
  return { ok: false, reason: 'runstate-mint-failed' };
259
351
  const mintedRunId = runId.trim();
352
+ // Pool claim first — a claimed worktree is already warm by construction, so
353
+ // no separate warm step runs for it (AC2). `claim()` itself never pushes
354
+ // the branch (mirrors setup-issue-workflow's per-issue pool-claim step),
355
+ // so that still happens here on success.
356
+ const claimed = deps.exec(teardown_1.POOL_BIN, [...teardown_1.POOL_ARGS_PREFIX, 'claim', '--issue', String(batch.anchor), '--branch', branch], deps.repoDir);
357
+ const claimedWorktree = claimed?.trim();
358
+ // Same hardening `teardown.ts`'s destructive sinks apply to a worktree path
359
+ // (no NUL/newline, no unresolved `..`) — this one is our own CLI's stdout,
360
+ // not attacker input, but garbage here would otherwise be trusted verbatim
361
+ // as `BatchEntry.worktree` and used as a spawn/exec cwd for every member.
362
+ const claimIsUsable = !!claimedWorktree &&
363
+ !claimedWorktree.includes('\0') &&
364
+ !claimedWorktree.includes('\n') &&
365
+ path.isAbsolute(claimedWorktree) &&
366
+ path.resolve(claimedWorktree) === claimedWorktree &&
367
+ (deps.fsExists ?? ((p) => fs.existsSync(p)))(claimedWorktree);
368
+ if (claimedWorktree && claimIsUsable) {
369
+ if (deps.exec('git', ['push', '-u', 'origin', '--', branch], claimedWorktree) === null) {
370
+ // Return the claim rather than leaking a permanently `assigned` pool
371
+ // entry nothing else will ever reference — `BatchEntry` only records
372
+ // `worktree`/`pool_claimed` on the `ok: true` path below.
373
+ deps.exec(teardown_1.POOL_BIN, [...teardown_1.POOL_ARGS_PREFIX, 'return', '--path', claimedWorktree, '--json'], deps.repoDir);
374
+ return { ok: false, reason: 'branch-push-failed', runId: mintedRunId };
375
+ }
376
+ return { ok: true, branch, worktree: claimedWorktree, runId: mintedRunId, poolClaimed: true };
377
+ }
378
+ // A non-null, non-empty, unusable claim response (rather than a plain "no
379
+ // warm spares" null/empty) is unusual enough to note on the batch that
380
+ // otherwise cold-builds silently.
381
+ const poolNote = claimedWorktree ? 'pool-claim-invalid:' : '';
260
382
  if (deps.exec('git', ['fetch', 'origin', '--', batch.base_branch], deps.repoDir) === null) {
261
383
  return { ok: false, reason: 'fetch-failed', runId: mintedRunId };
262
384
  }
@@ -269,7 +391,20 @@ function runBatchSetup(deps, batch, now) {
269
391
  if (deps.exec('git', ['worktree', 'add', '--', worktree, branch], deps.repoDir) === null) {
270
392
  return { ok: false, reason: 'worktree-add-failed', runId: mintedRunId };
271
393
  }
272
- return { ok: true, branch, worktree, runId: mintedRunId };
394
+ const warmed = warmColdBatchWorktree(deps, batch, worktree, now, poolNote);
395
+ if (!warmed.ok) {
396
+ // Restore the "all-or-nothing" contract this function documents: a warm
397
+ // failure otherwise leaves the branch pushed and the worktree on disk,
398
+ // so the NEXT tick's retry dies at `branch-create-failed` forever
399
+ // instead of ever reaching warm-up again. Best-effort — a failed cleanup
400
+ // here just means the next retry's `worktree-add-failed`/`branch-create-failed`
401
+ // surfaces the leftover instead, no worse than before this cleanup existed.
402
+ deps.exec('git', ['worktree', 'remove', '--force', '--', worktree], deps.repoDir);
403
+ deps.exec('git', ['branch', '-D', branch], deps.repoDir);
404
+ deps.exec('git', ['push', 'origin', '--delete', branch], deps.repoDir);
405
+ return { ok: false, reason: warmed.reason, runId: mintedRunId };
406
+ }
407
+ return { ok: true, branch, worktree, runId: mintedRunId, poolClaimed: false };
273
408
  }
274
409
  /** Spawn one batch member's `slot-cycle` agent into the slot batch-setup (or a prior member) just released. */
275
410
  /**
@@ -422,7 +557,12 @@ function claimAndSetup(deps, config, dispatch, batchId, now, result) {
422
557
  poster(batch.anchor, setup.runId, {
423
558
  phase: 'batch-setup',
424
559
  status: 'done',
425
- kv: { branch: setup.branch, worktree: setup.worktree, base_branch: batch.base_branch },
560
+ kv: {
561
+ branch: setup.branch,
562
+ worktree: setup.worktree,
563
+ base_branch: batch.base_branch,
564
+ pool_claimed: setup.poolClaimed ? 'true' : 'false',
565
+ },
426
566
  });
427
567
  deps.journal.append((0, journal_1.unitEvent)('batch-setup-done', unit(batchId), { detail: setup.worktree }), now);
428
568
  deps.store.withLock((s) => {
@@ -440,7 +580,12 @@ function claimAndSetup(deps, config, dispatch, batchId, now, result) {
440
580
  });
441
581
  return { state: releaseSlot(s, batchId, now), result: undefined };
442
582
  }
443
- let next = (0, state_1.patchBatch)(s, batchId, { branch: setup.branch, worktree: setup.worktree, run_id: setup.runId }, now);
583
+ let next = (0, state_1.patchBatch)(s, batchId, {
584
+ branch: setup.branch,
585
+ worktree: setup.worktree,
586
+ run_id: setup.runId,
587
+ pool_claimed: setup.poolClaimed,
588
+ }, now);
444
589
  next = (0, state_1.transitionBatch)(next, batchId, 'executing', { executing_member: 1 }, now);
445
590
  const slot = slotFor(next, batchId);
446
591
  if (slot)
@@ -604,7 +749,7 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
604
749
  if (!batch || batch.worktree === null)
605
750
  return;
606
751
  const suite = safeSuite(deps, batchId, batch.worktree);
607
- const rDeps = recoveryDeps(deps, batch, now);
752
+ const rDeps = recoveryDeps(deps, config, batch, now);
608
753
  const poster = (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir });
609
754
  if (suite.ok) {
610
755
  if (batch.anchor !== null && batch.run_id !== null) {
@@ -696,12 +841,12 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
696
841
  return next;
697
842
  });
698
843
  }
699
- function evictOffender(deps, _config, batchId, offender, attribution, now, result) {
844
+ function evictOffender(deps, config, batchId, offender, attribution, now, result) {
700
845
  const state = deps.store.load();
701
846
  const batch = (0, state_1.findBatch)(state, batchId);
702
847
  if (!batch)
703
848
  return;
704
- const rDeps = recoveryDeps(deps, batch, now);
849
+ const rDeps = recoveryDeps(deps, config, batch, now);
705
850
  const outcome = (0, recovery_1.evictMembers)(state, batchId, { issues: [offender], reason: 'suite-red-after-fix', attribution, ranges: batch.ranges }, rDeps);
706
851
  deps.store.withLock((s) => ({
707
852
  state: applyBatchAndIssues(s, outcome.state, batchId, outcome.requeued),
@@ -757,7 +902,7 @@ function advanceMemberOrValidate(deps, config, dispatch, batchId, memberCount, c
757
902
  * (self-reported blocked, and the incremental gate below).
758
903
  */
759
904
  function evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIssue, reason, now, result) {
760
- const dissolved = evictMemberDirectly(deps, batchId, memberIssue, reason, now);
905
+ const dissolved = evictMemberDirectly(deps, config, batchId, memberIssue, reason, now);
761
906
  if (dissolved) {
762
907
  result.failed.push(unit(batchId));
763
908
  return;
@@ -854,7 +999,8 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
854
999
  * and a commit range to revert; a member evicted here has neither). Returns
855
1000
  * whether the batch dissolved.
856
1001
  */
857
- function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
1002
+ function evictMemberDirectly(deps, config, batchId, memberIssue, reason, now) {
1003
+ const dissolvePolicy = (0, types_1.resolveDissolvePolicy)(config.dissolve_policy);
858
1004
  // Pass 1 (pure — requeue + record the eviction): safe to run entirely
859
1005
  // inside the lock, unlike `dissolveBatch` below, which shells out
860
1006
  // (`deps.exec`/`postMilestone`) and so must NOT hold the lock while it runs.
@@ -886,7 +1032,10 @@ function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
886
1032
  ],
887
1033
  }, now);
888
1034
  const updated = (0, state_1.findBatch)(next, batchId);
889
- return { state: next, result: updated !== undefined && (0, recovery_1.checkDissolveTrigger)(updated) };
1035
+ return {
1036
+ state: next,
1037
+ result: updated !== undefined && (0, recovery_1.checkDissolveTrigger)(updated, dissolvePolicy),
1038
+ };
890
1039
  });
891
1040
  if (!triggered)
892
1041
  return false;
@@ -897,7 +1046,7 @@ function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
897
1046
  const batch = (0, state_1.findBatch)(state, batchId);
898
1047
  if (!batch)
899
1048
  return false;
900
- const rDeps = recoveryDeps(deps, batch, now);
1049
+ const rDeps = recoveryDeps(deps, config, batch, now);
901
1050
  const outcome = (0, recovery_1.dissolveBatch)(state, batchId, { strategy: 'full', reason: 'eviction-threshold' }, rDeps);
902
1051
  deps.store.withLock((s) => ({
903
1052
  state: applyBatchAndIssues(s, outcome.state, batchId, outcome.requeued),
@@ -918,7 +1067,7 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
918
1067
  return;
919
1068
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
920
1069
  const suite = safeSuite(deps, batchId, batch.worktree);
921
- const rDeps = recoveryDeps(deps, batch, now);
1070
+ const rDeps = recoveryDeps(deps, config, batch, now);
922
1071
  const { state: resolved } = (0, recovery_1.resolveFixAttempt)(deps.store.load(), batchId, offenderRecord.issue, suite.ok ? 'green' : 'red', rDeps);
923
1072
  deps.store.withLock((s) => ({
924
1073
  state: applyBatchAndIssues(s, resolved, batchId, []),
@@ -1064,14 +1213,18 @@ function teardownBatch(deps, batchId) {
1064
1213
  if (!batch || batch.worktree === null)
1065
1214
  return;
1066
1215
  const root = deps.exec('git', ['rev-parse', '--show-toplevel'], deps.repoDir) ?? deps.repoDir;
1067
- if (!(0, teardown_1.isSafeWorktree)(path.resolve(root), batch.worktree)) {
1216
+ // Pool-claimed worktrees are validated by the pool's own `return` (it only
1217
+ // accepts paths in pool state) and can legitimately live outside either
1218
+ // `isSafeWorktree` root when `.worktree-pool.json` configures a custom
1219
+ // `pool_dir` — mirrors `runTeardown`'s own internal skip for `poolClaimed`.
1220
+ if (batch.pool_claimed !== true && !(0, teardown_1.isSafeWorktree)(path.resolve(root), batch.worktree)) {
1068
1221
  journalEvent(deps, 'teardown-failed', unit(batchId), {
1069
1222
  reason: 'unsafe-worktree-path',
1070
1223
  detail: batch.worktree,
1071
1224
  });
1072
1225
  return;
1073
1226
  }
1074
- const result = (0, teardown_1.runTeardown)(deps.exec, deps.repoDir, { worktree: batch.worktree, poolClaimed: false, branch: batch.branch }, deps.fsExists);
1227
+ const result = (0, teardown_1.runTeardown)(deps.exec, deps.repoDir, { worktree: batch.worktree, poolClaimed: batch.pool_claimed === true, branch: batch.branch }, deps.fsExists);
1075
1228
  journalEvent(deps, result.cleanup === 'done' ? 'teardown-done' : 'teardown-failed', unit(batchId), {
1076
1229
  cleanup: result.cleanup,
1077
1230
  detail: result.detail,
@@ -1148,7 +1301,7 @@ function runBatchTick(deps, config, dispatch) {
1148
1301
  ? [...b.fix_attempts].reverse().find((a) => a.outcome === 'dispatched')
1149
1302
  : undefined;
1150
1303
  if (b && offenderRecord) {
1151
- const rDeps = recoveryDeps(deps, b, now);
1304
+ const rDeps = recoveryDeps(deps, config, b, now);
1152
1305
  const { state: resolved } = (0, recovery_1.resolveFixAttempt)(state, batch.id, offenderRecord.issue, 'red', rDeps);
1153
1306
  deps.store.withLock((s) => ({
1154
1307
  state: applyBatchAndIssues(s, resolved, batch.id, []),