@ai-dossier/sched 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1119 @@
1
+ "use strict";
2
+ /**
3
+ * Batch dispatch (#523, RFC-0001 §C.4/D.2/D.3): the missing driver that
4
+ * executes `batch:<id>` units. #498 landed the batch failure-recovery library
5
+ * (attribution/bisect/eviction/dissolve, `recovery.ts`) and the batch state
6
+ * machine (`state.ts`); readiness/placement already treat a `ready` batch as
7
+ * a runnable unit (`readiness.ts`, `scheduler.ts`). Nothing dispatched one
8
+ * until now.
9
+ *
10
+ * Shape, mirroring `engine.ts`'s per-issue dispatch: claim a slot → spawn an
11
+ * agent → poll ground truth → verify → transition. Generalized to `BatchEntry`
12
+ * at batch-phase granularity instead of per-issue-phase granularity:
13
+ *
14
+ * ```
15
+ * ready → executing(member i/N) ⟲ → validating → reviewing → shipping
16
+ * → awaiting-merge → merged → deployed → reported → done
17
+ * failure rails (RFC F.2/F.8/F.9):
18
+ * executing → dissolving (a member self-reports blocked, RFC F.1)
19
+ * validating → attributing → (fixing | evicting) → validating
20
+ * → dissolving
21
+ * ```
22
+ *
23
+ * NO batch claim — not the first (`ready → executing`) nor any continuation
24
+ * (a later member, the tail agent, the report agent, the fix agent) — ever
25
+ * goes through `computeAssignments`/`runnableUnits`. Every one is a bespoke
26
+ * free-capacity-gated assignment, the same shape `engine.ts`'s
27
+ * `dispatchReportAgents` already uses (`runnableUnits` only ever offers a
28
+ * `status === 'ready'` batch, i.e. the moment BEFORE any claim). Between
29
+ * steps — a suite run, a PR merge wait — the slot is released to `idle` and
30
+ * holds no capacity (AC5): only a live member/tail/report/fix agent holds a
31
+ * slot.
32
+ *
33
+ * The aggregate suite itself is deterministic engine work, not an LLM step —
34
+ * it runs with no slot claimed at all, matching AC5's "member or batch-LLM-step"
35
+ * wording precisely.
36
+ *
37
+ * Two distinct failure rails, deliberately different:
38
+ * - A member's OWN agent reports itself blocked (its own gate never went
39
+ * green) — evicted directly, no attribution needed: the offender is already
40
+ * known, and either it has no commits yet (blocked before implementing) or
41
+ * its commits are exactly what gets reverted.
42
+ * - The AGGREGATE suite (run by the engine after every member individually
43
+ * went green) comes back red — an integration-level conflict no member's own
44
+ * gate caught. THIS is what `recovery.ts`'s attribution/fix/evict pipeline
45
+ * exists for (RFC F.2).
46
+ *
47
+ * Scope decisions recorded here, not silently cut: no `git bisect` stage for
48
+ * an ambiguous aggregate failure (bisect needs a per-project "run only these
49
+ * tests" command this module has no generic way to construct) — an
50
+ * unattributable red aggregate suite dissolves the batch rather than
51
+ * bisecting, which `attributing → dissolving` already models. No worktree-pool
52
+ * integration for batch-setup — cold `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.
57
+ */
58
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
59
+ if (k2 === undefined) k2 = k;
60
+ var desc = Object.getOwnPropertyDescriptor(m, k);
61
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
62
+ desc = { enumerable: true, get: function() { return m[k]; } };
63
+ }
64
+ Object.defineProperty(o, k2, desc);
65
+ }) : (function(o, m, k, k2) {
66
+ if (k2 === undefined) k2 = k;
67
+ o[k2] = m[k];
68
+ }));
69
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
70
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
71
+ }) : function(o, v) {
72
+ o["default"] = v;
73
+ });
74
+ var __importStar = (this && this.__importStar) || (function () {
75
+ var ownKeys = function(o) {
76
+ ownKeys = Object.getOwnPropertyNames || function (o) {
77
+ var ar = [];
78
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
79
+ return ar;
80
+ };
81
+ return ownKeys(o);
82
+ };
83
+ return function (mod) {
84
+ if (mod && mod.__esModule) return mod;
85
+ var result = {};
86
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
87
+ __setModuleDefault(result, mod);
88
+ return result;
89
+ };
90
+ })();
91
+ Object.defineProperty(exports, "__esModule", { value: true });
92
+ exports.runBatchTick = runBatchTick;
93
+ const path = __importStar(require("node:path"));
94
+ const attribution_1 = require("./attribution");
95
+ const dispatch_1 = require("./dispatch");
96
+ const groundtruth_1 = require("./groundtruth");
97
+ const journal_1 = require("./journal");
98
+ const recovery_1 = require("./recovery");
99
+ const scheduler_1 = require("./scheduler");
100
+ const state_1 = require("./state");
101
+ const teardown_1 = require("./teardown");
102
+ function emptyResult() {
103
+ return { spawned: [], completed: [], parked: [], mergeAccepted: [], failed: [], blocked: [] };
104
+ }
105
+ function unit(batchId) {
106
+ return `batch:${batchId}`;
107
+ }
108
+ /**
109
+ * Sanitize one untrusted string before it lands in persisted state, the
110
+ * journal, or a `sched status` terminal render (CWE-117/150): a milestone
111
+ * `reason=` value originates from a GitHub issue comment (anyone who can
112
+ * comment on a member issue can set it) and `parseMilestoneJson` copies it
113
+ * verbatim with no charset or length bound. Strips control characters
114
+ * (including the ANSI escape prefix) and bounds the length.
115
+ */
116
+ function sanitizeUntrustedText(value) {
117
+ return (value
118
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: flattening control characters is the point
119
+ .replace(/[\u0000-\u001F\u007F]/g, ' ')
120
+ .slice(0, 200));
121
+ }
122
+ /**
123
+ * Journal one event. Loosely-typed `extra`, matching `engine.ts`'s own local
124
+ * `journal()` wrapper — `unitEvent`'s stricter `Omit<JournalEvent, ...>` typing
125
+ * excess-property-checks an inline object literal (e.g. rejecting `pr`, a key
126
+ * `JournalEvent` doesn't declare), where a pre-typed `Record<string, unknown>`
127
+ * value passed through a variable does not.
128
+ */
129
+ function journalEvent(deps, event, unitId, extra = {}) {
130
+ deps.journal.append((0, journal_1.unitEvent)(event, unitId, extra), deps.now());
131
+ }
132
+ function slotFor(state, batchId) {
133
+ return state.slots.find((s) => s.unit === unit(batchId));
134
+ }
135
+ /**
136
+ * Re-apply a state computed OUTSIDE the lock (by `recovery.ts`'s functions,
137
+ * which necessarily shell out — `git revert`, `ai-dossier runstate post` —
138
+ * and so cannot themselves run inside `store.withLock`) onto a FRESHLY
139
+ * loaded state, touching only `batchId`'s own batch record (plus any new
140
+ * half-batches a dissolve split created) and the named issues' queue
141
+ * entries. Anything a concurrent process wrote to `fresh` in the meantime —
142
+ * `sched enqueue`, `sched abandon`, `sched pause` all take the same
143
+ * cross-process lock — survives, where blindly returning `computed` wholesale
144
+ * would have silently clobbered it.
145
+ */
146
+ function applyBatchAndIssues(fresh, computed, batchId, issues) {
147
+ const updatedBatch = computed.batches.find((b) => b.id === batchId);
148
+ const batches = fresh.batches.map((b) => (b.id === batchId && updatedBatch ? updatedBatch : b));
149
+ // A `halved` dissolve creates new batch ids (`<id>-a`/`<id>-b`) that exist
150
+ // in `computed` but not yet in `fresh`.
151
+ for (const cb of computed.batches) {
152
+ if (!batches.some((b) => b.id === cb.id))
153
+ batches.push(cb);
154
+ }
155
+ const issueSet = new Set(issues);
156
+ const entries = fresh.entries.map((e) => {
157
+ if (!issueSet.has(e.issue))
158
+ return e;
159
+ return computed.entries.find((ce) => ce.issue === e.issue) ?? e;
160
+ });
161
+ return { ...fresh, batches, entries };
162
+ }
163
+ /**
164
+ * Run the aggregate suite, treating a THROWING runner as a red suite with no
165
+ * failing tests — `recovery.ts`'s own internal `runSuite` wrapper already
166
+ * does this for calls that go through `beginAttribution`/`evictMembers`/etc,
167
+ * but `runValidate`/`reconcileFixSlot` call `deps.runSuite` directly (they
168
+ * need the result before deciding whether to call into `recovery.ts` at
169
+ * all), so an unguarded throw there would propagate out of `runBatchTick`
170
+ * into `tick()`'s own catch — a bare `tick-failed` with no unit id, repeating
171
+ * every reconcile interval forever since nothing about the batch changed.
172
+ */
173
+ function safeSuite(deps, batchId, worktree) {
174
+ try {
175
+ return deps.runSuite(worktree);
176
+ }
177
+ catch (err) {
178
+ const detail = `suite runner threw: ${err.message}`;
179
+ journalEvent(deps, 'suite-failed', unit(batchId), { detail });
180
+ return { ok: false, failing: [], detail };
181
+ }
182
+ }
183
+ function recoveryDeps(deps, batch, now) {
184
+ return {
185
+ exec: deps.exec,
186
+ repoDir: batch.worktree ?? deps.repoDir,
187
+ journal: deps.journal,
188
+ postMilestone: (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir }),
189
+ runSuite: batch.worktree !== null ? () => deps.runSuite(batch.worktree) : undefined,
190
+ now: () => now,
191
+ };
192
+ }
193
+ /**
194
+ * The declared edge one step closer to `idle` from each `SlotStatus`
195
+ * (`state.ts`'s `SLOT_BASE_TRANSITIONS`) — `recovering` has no direct edge to
196
+ * `idle`, only `running`/`failed`, so it routes through `failed` first;
197
+ * getting this wrong throws `IllegalTransitionError` inside a lock.
198
+ */
199
+ const NEXT_TOWARD_IDLE = {
200
+ idle: null,
201
+ assigned: 'idle',
202
+ running: 'exited',
203
+ exited: 'verifying',
204
+ verifying: 'complete',
205
+ complete: 'idle',
206
+ recovering: 'failed',
207
+ failed: 'idle',
208
+ };
209
+ /**
210
+ * Release a batch's slot to idle, whatever status it currently holds (mirrors
211
+ * `engine.ts`'s `walkSlotToIdle` walk). Unlike that walk, this one does not
212
+ * journal `slot-released` (#525) — batch-slot release is not yet wired to
213
+ * that event, tracked as a follow-up.
214
+ */
215
+ function releaseSlot(state, batchId, now) {
216
+ let next = state;
217
+ let slot = slotFor(next, batchId);
218
+ // Bounded: the longest real walk (recovering → failed → idle, or
219
+ // running → exited → verifying → complete → idle) is 4 hops.
220
+ for (let i = 0; i < 8 && slot && slot.status !== 'idle'; i++) {
221
+ const to = NEXT_TOWARD_IDLE[slot.status];
222
+ if (to === null)
223
+ break;
224
+ next = (0, state_1.transitionSlot)(next, slot.id, to, {}, now);
225
+ slot = slotFor(next, batchId);
226
+ }
227
+ return next;
228
+ }
229
+ // --- Batch setup (ready → executing, member 1) ---
230
+ /**
231
+ * `batch/<id>-<YYYYMMDD>` off `base_branch`, a worktree at
232
+ * `<repoDir>/worktrees/batch-<id>-<YYYYMMDD>` (cold git only — no pool
233
+ * integration in this version, see the module doc), and a fresh runstate run
234
+ * id minted against the anchor. All-or-nothing: any failed step reports the
235
+ * step name and nothing is partially recorded on the batch.
236
+ */
237
+ function runBatchSetup(deps, batch, now) {
238
+ if (batch.anchor === null)
239
+ return { ok: false, reason: 'no-anchor' };
240
+ const date = now.toISOString().slice(0, 10).replaceAll('-', '');
241
+ const branch = `batch/${batch.id}-${date}`;
242
+ const worktree = path.join(deps.repoDir, 'worktrees', `batch-${batch.id}-${date}`);
243
+ if (!attribution_1.SAFE_REF_RE.test(branch) || !attribution_1.SAFE_REF_RE.test(batch.base_branch)) {
244
+ return { ok: false, reason: 'invalid-branch-name' };
245
+ }
246
+ // Defense in depth alongside enqueue.ts's `BATCH_ID_RE` (CWE-22): the batch
247
+ // id is enqueue-time-validated against path-hostile characters already, but
248
+ // this is the actual point where it becomes a filesystem path — the same
249
+ // containment check teardown applies on the way OUT must hold on the way IN.
250
+ const root = deps.exec('git', ['rev-parse', '--show-toplevel'], deps.repoDir) ?? deps.repoDir;
251
+ if (!(0, teardown_1.isSafeWorktree)(path.resolve(root), worktree)) {
252
+ return { ok: false, reason: 'invalid-worktree-path' };
253
+ }
254
+ const runId = deps.exec('ai-dossier', ['runstate', 'mint', '--issue', String(batch.anchor)], deps.repoDir);
255
+ if (runId === null || runId.trim() === '')
256
+ return { ok: false, reason: 'runstate-mint-failed' };
257
+ const mintedRunId = runId.trim();
258
+ if (deps.exec('git', ['fetch', 'origin', '--', batch.base_branch], deps.repoDir) === null) {
259
+ return { ok: false, reason: 'fetch-failed', runId: mintedRunId };
260
+ }
261
+ if (deps.exec('git', ['branch', branch, `origin/${batch.base_branch}`], deps.repoDir) === null) {
262
+ return { ok: false, reason: 'branch-create-failed', runId: mintedRunId };
263
+ }
264
+ if (deps.exec('git', ['push', '-u', 'origin', '--', branch], deps.repoDir) === null) {
265
+ return { ok: false, reason: 'branch-push-failed', runId: mintedRunId };
266
+ }
267
+ if (deps.exec('git', ['worktree', 'add', '--', worktree, branch], deps.repoDir) === null) {
268
+ return { ok: false, reason: 'worktree-add-failed', runId: mintedRunId };
269
+ }
270
+ return { ok: true, branch, worktree, runId: mintedRunId };
271
+ }
272
+ /** Spawn one batch member's `slot-cycle` agent into the slot batch-setup (or a prior member) just released. */
273
+ /**
274
+ * Drive a member's `QueueEntry` through the D.1 slot-line states it must pass
275
+ * through before `shipped-in-batch` becomes a legal edge (`validated` is the
276
+ * only state that transitions there) — `classified → batched → waiting →
277
+ * in-work`, each a no-op waypoint from the batch's perspective (the real
278
+ * waiting/working happens at BATCH granularity), applied idempotently so a
279
+ * member already past a given waypoint is left alone.
280
+ */
281
+ function advanceMemberToInWork(state, memberIssue, now) {
282
+ let next = state;
283
+ const chain = [
284
+ ['queued', 'classified'],
285
+ ['classified', 'batched'],
286
+ ['batched', 'waiting'],
287
+ ['waiting', 'in-work'],
288
+ ];
289
+ for (const [from, to] of chain) {
290
+ if ((0, state_1.findEntry)(next, memberIssue)?.status === from) {
291
+ next = (0, state_1.transitionIssue)(next, memberIssue, to, {}, now);
292
+ }
293
+ }
294
+ return next;
295
+ }
296
+ /** The completion half of the same chain: `in-work → committed → validated` (see `advanceMemberToInWork`). */
297
+ function advanceMemberToValidated(state, memberIssue, now) {
298
+ let next = state;
299
+ const chain = [
300
+ ['in-work', 'committed'],
301
+ ['committed', 'validated'],
302
+ ];
303
+ for (const [from, to] of chain) {
304
+ if ((0, state_1.findEntry)(next, memberIssue)?.status === from) {
305
+ next = (0, state_1.transitionIssue)(next, memberIssue, to, {}, now);
306
+ }
307
+ }
308
+ return next;
309
+ }
310
+ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
311
+ const batch = (0, state_1.findBatch)(state, batchId);
312
+ if (!batch || batch.worktree === null) {
313
+ // A leaked `assigned` slot with `pid: null` is invisible to `dead`
314
+ // detection (nothing ever kills/reclaims it) — release it here rather
315
+ // than leaving the batch permanently down one slot of capacity.
316
+ journalEvent(deps, 'unit-failed', unit(batchId), {
317
+ reason: 'no-worktree',
318
+ detail: 'spawnMember: batch has no worktree — batch-setup has not landed',
319
+ });
320
+ return releaseSlot(state, batchId, now);
321
+ }
322
+ const memberIssue = batch.members[batch.executing_member - 1];
323
+ if (memberIssue === undefined) {
324
+ journalEvent(deps, 'unit-failed', unit(batchId), {
325
+ reason: 'no-member',
326
+ detail: `spawnMember: executing_member ${batch.executing_member} has no member issue`,
327
+ });
328
+ return releaseSlot(state, batchId, now);
329
+ }
330
+ const withStatus = advanceMemberToInWork(state, memberIssue, now);
331
+ const tier = (0, state_1.findEntry)(withStatus, memberIssue)?.tier ?? 'mid';
332
+ const cmd = (0, dispatch_1.buildAgentCommand)(dispatch.command, tier, memberIssue, dispatch.tierModels);
333
+ const prompt = (0, dispatch_1.buildMemberPrompt)(dispatch.memberPrompt, memberIssue, batchId, batch.worktree);
334
+ const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-m${batch.executing_member}-${memberIssue}.log`);
335
+ let pid;
336
+ try {
337
+ pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
338
+ }
339
+ catch (err) {
340
+ journalEvent(deps, 'unit-failed', unit(batchId), {
341
+ issue: memberIssue,
342
+ reason: 'spawn-error',
343
+ detail: `member #${memberIssue} spawn failed: ${err.message}`,
344
+ });
345
+ result.failed.push(unit(batchId));
346
+ return releaseSlot(withStatus, batchId, now);
347
+ }
348
+ const patch = {
349
+ pid,
350
+ pid_start: deps.spawnDeps.processStart(pid),
351
+ phase: 'member',
352
+ last_progress_at: now.toISOString(),
353
+ };
354
+ const next = slot.status === 'assigned' || slot.status === 'recovering'
355
+ ? (0, state_1.transitionSlot)(withStatus, slot.id, 'running', patch, now)
356
+ : withStatus;
357
+ deps.journal.append((0, journal_1.unitEvent)('spawned', unit(batchId), {
358
+ pid,
359
+ tier,
360
+ slot: slot.id,
361
+ issue: memberIssue,
362
+ detail: `member ${batch.executing_member}/${batch.members.length}`,
363
+ }), now);
364
+ result.spawned.push(unit(batchId));
365
+ return next;
366
+ }
367
+ /**
368
+ * First claim of a `ready` batch: assign it a slot, run batch-setup (real
369
+ * network round trips — `ai-dossier runstate mint`, `git fetch`/`push`), land
370
+ * the results, then spawn member 1 in the SAME slot rather than releasing and
371
+ * re-claiming: setup already holds the slot for its own duration, and
372
+ * splitting it into two capacity-gated claims would only add a second gate
373
+ * for no benefit — the slot is going to member 1 immediately either way.
374
+ */
375
+ function claimAndSetup(deps, config, dispatch, batchId, now, result) {
376
+ const claimedSlot = deps.store.withLock((state) => {
377
+ const batch = (0, state_1.findBatch)(state, batchId);
378
+ if (!batch || batch.status !== 'ready' || batch.anchor === null || slotFor(state, batchId)) {
379
+ return { state, result: null };
380
+ }
381
+ if ((0, scheduler_1.freeCapacity)(state, config) === 0)
382
+ return { state, result: null };
383
+ const assigned = (0, scheduler_1.assignToIdleSlot)(state, unit(batchId), 'batch-setup', now);
384
+ return { state: assigned.state, result: assigned.slotId };
385
+ });
386
+ if (claimedSlot === null)
387
+ return;
388
+ const state = deps.store.load();
389
+ const batch = (0, state_1.findBatch)(state, batchId);
390
+ if (!batch)
391
+ return;
392
+ const setup = runBatchSetup(deps, batch, now);
393
+ const poster = (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir });
394
+ if (!setup.ok) {
395
+ // `ai-dossier runstate post` REQUIRES a run id (types.ts's `BatchEntry.run_id`
396
+ // doc) — posting with an empty string silently fails the CLI call. Only post
397
+ // when the mint step actually landed one (`setup.runId`, when a LATER step
398
+ // failed) or the batch already carries one from an earlier attempt.
399
+ const runId = setup.runId ?? batch.run_id;
400
+ if (batch.anchor !== null && runId !== null) {
401
+ poster(batch.anchor, runId, {
402
+ phase: 'batch-setup',
403
+ status: 'blocked',
404
+ kv: { reason: setup.reason },
405
+ });
406
+ }
407
+ else {
408
+ journalEvent(deps, 'milestone-post-failed', unit(batchId), {
409
+ detail: `batch-setup blocked (${setup.reason}) — no run id to post to yet`,
410
+ });
411
+ }
412
+ deps.journal.append((0, journal_1.unitEvent)('batch-setup-failed', unit(batchId), { detail: setup.reason }), now);
413
+ result.failed.push(unit(batchId));
414
+ deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
415
+ return;
416
+ }
417
+ poster(batch.anchor, setup.runId, {
418
+ phase: 'batch-setup',
419
+ status: 'done',
420
+ kv: { branch: setup.branch, worktree: setup.worktree, base_branch: batch.base_branch },
421
+ });
422
+ deps.journal.append((0, journal_1.unitEvent)('batch-setup-done', unit(batchId), { detail: setup.worktree }), now);
423
+ deps.store.withLock((s) => {
424
+ const b = (0, state_1.findBatch)(s, batchId);
425
+ if (!b || b.status !== 'ready') {
426
+ // The batch moved (dissolved/abandoned) between the claim and here — a
427
+ // real worktree now exists that nothing else knows about, and the slot
428
+ // this claim took is still `assigned` with no agent in it. Release the
429
+ // slot rather than leaking capacity; the worktree is orphaned (named in
430
+ // the journal for manual cleanup — it is not this rare-race path's job
431
+ // to guess whether reusing or removing it is safe).
432
+ journalEvent(deps, 'unit-failed', unit(batchId), {
433
+ reason: 'batch-left-ready-during-setup',
434
+ detail: `worktree ${setup.worktree} created but batch is now '${b?.status ?? 'gone'}' — orphaned, manual cleanup required`,
435
+ });
436
+ return { state: releaseSlot(s, batchId, now), result: undefined };
437
+ }
438
+ let next = (0, state_1.patchBatch)(s, batchId, { branch: setup.branch, worktree: setup.worktree, run_id: setup.runId }, now);
439
+ next = (0, state_1.transitionBatch)(next, batchId, 'executing', { executing_member: 1 }, now);
440
+ const slot = slotFor(next, batchId);
441
+ if (slot)
442
+ next = spawnMember(deps, dispatch, next, slot, batchId, now, result);
443
+ return { state: next, result: undefined };
444
+ });
445
+ }
446
+ // --- Continuation: claim a fresh slot for the next live step ---
447
+ /**
448
+ * Claim a fresh idle slot for the batch's next live step (a later member, the
449
+ * tail agent, the report agent), gated on free capacity exactly like
450
+ * `dispatchReportAgents` — never through `computeAssignments`/`runnableUnits`
451
+ * again (those only ever offer a `status === 'ready'` batch).
452
+ */
453
+ function claimAndSpawn(deps, config, batchId, phase, now, spawn) {
454
+ return deps.store.withLock((state) => {
455
+ const batch = (0, state_1.findBatch)(state, batchId);
456
+ if (!batch || slotFor(state, batchId))
457
+ return { state, result: false };
458
+ if ((0, scheduler_1.freeCapacity)(state, config) === 0)
459
+ return { state, result: false };
460
+ const assigned = (0, scheduler_1.assignToIdleSlot)(state, unit(batchId), phase, now, 'cycle');
461
+ const slot = assigned.state.slots.find((s) => s.id === assigned.slotId);
462
+ if (!slot)
463
+ return { state, result: false };
464
+ return { state: spawn(assigned.state, slot), result: true };
465
+ });
466
+ }
467
+ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
468
+ claimAndSpawn(deps, config, batchId, 'member', now, (state, slot) => spawnMember(deps, dispatch, state, slot, batchId, now, result));
469
+ }
470
+ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
471
+ claimAndSpawn(deps, config, batchId, 'reviewing', now, (state, slot) => {
472
+ const batch = (0, state_1.findBatch)(state, batchId);
473
+ if (!batch || batch.worktree === null || batch.anchor === null) {
474
+ journalEvent(deps, 'unit-failed', unit(batchId), {
475
+ reason: 'no-worktree-or-anchor',
476
+ detail: 'spawnTailAgent: batch has no worktree/anchor',
477
+ });
478
+ return releaseSlot(state, batchId, now);
479
+ }
480
+ const cmd = (0, dispatch_1.buildAgentCommand)(dispatch.command, 'strong', batch.anchor, dispatch.tierModels);
481
+ const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, batch.members, batch.worktree);
482
+ const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-tail.log`);
483
+ let pid;
484
+ try {
485
+ pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
486
+ }
487
+ catch (err) {
488
+ journalEvent(deps, 'unit-failed', unit(batchId), {
489
+ reason: 'spawn-error',
490
+ detail: `tail agent spawn failed: ${err.message}`,
491
+ });
492
+ result.failed.push(unit(batchId));
493
+ return releaseSlot(state, batchId, now);
494
+ }
495
+ const patch = {
496
+ pid,
497
+ pid_start: deps.spawnDeps.processStart(pid),
498
+ phase: 'reviewing',
499
+ last_progress_at: now.toISOString(),
500
+ };
501
+ const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(state, slot.id, 'running', patch, now) : state;
502
+ deps.journal.append((0, journal_1.unitEvent)('spawned', unit(batchId), { pid, slot: slot.id }), now);
503
+ result.spawned.push(unit(batchId));
504
+ return next;
505
+ });
506
+ }
507
+ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
508
+ claimAndSpawn(deps, config, batchId, 'report', now, (state, slot) => {
509
+ const batch = (0, state_1.findBatch)(state, batchId);
510
+ if (!batch || batch.anchor === null) {
511
+ journalEvent(deps, 'report-failed', unit(batchId), {
512
+ detail: 'spawnReportAgent: batch has no anchor',
513
+ });
514
+ return releaseSlot(state, batchId, now);
515
+ }
516
+ const prNumber = batch.pr;
517
+ if (prNumber === null) {
518
+ journalEvent(deps, 'report-failed', unit(batchId), {
519
+ detail: 'spawnReportAgent: batch has no parked pr recorded',
520
+ });
521
+ return releaseSlot(state, batchId, now);
522
+ }
523
+ const cmd = (0, dispatch_1.buildAgentCommand)(dispatch.command, 'mechanical', batch.anchor, dispatch.tierModels);
524
+ const prompt = (0, dispatch_1.buildBatchReportPrompt)(dispatch.batchReportPrompt, batchId, batch.anchor, prNumber);
525
+ const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-report.log`);
526
+ let pid;
527
+ try {
528
+ pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
529
+ }
530
+ catch (err) {
531
+ deps.journal.append((0, journal_1.unitEvent)('report-failed', unit(batchId), { detail: err.message }), now);
532
+ return releaseSlot(state, batchId, now);
533
+ }
534
+ const patchState = {
535
+ pid,
536
+ pid_start: deps.spawnDeps.processStart(pid),
537
+ phase: 'report',
538
+ last_progress_at: now.toISOString(),
539
+ };
540
+ const next = slot.status === 'assigned'
541
+ ? (0, state_1.transitionSlot)(state, slot.id, 'running', patchState, now)
542
+ : state;
543
+ journalEvent(deps, 'report-dispatched', unit(batchId), { pid, slot: slot.id, pr: prNumber });
544
+ result.spawned.push(unit(batchId));
545
+ return next;
546
+ });
547
+ }
548
+ // --- Aggregate validate + attribution/fix/evict (RFC F.2) ---
549
+ function memberFootprints(deps, batch) {
550
+ if (batch.worktree === null)
551
+ return [];
552
+ return batch.ranges.map((range) => {
553
+ // `range.commits` is persisted state — validate as shas before they
554
+ // become git argv (CWE-88), the same discipline `recovery.ts`'s revert
555
+ // path applies to the identical values (attribution.ts's `SHA_RE` doc).
556
+ const commits = range.commits.filter((c) => attribution_1.SHA_RE.test(c));
557
+ if (commits.length === 0)
558
+ return { issue: range.issue, changedPaths: [], focusedTests: [] };
559
+ const out = deps.exec('git', ['show', '--name-only', '--format=', ...commits], batch.worktree);
560
+ const changedPaths = (out ?? '')
561
+ .split('\n')
562
+ .map((l) => l.trim())
563
+ .filter((l) => l.length > 0);
564
+ return { issue: range.issue, changedPaths, focusedTests: [] };
565
+ });
566
+ }
567
+ function boundaryCommits(deps, batch) {
568
+ if (batch.worktree === null || batch.branch === null)
569
+ return [];
570
+ const out = deps.exec('git', ['log', '--reverse', '--format=%H%x09%s', `origin/${batch.base_branch}..${batch.branch}`], batch.worktree);
571
+ return (0, attribution_1.parseBoundaryCommits)(out);
572
+ }
573
+ /**
574
+ * `validating`, no live slot: run the aggregate suite (deterministic — no
575
+ * agent, no slot claimed, matching AC5's "member or batch-LLM-step" wording).
576
+ * Green proceeds to the tail; red attributes and either fixes one offender or
577
+ * dissolves when nothing could be attributed (RFC F.2/F.8).
578
+ */
579
+ function runValidate(deps, config, dispatch, batchId, now, result) {
580
+ const state = deps.store.load();
581
+ const batch = (0, state_1.findBatch)(state, batchId);
582
+ if (!batch || batch.worktree === null)
583
+ return;
584
+ const suite = safeSuite(deps, batchId, batch.worktree);
585
+ const rDeps = recoveryDeps(deps, batch, now);
586
+ const poster = (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir });
587
+ if (suite.ok) {
588
+ if (batch.anchor !== null && batch.run_id !== null) {
589
+ poster(batch.anchor, batch.run_id, { phase: 'batch-validate', status: 'done', kv: {} });
590
+ }
591
+ deps.journal.append((0, journal_1.unitEvent)('verify-complete', unit(batchId), { detail: 'suite green' }), now);
592
+ deps.store.withLock((s) => {
593
+ const b = (0, state_1.findBatch)(s, batchId);
594
+ if (!b || b.status !== 'validating')
595
+ return { state: s, result: undefined };
596
+ return { state: (0, state_1.transitionBatch)(s, batchId, 'reviewing', {}, now), result: undefined };
597
+ });
598
+ spawnTailAgent(deps, config, dispatch, batchId, now, result);
599
+ return;
600
+ }
601
+ const { state: attributed, outcome } = (0, recovery_1.beginAttribution)(state, batchId, { failing: suite.failing, footprints: memberFootprints(deps, batch) }, rDeps);
602
+ if (outcome.offenders.length === 0) {
603
+ const dissolve = (0, recovery_1.dissolveBatch)(attributed, batchId, { strategy: 'full', reason: 'unattributable-suite-failure' }, rDeps);
604
+ deps.store.withLock((s) => ({
605
+ state: applyBatchAndIssues(s, dissolve.state, batchId, dissolve.requeued),
606
+ result: undefined,
607
+ }));
608
+ teardownBatch(deps, batchId);
609
+ result.blocked.push(...dissolve.requeued);
610
+ result.failed.push(unit(batchId));
611
+ return;
612
+ }
613
+ const offender = outcome.offenders[0];
614
+ const { state: fixing, dispatch: fixDispatch } = (0, recovery_1.beginFixAttempt)(attributed, batchId, offender, rDeps, { config, tests: outcome.attributed.get(offender) ?? [] });
615
+ deps.store.withLock((s) => ({
616
+ state: applyBatchAndIssues(s, fixing, batchId, []),
617
+ result: undefined,
618
+ }));
619
+ if (fixDispatch === null) {
620
+ // Already had its one attempt — evict directly (mirrors the module's own
621
+ // documented next step when `beginFixAttempt` refuses).
622
+ evictOffender(deps, config, batchId, offender, outcome.method, now, result);
623
+ return;
624
+ }
625
+ claimAndSpawn(deps, config, batchId, 'fixing', now, (s, slot) => {
626
+ const logFile = path.join(deps.store.runsDir, `${(0, dispatch_1.unitLogName)(unit(batchId))}-fix-${offender}.log`);
627
+ let pid;
628
+ try {
629
+ pid = deps.spawnDeps.spawn(fixDispatch.command, fixDispatch.prompt, logFile);
630
+ }
631
+ catch (err) {
632
+ journalEvent(deps, 'unit-failed', unit(batchId), {
633
+ issue: offender,
634
+ reason: 'fix-spawn-error',
635
+ detail: `fix agent spawn failed: ${err.message}`,
636
+ });
637
+ // The fix attempt was already recorded `dispatched` by `beginFixAttempt`
638
+ // — a spawn failure never dispatched anything, so resolve it `red`
639
+ // (pure, no I/O — safe inside this lock) rather than leaving the state
640
+ // claiming an attempt is in flight forever.
641
+ const resolved = (0, recovery_1.resolveFixAttempt)(s, batchId, offender, 'red', rDeps).state;
642
+ return releaseSlot(resolved, batchId, now);
643
+ }
644
+ const patch = {
645
+ pid,
646
+ pid_start: deps.spawnDeps.processStart(pid),
647
+ phase: 'fixing',
648
+ last_progress_at: now.toISOString(),
649
+ };
650
+ const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(s, slot.id, 'running', patch, now) : s;
651
+ result.spawned.push(unit(batchId));
652
+ return next;
653
+ });
654
+ }
655
+ function evictOffender(deps, _config, batchId, offender, attribution, now, result) {
656
+ const state = deps.store.load();
657
+ const batch = (0, state_1.findBatch)(state, batchId);
658
+ if (!batch)
659
+ return;
660
+ const rDeps = recoveryDeps(deps, batch, now);
661
+ const outcome = (0, recovery_1.evictMembers)(state, batchId, { issues: [offender], reason: 'suite-red-after-fix', attribution, ranges: batch.ranges }, rDeps);
662
+ deps.store.withLock((s) => ({
663
+ state: applyBatchAndIssues(s, outcome.state, batchId, outcome.requeued),
664
+ result: undefined,
665
+ }));
666
+ if (outcome.dissolved) {
667
+ result.failed.push(unit(batchId));
668
+ return;
669
+ }
670
+ if (outcome.suite?.ok) {
671
+ deps.store.withLock((s) => {
672
+ const b = (0, state_1.findBatch)(s, batchId);
673
+ if (!b || b.status !== 'validating')
674
+ return { state: s, result: undefined };
675
+ return { state: (0, state_1.transitionBatch)(s, batchId, 'reviewing', {}, now), result: undefined };
676
+ });
677
+ }
678
+ }
679
+ // --- Reconcile a batch currently holding a live/exited slot ---
680
+ /**
681
+ * After a member leaves `executing` (green, gate-failed, or self-blocked):
682
+ * advance to the next member, or — on the last member — transition to
683
+ * `validating` and run the aggregate suite. Shared by every exit from
684
+ * `reconcileMemberSlot` so the pointer-advance rail exists once.
685
+ */
686
+ function advanceMemberOrValidate(deps, config, dispatch, batchId, memberCount, currentMember, memberIssue, now, result) {
687
+ const isLast = currentMember >= memberCount;
688
+ if (isLast) {
689
+ deps.store.withLock((s) => {
690
+ const b = (0, state_1.findBatch)(s, batchId);
691
+ if (!b || b.status !== 'executing')
692
+ return { state: s, result: undefined };
693
+ return { state: (0, state_1.transitionBatch)(s, batchId, 'validating', {}, now), result: undefined };
694
+ });
695
+ runValidate(deps, config, dispatch, batchId, now, result);
696
+ return;
697
+ }
698
+ deps.store.withLock((s) => {
699
+ const b = (0, state_1.findBatch)(s, batchId);
700
+ if (!b || b.status !== 'executing')
701
+ return { state: s, result: undefined };
702
+ return {
703
+ state: (0, state_1.transitionBatch)(s, batchId, 'executing', { executing_member: b.executing_member + 1 }, now),
704
+ result: undefined,
705
+ };
706
+ });
707
+ journalEvent(deps, 'member-advanced', unit(batchId), { issue: memberIssue });
708
+ spawnMemberContinuation(deps, config, dispatch, batchId, now, result);
709
+ }
710
+ /**
711
+ * Evict the current member and either dissolve, or continue the batch via
712
+ * `advanceMemberOrValidate` — the shared tail of both member-failure rails
713
+ * (self-reported blocked, and the incremental gate below).
714
+ */
715
+ function evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIssue, reason, now, result) {
716
+ const dissolved = evictMemberDirectly(deps, batchId, memberIssue, reason, now);
717
+ if (dissolved) {
718
+ result.failed.push(unit(batchId));
719
+ return;
720
+ }
721
+ advanceMemberOrValidate(deps, config, dispatch, batchId, batch.members.length, batch.executing_member, memberIssue, now, result);
722
+ }
723
+ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result) {
724
+ const state0 = deps.store.load();
725
+ const batch = (0, state_1.findBatch)(state0, batchId);
726
+ if (!batch)
727
+ return;
728
+ const memberIssue = batch.members[batch.executing_member - 1];
729
+ if (memberIssue === undefined)
730
+ return;
731
+ const dead = slot.pid !== null && !deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined);
732
+ const milestone = deps.groundTruth.latestMilestone(memberIssue);
733
+ if (milestone === undefined)
734
+ return; // unreachable — pause this batch's decisions
735
+ if ((0, groundtruth_1.isMemberComplete)(milestone)) {
736
+ deps.journal.append((0, journal_1.unitEvent)('external-advance', unit(batchId), {
737
+ issue: memberIssue,
738
+ detail: 'member review done',
739
+ }), now);
740
+ // Incremental gate (#523 AC2): typecheck + focused tests via `cap run`,
741
+ // when the repo has a manifest for them — a second, independent check
742
+ // that the member's own self-reported "done" is real, matching this
743
+ // codebase's "never trust a claimed completion" ethos (AC2/#464's
744
+ // `isVerifiedComplete`). `task-failed` evicts the member directly, same
745
+ // rail as a self-reported block (RFC F.1) — no aggregate suite has run
746
+ // yet, so there is nothing to attribute.
747
+ if (batch.worktree !== null && deps.runCapability) {
748
+ const gateFailure = ['typecheck.run', 'test.focused']
749
+ .map((id) => ({ id, outcome: deps.runCapability?.(batch.worktree, id) }))
750
+ .find((r) => r.outcome === 'task-failed');
751
+ if (gateFailure) {
752
+ const reason = `incremental-gate-failed:${gateFailure.id}`;
753
+ journalEvent(deps, 'unit-failed', unit(batchId), {
754
+ issue: memberIssue,
755
+ reason,
756
+ detail: `cap run ${gateFailure.id} reported task-failed after member review done`,
757
+ });
758
+ deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
759
+ evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIssue, reason, now, result);
760
+ return;
761
+ }
762
+ // `ok` / `automation-broken` / `capability-unavailable` all proceed —
763
+ // only a definite task failure blocks a member here.
764
+ }
765
+ // The commit-range recompute (`git log`) is a blocking subprocess call —
766
+ // it must run OUTSIDE the lock, like every other exec in this module;
767
+ // the result then lands as a pure data patch under the lock (Convention
768
+ // review: `recordRanges` used to run `git log` INSIDE the withLock
769
+ // mutator, which is exactly what `engine.ts`'s own "a slow git call never
770
+ // holds the lock" invariant exists to prevent).
771
+ const ranges = (0, attribution_1.memberRanges)(boundaryCommits(deps, batch));
772
+ deps.store.withLock((s) => {
773
+ let n = releaseSlot(s, batchId, now);
774
+ n = (0, state_1.patchBatch)(n, batchId, { ranges }, now);
775
+ n = advanceMemberToValidated(n, memberIssue, now);
776
+ return { state: n, result: undefined };
777
+ });
778
+ result.completed.push(unit(batchId));
779
+ advanceMemberOrValidate(deps, config, dispatch, batchId, batch.members.length, batch.executing_member, memberIssue, now, result);
780
+ return;
781
+ }
782
+ if ((0, groundtruth_1.isMemberBlocked)(milestone) || dead) {
783
+ const rawReason = milestone?.keys.reason;
784
+ const reason = typeof rawReason === 'string' && rawReason.length > 0
785
+ ? sanitizeUntrustedText(rawReason)
786
+ : dead
787
+ ? 'agent-exited-unverified'
788
+ : 'member-blocked';
789
+ journalEvent(deps, 'unit-failed', unit(batchId), {
790
+ issue: memberIssue,
791
+ reason,
792
+ detail: 'member blocked',
793
+ });
794
+ deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
795
+ // A member that never went green (RFC F.1) evicts DIRECTLY — no aggregate
796
+ // suite has run yet, so there is nothing for `attributing`/`evicting` (the
797
+ // AGGREGATE-suite-red pipeline, RFC F.2) to attribute or revert: the
798
+ // offender is already known, and `batch.ranges` has no entry for a member
799
+ // that never reached `isMemberComplete`. `executing → validating →
800
+ // attributing → evicting` is not even a legal edge from mid-`executing`
801
+ // (BATCH_TRANSITIONS has no `executing → evicting`) — this stays entirely
802
+ // within `executing`/`dissolving`, both of which ARE legal from here.
803
+ evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIssue, reason, now, result);
804
+ }
805
+ }
806
+ /**
807
+ * Requeue a member full-cycle, record the eviction, and dissolve if this tips
808
+ * the batch past the ⅓ threshold (RFC F.1/F.8) — WITHOUT going through
809
+ * `recovery.ts`'s `evictMembers` (which needs `attributing`/`evicting` status
810
+ * and a commit range to revert; a member evicted here has neither). Returns
811
+ * whether the batch dissolved.
812
+ */
813
+ function evictMemberDirectly(deps, batchId, memberIssue, reason, now) {
814
+ // Pass 1 (pure — requeue + record the eviction): safe to run entirely
815
+ // inside the lock, unlike `dissolveBatch` below, which shells out
816
+ // (`deps.exec`/`postMilestone`) and so must NOT hold the lock while it runs.
817
+ const triggered = deps.store.withLock((s) => {
818
+ const b = (0, state_1.findBatch)(s, batchId);
819
+ if (!b)
820
+ return { state: s, result: false };
821
+ const evidence = {
822
+ batch: batchId,
823
+ reason,
824
+ failing_tests: [],
825
+ attribution: 'none',
826
+ reverted_commits: [],
827
+ at: now.toISOString(),
828
+ };
829
+ const requeueResult = (0, state_1.requeueMember)(s, memberIssue, { mode: 'full', batch: null }, reason, now, { failure_evidence: evidence });
830
+ let next = requeueResult.state;
831
+ next = (0, state_1.patchBatch)(next, batchId, {
832
+ evictions: [
833
+ ...b.evictions,
834
+ {
835
+ issue: memberIssue,
836
+ reason,
837
+ attribution: 'none',
838
+ reverted_commits: [],
839
+ group: [],
840
+ at: now.toISOString(),
841
+ },
842
+ ],
843
+ }, now);
844
+ const updated = (0, state_1.findBatch)(next, batchId);
845
+ return { state: next, result: updated !== undefined && (0, recovery_1.checkDissolveTrigger)(updated) };
846
+ });
847
+ if (!triggered)
848
+ return false;
849
+ // Pass 2 (outside the lock — dissolveBatch shells out): re-load fresh
850
+ // (pass 1's write already landed), dissolve, then re-apply just this
851
+ // batch's + the requeued members' state under a fresh lock.
852
+ const state = deps.store.load();
853
+ const batch = (0, state_1.findBatch)(state, batchId);
854
+ if (!batch)
855
+ return false;
856
+ const rDeps = recoveryDeps(deps, batch, now);
857
+ const outcome = (0, recovery_1.dissolveBatch)(state, batchId, { strategy: 'full', reason: 'eviction-threshold' }, rDeps);
858
+ deps.store.withLock((s) => ({
859
+ state: applyBatchAndIssues(s, outcome.state, batchId, outcome.requeued),
860
+ result: undefined,
861
+ }));
862
+ teardownBatch(deps, batchId);
863
+ return true;
864
+ }
865
+ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
866
+ if (slot.pid !== null && deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined))
867
+ return; // still running
868
+ const state = deps.store.load();
869
+ const batch = (0, state_1.findBatch)(state, batchId);
870
+ if (!batch || batch.worktree === null)
871
+ return;
872
+ const offenderRecord = [...batch.fix_attempts].reverse().find((a) => a.outcome === 'dispatched');
873
+ if (!offenderRecord)
874
+ return;
875
+ deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
876
+ const suite = safeSuite(deps, batchId, batch.worktree);
877
+ const rDeps = recoveryDeps(deps, batch, now);
878
+ const { state: resolved } = (0, recovery_1.resolveFixAttempt)(deps.store.load(), batchId, offenderRecord.issue, suite.ok ? 'green' : 'red', rDeps);
879
+ deps.store.withLock((s) => ({
880
+ state: applyBatchAndIssues(s, resolved, batchId, []),
881
+ result: undefined,
882
+ }));
883
+ if (suite.ok) {
884
+ deps.store.withLock((s) => {
885
+ const b = (0, state_1.findBatch)(s, batchId);
886
+ if (!b || b.status !== 'validating')
887
+ return { state: s, result: undefined };
888
+ return { state: (0, state_1.transitionBatch)(s, batchId, 'reviewing', {}, now), result: undefined };
889
+ });
890
+ return;
891
+ }
892
+ evictOffender(deps, config, batchId, offenderRecord.issue, 'overlap', now, result);
893
+ }
894
+ function reconcileTailSlot(deps, batchId, slot, now, result) {
895
+ const state = deps.store.load();
896
+ const batch = (0, state_1.findBatch)(state, batchId);
897
+ if (!batch || batch.anchor === null)
898
+ return;
899
+ const dead = slot.pid !== null && !deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined);
900
+ const milestone = deps.groundTruth.latestMilestone(batch.anchor);
901
+ if (milestone === undefined)
902
+ return;
903
+ if (batch.status === 'reviewing' && (0, groundtruth_1.isBatchPhaseDone)(milestone, 'batch-review')) {
904
+ deps.store.withLock((s) => {
905
+ const b = (0, state_1.findBatch)(s, batchId);
906
+ if (!b || b.status !== 'reviewing')
907
+ return { state: s, result: undefined };
908
+ return { state: (0, state_1.transitionBatch)(s, batchId, 'shipping', {}, now), result: undefined };
909
+ });
910
+ return;
911
+ }
912
+ if ((0, groundtruth_1.isBatchTailParked)(milestone)) {
913
+ const pr = (0, groundtruth_1.prOfMilestone)(milestone);
914
+ journalEvent(deps, 'pr-parked', unit(batchId), { pr: pr ?? undefined });
915
+ deps.store.withLock((s) => {
916
+ let n = releaseSlot(s, batchId, now);
917
+ const b = (0, state_1.findBatch)(n, batchId);
918
+ if (!b)
919
+ return { state: n, result: undefined };
920
+ n = b.status === 'reviewing' ? (0, state_1.transitionBatch)(n, batchId, 'shipping', {}, now) : n;
921
+ n = (0, state_1.transitionBatch)(n, batchId, 'awaiting-merge', { pr }, now);
922
+ return { state: n, result: undefined };
923
+ });
924
+ result.parked.push(unit(batchId));
925
+ return;
926
+ }
927
+ if (dead) {
928
+ deps.journal.append((0, journal_1.unitEvent)('unit-failed', unit(batchId), { reason: 'tail-agent-exited-unverified' }), now);
929
+ deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
930
+ result.failed.push(unit(batchId));
931
+ }
932
+ }
933
+ function reconcileReportSlot(deps, batchId, slot, now, result) {
934
+ const state = deps.store.load();
935
+ const batch = (0, state_1.findBatch)(state, batchId);
936
+ if (!batch || batch.anchor === null)
937
+ return;
938
+ const dead = slot.pid !== null && !deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined);
939
+ const milestone = deps.groundTruth.latestMilestone(batch.anchor);
940
+ if (milestone === undefined)
941
+ return;
942
+ if ((0, groundtruth_1.isBatchPhaseDone)(milestone, 'batch-report')) {
943
+ deps.journal.append((0, journal_1.unitEvent)('external-advance', unit(batchId), { detail: 'batch report done' }), now);
944
+ deps.store.withLock((s) => {
945
+ let n = releaseSlot(s, batchId, now);
946
+ const b = (0, state_1.findBatch)(n, batchId);
947
+ if (!b || b.status !== 'deployed')
948
+ return { state: n, result: undefined };
949
+ n = (0, state_1.transitionBatch)(n, batchId, 'reported', {}, now);
950
+ n = (0, state_1.transitionBatch)(n, batchId, 'done', {}, now);
951
+ return { state: n, result: undefined };
952
+ });
953
+ result.completed.push(unit(batchId));
954
+ teardownBatch(deps, batchId);
955
+ return;
956
+ }
957
+ if (dead) {
958
+ deps.journal.append((0, journal_1.unitEvent)('report-failed', unit(batchId), { detail: 'unverified exit' }), now);
959
+ deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
960
+ }
961
+ }
962
+ // --- PR watch for `awaiting-merge` batches (mirrors `pollParkedPrs`/`reconcileParked`) ---
963
+ function reconcilePrWatch(deps, now, result) {
964
+ const state = deps.store.load();
965
+ for (const batch of state.batches) {
966
+ if (batch.status !== 'awaiting-merge')
967
+ continue;
968
+ const pr = batch.pr;
969
+ if (pr === null)
970
+ continue;
971
+ const truth = deps.groundTruth.prState(pr);
972
+ if (truth === undefined)
973
+ continue; // unreachable — keep watching
974
+ if (truth.state === 'MERGED' && truth.mergedAt !== null) {
975
+ journalEvent(deps, 'merge-accepted', unit(batch.id), { pr });
976
+ deps.store.withLock((s) => {
977
+ const b = (0, state_1.findBatch)(s, batch.id);
978
+ if (!b || b.status !== 'awaiting-merge')
979
+ return { state: s, result: undefined };
980
+ let n = (0, state_1.transitionBatch)(s, batch.id, 'merged', {}, now);
981
+ n = (0, state_1.transitionBatch)(n, batch.id, 'deployed', {}, now);
982
+ for (const issue of b.members) {
983
+ const entry = (0, state_1.findEntry)(n, issue);
984
+ if (entry && entry.status !== 'shipped-in-batch') {
985
+ try {
986
+ n = (0, state_1.transitionIssue)(n, issue, 'shipped-in-batch', {}, now);
987
+ n = (0, state_1.transitionIssue)(n, issue, 'done', {}, now);
988
+ }
989
+ catch {
990
+ // Already terminal via another rail — leave it.
991
+ }
992
+ }
993
+ }
994
+ return { state: n, result: undefined };
995
+ });
996
+ result.mergeAccepted.push(unit(batch.id));
997
+ continue;
998
+ }
999
+ if (truth.blocked || truth.mergeable === 'CONFLICTING') {
1000
+ journalEvent(deps, 'pr-watch-failed', unit(batch.id), {
1001
+ reason: truth.blocked ? 'auto-merge-blocked' : 'pr-conflicting',
1002
+ pr,
1003
+ });
1004
+ // #472's own rebase-and-reship path (RFC F.9) is a documented follow-up
1005
+ // for the batch PR-conflict rail; for now the batch stays parked and
1006
+ // the block is visible via the journal + `sched status`.
1007
+ }
1008
+ }
1009
+ }
1010
+ /**
1011
+ * Remove the batch's shared worktree — called both on the happy path
1012
+ * (`reconcileReportSlot`, after `batch-report done`) and on every dissolve
1013
+ * path (`runValidate`'s unattributable-suite dissolve, `evictMemberDirectly`'s
1014
+ * eviction-threshold dissolve): a dissolved batch's worktree is otherwise
1015
+ * left on disk forever, since nothing else ever calls this for it.
1016
+ */
1017
+ function teardownBatch(deps, batchId) {
1018
+ const state = deps.store.load();
1019
+ const batch = (0, state_1.findBatch)(state, batchId);
1020
+ if (!batch || batch.worktree === null)
1021
+ return;
1022
+ const root = deps.exec('git', ['rev-parse', '--show-toplevel'], deps.repoDir) ?? deps.repoDir;
1023
+ if (!(0, teardown_1.isSafeWorktree)(path.resolve(root), batch.worktree)) {
1024
+ journalEvent(deps, 'teardown-failed', unit(batchId), {
1025
+ reason: 'unsafe-worktree-path',
1026
+ detail: batch.worktree,
1027
+ });
1028
+ return;
1029
+ }
1030
+ const result = (0, teardown_1.runTeardown)(deps.exec, deps.repoDir, { worktree: batch.worktree, poolClaimed: false, branch: batch.branch }, deps.fsExists);
1031
+ journalEvent(deps, result.cleanup === 'done' ? 'teardown-done' : 'teardown-failed', unit(batchId), {
1032
+ cleanup: result.cleanup,
1033
+ detail: result.detail,
1034
+ worktree: batch.worktree,
1035
+ });
1036
+ }
1037
+ // --- Entry point ---
1038
+ /**
1039
+ * One batch reconcile+refill pass. Called from `engine.ts`'s `tick()` after
1040
+ * the issue-level pass — batches never compete with issues for a slot within
1041
+ * the same tick because this pass runs strictly after `dispatchAssignments`
1042
+ * already filled every slot it could (see the module doc: batch claims never
1043
+ * go through `computeAssignments`/`runnableUnits` at all). Loads and saves
1044
+ * state itself via `deps.store.withLock` — the caller holds no lock across
1045
+ * this call. `deps.exec` and `deps.runSuite` are mandatory; `deps.
1046
+ * runCapability` is independently optional (AC2's incremental gate is itself
1047
+ * a "when available" fast path).
1048
+ */
1049
+ function runBatchTick(deps, config, dispatch) {
1050
+ const result = emptyResult();
1051
+ const now = deps.now();
1052
+ for (const batch of deps.store.load().batches) {
1053
+ if (batch.status === 'ready' && slotFor(deps.store.load(), batch.id) === undefined) {
1054
+ claimAndSetup(deps, config, dispatch, batch.id, now, result);
1055
+ }
1056
+ }
1057
+ for (const batch of deps.store.load().batches) {
1058
+ const slot = slotFor(deps.store.load(), batch.id);
1059
+ if (slot && (slot.status === 'running' || slot.status === 'assigned')) {
1060
+ if (batch.status === 'executing') {
1061
+ reconcileMemberSlot(deps, config, dispatch, batch.id, slot, now, result);
1062
+ }
1063
+ else if (batch.status === 'fixing') {
1064
+ reconcileFixSlot(deps, config, batch.id, slot, now, result);
1065
+ }
1066
+ else if (batch.status === 'reviewing' || batch.status === 'shipping') {
1067
+ reconcileTailSlot(deps, batch.id, slot, now, result);
1068
+ }
1069
+ else if (batch.status === 'deployed') {
1070
+ reconcileReportSlot(deps, batch.id, slot, now, result);
1071
+ }
1072
+ continue;
1073
+ }
1074
+ if (slot)
1075
+ continue; // live but neither running/assigned (e.g. mid-verify) — next tick
1076
+ if (batch.status === 'validating') {
1077
+ runValidate(deps, config, dispatch, batch.id, now, result);
1078
+ }
1079
+ else if (batch.status === 'deployed') {
1080
+ spawnReportAgent(deps, config, dispatch, batch.id, now, result);
1081
+ }
1082
+ else if (batch.status === 'executing') {
1083
+ // A prior spawn threw, or `claimAndSpawn` found zero free capacity —
1084
+ // either way the batch is stuck mid-member with no slot and nothing
1085
+ // else will ever retry it (Conformance review AC5 caveat; Supportability
1086
+ // review #12). Retrying every tick is safe: `claimAndSpawn` itself is
1087
+ // the capacity gate, so this is a no-op until a slot actually frees up.
1088
+ spawnMemberContinuation(deps, config, dispatch, batch.id, now, result);
1089
+ }
1090
+ else if (batch.status === 'reviewing' || batch.status === 'shipping') {
1091
+ // Same wedge, for a dead-or-never-claimed tail agent.
1092
+ spawnTailAgent(deps, config, dispatch, batch.id, now, result);
1093
+ }
1094
+ else if (batch.status === 'fixing') {
1095
+ // `beginFixAttempt` already recorded this member's ONE attempt as
1096
+ // `dispatched` before `claimAndSpawn` could find capacity — retrying the
1097
+ // exact same dispatch isn't reconstructible from persisted state (only
1098
+ // the outcome is persisted, not the command/prompt), so resolve it
1099
+ // `red` (conservatively: the member loses its one attempt and evicts on
1100
+ // the next validate pass, which is safe — never a permanent wedge).
1101
+ const state = deps.store.load();
1102
+ const b = (0, state_1.findBatch)(state, batch.id);
1103
+ const offenderRecord = b
1104
+ ? [...b.fix_attempts].reverse().find((a) => a.outcome === 'dispatched')
1105
+ : undefined;
1106
+ if (b && offenderRecord) {
1107
+ const rDeps = recoveryDeps(deps, b, now);
1108
+ const { state: resolved } = (0, recovery_1.resolveFixAttempt)(state, batch.id, offenderRecord.issue, 'red', rDeps);
1109
+ deps.store.withLock((s) => ({
1110
+ state: applyBatchAndIssues(s, resolved, batch.id, []),
1111
+ result: undefined,
1112
+ }));
1113
+ }
1114
+ }
1115
+ }
1116
+ reconcilePrWatch(deps, now, result);
1117
+ return result;
1118
+ }
1119
+ //# sourceMappingURL=batch-dispatch.js.map