@ai-dossier/sched 0.50.0 → 0.51.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
@@ -31,7 +31,7 @@ ai-dossier sched start --once # a single reconcile+refill tick (cron-style)
31
31
  ai-dossier sched status # queue (+pr/cleanup), engine lease, slots, batches, blocked/failed; --anchors adds the ledger anchor sweep (#768) plus orphaned anchors no longer in state.batches (#790)
32
32
  ai-dossier sched pause # prevent every new agent process; live units keep running
33
33
  ai-dossier sched resume
34
- ai-dossier sched stop --issue 42 # terminate one full-cycle agent and record it stopped (no recovery)
34
+ ai-dossier sched stop --issue 42 # terminate one full-cycle agent (or stop a parked batch member, #832) and record it stopped (no recovery)
35
35
  ai-dossier sched stop --batch b1 # terminate every batch agent (incl. parallel members) and stop unfinished members
36
36
  ai-dossier sched abandon --issue 42 --reason "operator abort"
37
37
  ai-dossier sched abandon --batch b1 # dissolve; every unshipped member — parked and validated ones included — requeues as full-cycle
@@ -53,8 +53,10 @@ directory directly, reconstructing costs from the raw dispatch logs rather than
53
53
  `pause` prevents every new agent process, including escalation and same-tier recovery
54
54
  takeovers; it does not terminate agents that are already running. `stop --issue <n>` is the
55
55
  single-command stop path: it PID-start-safely terminates that issue's live agent, releases its
56
- slot, and records a terminal `stopped` outcome that will not recover or escalate. An active
57
- slot-mode member must instead be stopped through `stop --batch <id>`, which terminates every agent
56
+ slot, and records a terminal `stopped` outcome that will not recover or escalate. A slot-mode
57
+ member still in a live batch (pending, in work, or validated) must instead be stopped through
58
+ `stop --batch <id>` — a parked (`handed-back`/`evicted`) member can be stopped on its own (#832) —
59
+ which terminates every agent
58
60
  the batch holds (its own slot plus one per running parallel member, `batch:<id>#<issue>`) and atomically stops the batch and its unfinished members. `abandon` only records failure
59
61
  and releases the slot; it intentionally does not terminate its process.
60
62
 
@@ -247,7 +249,9 @@ where every mechanical supervision decision is code, not remembered prose:
247
249
  `reconcileRecovering`, held in `recovering` until `sched resume`) and every batch
248
250
  respawn wedge in `runBatchTick` (tail, member continuation, fix, report, and the
249
251
  `ready` → `claimAndSetup` claim — `runValidate`'s local suite run is unaffected) stop
250
- respawning into the wall, which previously ignored `paused` entirely. `dispatch-
252
+ respawning into the wall, which previously ignored `paused` entirely. (Unverified
253
+ tail/report exits that are NOT API errors are capped separately since #832 —
254
+ `respawn-cap:tail`, or a merged batch closed without its report.) `dispatch-
251
255
  health.ts` is its own module (not `engine.ts`) specifically so it is shared by BOTH
252
256
  dispatch paths without `engine.ts` and `batch-dispatch.ts` importing each other.
253
257
  Batch dispatch logs (per-role, append-mode) are fenced to `log_offset_at_spawn`,
@@ -1011,6 +1015,51 @@ Dissolve never throws validated work away:
1011
1015
  State schema 1.24.0: `IssueStatus` gains `handed-back`; `EvictionRecord` gains optional
1012
1016
  `kind`/`branch`, `FailureEvidence` optional `branch`.
1013
1017
 
1018
+ ### The tail runs over landed members; a tail verdict is final (#832)
1019
+
1020
+ - **Landed members only.** The tail (aggregate review + integrate/ship) is dispatched with
1021
+ `{members}` = the batch's validated members (`validatedMembersOf`), never the raw
1022
+ `batch.members`, which still lists members that were stopped, handed back, evicted,
1023
+ parked or requeued. Before claiming a slot the engine makes the tail's own two-way check
1024
+ for free (`tailMembersRefusal`): no landed members blocks `no-landed-members`; when
1025
+ `batch.ranges` records boundary commits, a landed member with none blocks
1026
+ `members-mismatch:no-boundary-commit-<n>`, and a boundary commit for a member that did
1027
+ not land blocks `members-mismatch:unlanded-boundary-commit-<n>`. No tail agent is spent.
1028
+ - **`sched stop --issue` on a parked member.** A `handed-back`/`evicted` member of a live
1029
+ batch can be stopped on its own (the operator's cleanup after a hand-back). It moves to
1030
+ `stopped` and keeps its exit record, so it stays out of the landed set. A member still in
1031
+ the batch (pending, in work, or validated) is still refused: use `sched stop --batch`.
1032
+ - **A blocked tail blocks the batch.** A `batch-review` or `batch-ship` `status=blocked`
1033
+ milestone posted by THIS tail dispatch (fenced by the slot's `spawned_at`, which tail and
1034
+ report spawns now stamp; a slot from an older engine with no `spawned_at` skips this
1035
+ check and falls to the capped path below) releases the slot and blocks the batch
1036
+ `tail-blocked:<reason>` once, in one lock. It is never read as an unverified exit to
1037
+ respawn. The agent's milestone is already on the anchor, so the engine posts none. The
1038
+ reason is reduced to a slug.
1039
+ - **Per-phase respawn cap.** `BatchEntry.agent_exits` (`{ phase: 'tail'|'report', count }`,
1040
+ schema 1.26.0) counts unverified exits of the phase's agent; each is journaled with its
1041
+ position (`unverified exit 2/3 (log: …)`). #629 API-error exits are not counted: they
1042
+ have their own pause. After `MAX_BATCH_AGENT_RESPAWNS` (2) respawns, the next unverified
1043
+ exit ends the phase:
1044
+ - **tail** → the batch blocks `respawn-cap:tail`;
1045
+ - **report** → the batch (whose PR is already merged and deployed) closes
1046
+ `deployed → reported → done` without its report, journaling `report-failed
1047
+ respawn-cap:report — batch closed without its report`. A block would not hold there:
1048
+ `reconcileStaleBlockedBatches` walks a blocked batch with a merged PR straight back to
1049
+ `deployed`, and the report agent would respawn.
1050
+
1051
+ Verified progress (review done, park, report done) clears the count, and so does blocking
1052
+ the batch. A spawn that finds the count already spent (a crash between the count and the
1053
+ block) refuses instead of spawning. Before #832, b-20260924-04's tail was respawned four
1054
+ times at the strong tier (~375k tokens) until an operator stopped the batch.
1055
+ - **Clearing these blocks.** `no-landed-members`, `members-mismatch:*`, `tail-blocked:*` and
1056
+ `respawn-cap:tail` have no `sched resume --batch` recheck. `sched status` names the
1057
+ evidence (the anchor's blocked milestone, or the tail log) and the exits: fix the cause
1058
+ on the integration branch and open its PR by hand (`gh pr create --head <branch> --base
1059
+ <base>`; once it merges, `reconcileStaleBlockedBatches` settles the batch), or `sched
1060
+ abandon --batch <id>` (requeues the landed members full-cycle; for `no-landed-members`
1061
+ there is nothing to lose).
1062
+
1014
1063
  ## API surface
1015
1064
 
1016
1065
  ```ts
@@ -1183,6 +1232,9 @@ import {
1183
1232
  isMemberComplete, isMemberBlocked, // member milestone predicates (member-trail gated: mode=slot or batch=<id>, #677)
1184
1233
  isBatchTailParked, // batch-ship awaiting-merge + pr= — the batch park signal
1185
1234
  isBatchPhaseDone, // <phase> done on the anchor (batch-review/batch-report)
1235
+ batchPhaseBlockedReason, // #832: slugged reason of a `<phase> blocked` anchor milestone posted by THIS dispatch; null otherwise
1236
+ MAX_BATCH_AGENT_RESPAWNS, // #832: tail/report respawns after unverified exits before the phase ends (2)
1237
+ type BatchAgentExits, type BatchAgentPhase, // #832: BatchEntry.agent_exits ({ phase: 'tail'|'report', count })
1186
1238
  batchOfUnit, // batch:<id> → <id>; null for issue units or malformed ids
1187
1239
  } from '@ai-dossier/sched';
1188
1240
  ```
@@ -1314,7 +1366,7 @@ after-the-fact recovery, not a missing-data bug.
1314
1366
  queue data.
1315
1367
  - **Schema**: state/config files from #460 (schema 1.0.0), #464 (1.1.0), #468 (1.2.0),
1316
1368
  #472 (1.3.0), #500 (1.4.0), #505 (1.5.0), #504 (1.6.0), #523 (1.7.0) and #524 (1.8.0)
1317
- load and migrate to the current schema (1.25.0 — 1.24.0 was #810: no backfill,
1369
+ load and migrate to the current schema (1.26.0 — 1.24.0 was #810: no backfill,
1318
1370
  `kind`/`branch` optional, absent = an `evicted` record with no branch) automatically
1319
1371
  (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
1320
1372
  unit's queue entry, with the persisted `phase` as a fallback — #500), entry
@@ -1333,6 +1385,8 @@ after-the-fact recovery, not a missing-data bug.
1333
1385
  `IssueStatus` gains `handed-back`, `EvictionRecord`/`FailureEvidence` gain optional
1334
1386
  `kind`/`branch` — #810, schema 1.24.0; batch `pr_detect_ambiguous_reason`/`_since`/`_ticks`
1335
1387
  backfill to `null`/`null`/`0` — #789, schema 1.25.0).
1388
+ Schema 1.26.0 (#832): `BatchEntry` gains `agent_exits` (the per-phase tail/report
1389
+ respawn counter); `null` is backfilled on load.
1336
1390
  - **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
1337
1391
  edges gate readiness — an issue with an unmerged dependency, and a batch behind an
1338
1392
  unmerged batch, are never runnable.
@@ -172,6 +172,20 @@ export interface MemberFailure {
172
172
  * scheduler lands it there.
173
173
  */
174
174
  export declare function memberBranchFor(batchId: string, memberIndex: number, issue: number): string;
175
+ /**
176
+ * #832: why the tail must NOT be dispatched over `landed`, or null when it may.
177
+ * The deterministic half of the check the tail agent itself makes before
178
+ * integrating (the members it is told about and the members it derives from
179
+ * the integration branch's boundary commits must agree) — made here for free
180
+ * instead of by a strong-tier agent that then exits `members-mismatch`.
181
+ * `batch.ranges` is the recorded boundary attribution (recomputed at every
182
+ * landing); when it is empty nothing was attributed, so there is nothing to
183
+ * cross-check. Invariant this relies on: `ranges` names only landed members —
184
+ * every exit path that reverts a member also drops its range
185
+ * (`recovery.ts`'s partial dissolve); a path that re-derived `ranges` from
186
+ * `git log` after a revert would make this refuse a healthy batch.
187
+ */
188
+ export declare function tailMembersRefusal(batch: BatchEntry, landed: readonly number[]): string | null;
175
189
  /**
176
190
  * Evict the current member and either dissolve, or continue the batch via
177
191
  * `advanceMemberOrValidate` — the shared tail of both member-failure rails
@@ -238,6 +252,16 @@ export declare function resumeBlockedGate(deps: BatchDispatchDeps, config: Sched
238
252
  blockedBy?: 'inconclusive' | 'unevidenced-failure';
239
253
  detail?: string;
240
254
  };
255
+ /**
256
+ * #832: how many times one tail-work phase's agent (`tail` or `report`) is
257
+ * RESPAWNED after exiting unverified — the first dispatch plus this many
258
+ * respawns; the next unverified exit ends the phase instead. The #629 class:
259
+ * the tick loop's "same wedge" arm respawns a dead tail/report agent on its
260
+ * own, so without a cap an agent that exits without a verdict is
261
+ * redispatched forever (b-20260924-04: the strong-tier tail four times,
262
+ * ~375k tokens, stopped only by hand).
263
+ */
264
+ export declare const MAX_BATCH_AGENT_RESPAWNS = 2;
241
265
  /**
242
266
  * #686: reconcile a `blocked` batch whose work demonstrably shipped anyway.
243
267
  * `reconcilePrWatch` above only watches `awaiting-merge` batches, and
@@ -1 +1 @@
1
- {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
@@ -96,7 +96,9 @@ var __importStar = (this && this.__importStar) || (function () {
96
96
  };
97
97
  })();
98
98
  Object.defineProperty(exports, "__esModule", { value: true });
99
+ exports.MAX_BATCH_AGENT_RESPAWNS = void 0;
99
100
  exports.memberBranchFor = memberBranchFor;
101
+ exports.tailMembersRefusal = tailMembersRefusal;
100
102
  exports.evictMemberAndContinue = evictMemberAndContinue;
101
103
  exports.resumeBlockedGate = resumeBlockedGate;
102
104
  exports.reconcileStaleBlockedBatches = reconcileStaleBlockedBatches;
@@ -1309,6 +1311,50 @@ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
1309
1311
  }
1310
1312
  claimAndSpawn(deps, config, batchId, 'member', now, (nextState, slot) => spawnMember(deps, dispatch, nextState, slot, batchId, now, result, member));
1311
1313
  }
1314
+ /**
1315
+ * #832: why the tail must NOT be dispatched over `landed`, or null when it may.
1316
+ * The deterministic half of the check the tail agent itself makes before
1317
+ * integrating (the members it is told about and the members it derives from
1318
+ * the integration branch's boundary commits must agree) — made here for free
1319
+ * instead of by a strong-tier agent that then exits `members-mismatch`.
1320
+ * `batch.ranges` is the recorded boundary attribution (recomputed at every
1321
+ * landing); when it is empty nothing was attributed, so there is nothing to
1322
+ * cross-check. Invariant this relies on: `ranges` names only landed members —
1323
+ * every exit path that reverts a member also drops its range
1324
+ * (`recovery.ts`'s partial dissolve); a path that re-derived `ranges` from
1325
+ * `git log` after a revert would make this refuse a healthy batch.
1326
+ */
1327
+ function tailMembersRefusal(batch, landed) {
1328
+ if (landed.length === 0)
1329
+ return 'no-landed-members';
1330
+ if (batch.ranges.length === 0)
1331
+ return null;
1332
+ const attributed = new Set(batch.ranges.map((r) => r.issue));
1333
+ const missing = landed.filter((issue) => !attributed.has(issue));
1334
+ if (missing.length > 0)
1335
+ return `members-mismatch:no-boundary-commit-${missing.join(',')}`;
1336
+ // The tail derives members from the boundary commits too, and refuses when
1337
+ // the two lists disagree in EITHER direction (review-issue Aggregate Step 1).
1338
+ const landedSet = new Set(landed);
1339
+ const unlanded = [...attributed].filter((issue) => !landedSet.has(issue));
1340
+ return unlanded.length > 0
1341
+ ? `members-mismatch:unlanded-boundary-commit-${unlanded.join(',')}`
1342
+ : null;
1343
+ }
1344
+ /**
1345
+ * #832: the one pre-dispatch verdict for a tail — checked before a slot is
1346
+ * claimed and again under the claim's lock. A tail whose respawns are spent
1347
+ * (a crash landed between counting the exit that spent them and blocking the
1348
+ * batch) is refused, as is a members mismatch. Only a batch in a tail phase
1349
+ * is judged; anything else is not the tail's to refuse.
1350
+ */
1351
+ function tailDispatchRefusal(state, batch) {
1352
+ if (batch.status !== 'reviewing' && batch.status !== 'shipping')
1353
+ return null;
1354
+ if (agentRespawnsSpent(batch, 'tail'))
1355
+ return 'respawn-cap:tail';
1356
+ return tailMembersRefusal(batch, (0, state_1.validatedMembersOf)(state, batch));
1357
+ }
1312
1358
  /**
1313
1359
  * Tail/report/fix dispatches (this function, `spawnReportAgent`,
1314
1360
  * `reconcileFixSlot`) are NOT recorded live to `runs.jsonl` — only member
@@ -1317,11 +1363,27 @@ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
1317
1363
  * the only way to see their cost today, reconstructed from the raw log
1318
1364
  * after the fact. Wiring in live recording for these later means repeating
1319
1365
  * `spawnMember`'s own #564 fix first: none of these three spawn functions'
1320
- * patches stamp `SlotEntry.spawned_at` either, so a `recordXRunLog` guarded
1366
+ * patches stamped `SlotEntry.spawned_at` either, so a `recordXRunLog` guarded
1321
1367
  * on `spawned_at !== null` (mirroring `recordMemberRunLog`) would silently
1322
- * no-op forever, exactly like the original bug.
1368
+ * no-op forever, exactly like the original bug. #832 made the tail and report
1369
+ * spawns stamp it (their hand-back fence needs it); the fix agent still does
1370
+ * not.
1323
1371
  */
1324
1372
  function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1373
+ // #832: the tail runs over the LANDED members only — never `batch.members`,
1374
+ // which still lists a member that was stopped, handed back, evicted or
1375
+ // parked. A refusal blocks the batch before any agent is spawned (and
1376
+ // before a slot is claimed — `blockBatchForOperator` posts a milestone, an
1377
+ // exec that must not run under the store lock).
1378
+ const before = deps.store.load();
1379
+ const pending = (0, state_1.findBatch)(before, batchId);
1380
+ if (pending) {
1381
+ const refusal = tailDispatchRefusal(before, pending);
1382
+ if (refusal !== null) {
1383
+ blockBatchForOperator(deps, config, batchId, { reason: refusal, milestonePhase: 'batch-review' }, now, result);
1384
+ return;
1385
+ }
1386
+ }
1325
1387
  claimAndSpawn(deps, config, batchId, 'reviewing', now, (state, slot) => {
1326
1388
  const batch = (0, state_1.findBatch)(state, batchId);
1327
1389
  if (!batch || batch.worktree === null || batch.anchor === null) {
@@ -1331,9 +1393,20 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1331
1393
  });
1332
1394
  return releaseSlot(state, batchId, now);
1333
1395
  }
1396
+ const landed = (0, state_1.validatedMembersOf)(state, batch);
1397
+ const lateRefusal = tailDispatchRefusal(state, batch);
1398
+ if (lateRefusal !== null) {
1399
+ // The state changed between the pre-check and the lock (a concurrent
1400
+ // stop/park) — release, and the next tick's pre-check (reading the same
1401
+ // persisted state) blocks the batch with this reason.
1402
+ journalEvent(deps, 'slot-released', unit(batchId), {
1403
+ detail: `tail not dispatched: ${lateRefusal} (state changed before the claim; the next tick blocks the batch)`,
1404
+ });
1405
+ return releaseSlot(state, batchId, now);
1406
+ }
1334
1407
  const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, 'strong', batch.anchor);
1335
1408
  const cmd = spawnSpec.cmd;
1336
- const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, batch.members, batch.worktree);
1409
+ const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, landed, batch.worktree);
1337
1410
  const logFile = (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId);
1338
1411
  // #629: fences `handleDeadDispatchApiError`'s classification to this
1339
1412
  // dispatch's slice — see `spawnMember`'s identical comment.
@@ -1356,6 +1429,14 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1356
1429
  phase: 'reviewing',
1357
1430
  last_progress_at: now.toISOString(),
1358
1431
  log_offset_at_spawn: logOffset,
1432
+ // #832: the fence `reconcileTailSlot` reads — without it a
1433
+ // `batch-review blocked` left on the anchor by an EARLIER dispatch
1434
+ // (or a previous batch on the same anchor, #605) would block this one.
1435
+ // The fence tolerates `DISPATCH_FENCE_TOLERANCE_MS` (60s) of clock skew,
1436
+ // so a blocked milestone posted less than a minute before this spawn
1437
+ // still counts — only a resume that re-dispatches the tail that fast
1438
+ // could trip it.
1439
+ spawned_at: now.toISOString(),
1359
1440
  };
1360
1441
  const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(state, slot.id, 'running', patch, now) : state;
1361
1442
  deps.journal.append((0, journal_1.unitEvent)('spawned', unit(batchId), {
@@ -1370,6 +1451,13 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1370
1451
  });
1371
1452
  }
1372
1453
  function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
1454
+ // #832: respawns already spent (a crash landed between counting the exit
1455
+ // that spent them and closing the batch) — close it now, never spawn.
1456
+ const pending = (0, state_1.findBatch)(deps.store.load(), batchId);
1457
+ if (pending && pending.status === 'deployed' && agentRespawnsSpent(pending, 'report')) {
1458
+ closeWithoutReport(deps, batchId, 'respawn-cap:report', now, result);
1459
+ return;
1460
+ }
1373
1461
  claimAndSpawn(deps, config, batchId, 'report', now, (state, slot) => {
1374
1462
  const batch = (0, state_1.findBatch)(state, batchId);
1375
1463
  if (!batch || batch.anchor === null) {
@@ -1406,6 +1494,8 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
1406
1494
  phase: 'report',
1407
1495
  last_progress_at: now.toISOString(),
1408
1496
  log_offset_at_spawn: logOffset,
1497
+ // #832: the fence `reconcileReportSlot`'s hand-back check reads.
1498
+ spawned_at: now.toISOString(),
1409
1499
  };
1410
1500
  const next = slot.status === 'assigned'
1411
1501
  ? (0, state_1.transitionSlot)(state, slot.id, 'running', patchState, now)
@@ -2565,7 +2655,98 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
2565
2655
  }
2566
2656
  evictOffender(deps, config, batchId, offenderRecord.issue, 'overlap', now, result);
2567
2657
  }
2568
- function reconcileTailSlot(deps, batchId, slot, now, result) {
2658
+ /**
2659
+ * #832: how many times one tail-work phase's agent (`tail` or `report`) is
2660
+ * RESPAWNED after exiting unverified — the first dispatch plus this many
2661
+ * respawns; the next unverified exit ends the phase instead. The #629 class:
2662
+ * the tick loop's "same wedge" arm respawns a dead tail/report agent on its
2663
+ * own, so without a cap an agent that exits without a verdict is
2664
+ * redispatched forever (b-20260924-04: the strong-tier tail four times,
2665
+ * ~375k tokens, stopped only by hand).
2666
+ */
2667
+ exports.MAX_BATCH_AGENT_RESPAWNS = 2;
2668
+ /** #832: whether `batch` has already spent its respawns for `phase`. */
2669
+ function agentRespawnsSpent(batch, phase) {
2670
+ return (batch.agent_exits !== null &&
2671
+ batch.agent_exits.phase === phase &&
2672
+ batch.agent_exits.count > exports.MAX_BATCH_AGENT_RESPAWNS);
2673
+ }
2674
+ /**
2675
+ * #832: count one unverified exit of `phase`'s agent, release its slot, and
2676
+ * journal the exit with its position against the cap — one lock, so the count
2677
+ * and the release cannot come apart. Returns `'capped'` when this exit spent
2678
+ * the phase's last respawn (the caller ends the phase), else `'respawn'`.
2679
+ * The count is per phase — another phase's exits start it over — and verified
2680
+ * progress clears it (`clearAgentExits`).
2681
+ */
2682
+ function releaseAndCountAgentExit(deps, batchId, phase, failed, now) {
2683
+ const count = deps.store.withLock((s) => {
2684
+ const b = (0, state_1.findBatch)(s, batchId);
2685
+ const prior = b?.agent_exits;
2686
+ const next = prior && prior.phase === phase ? prior.count + 1 : 1;
2687
+ let n = releaseSlot(s, batchId, now);
2688
+ if (b)
2689
+ n = (0, state_1.patchBatch)(n, batchId, { agent_exits: { phase, count: next } }, now);
2690
+ return { state: n, result: next };
2691
+ });
2692
+ journalEvent(deps, failed.event, unit(batchId), {
2693
+ ...failed.extra,
2694
+ detail: `${String(failed.extra.detail ?? 'unverified exit')} ${count}/${exports.MAX_BATCH_AGENT_RESPAWNS + 1} (log: ${failed.log})`,
2695
+ });
2696
+ return count > exports.MAX_BATCH_AGENT_RESPAWNS ? 'capped' : 'respawn';
2697
+ }
2698
+ /** #832: verified progress in a tail-work phase — the respawn count starts over. */
2699
+ function clearAgentExits(s, batchId, now) {
2700
+ const b = (0, state_1.findBatch)(s, batchId);
2701
+ return b && b.agent_exits !== null ? (0, state_1.patchBatch)(s, batchId, { agent_exits: null }, now) : s;
2702
+ }
2703
+ /**
2704
+ * #832: the tail posted its own `batch-review`/`batch-ship blocked` — release
2705
+ * its slot and block the batch with that reason in ONE lock (no milestone to
2706
+ * post: the agent's is already the anchor's record, so no exec runs under the
2707
+ * lock), so a crash can never leave the slot released with the batch still
2708
+ * `reviewing` for the wedge arm to respawn. The agent decided; respawning it
2709
+ * would only get the same answer again.
2710
+ */
2711
+ function blockOnTailHandBack(deps, config, batch, reason, now, result) {
2712
+ const rDeps = recoveryDeps(deps, config, batch, now);
2713
+ deps.store.withLock((s) => {
2714
+ const released = releaseSlot(s, batch.id, now);
2715
+ const b = (0, state_1.findBatch)(released, batch.id);
2716
+ if (!b || types_1.TERMINAL_BATCH_STATUSES.has(b.status) || b.status === 'blocked') {
2717
+ return { state: released, result: undefined };
2718
+ }
2719
+ const blocked = (0, recovery_1.blockBatch)(released, batch.id, { reason, milestonePhase: 'batch-review', milestoneAlreadyPosted: true }, rDeps);
2720
+ return { state: blocked.state, result: undefined };
2721
+ });
2722
+ result.failed.push(unit(batch.id));
2723
+ }
2724
+ /**
2725
+ * #832: close a MERGED batch whose report agent spent its respawns, without
2726
+ * its report. The report is the batch's summary, not its work — the PR is
2727
+ * merged and deployed — so the batch goes `deployed → reported → done` and
2728
+ * is torn down like any finished batch. Blocking it instead would not stop
2729
+ * anything: `reconcileStaleBlockedBatches` walks a blocked batch with a
2730
+ * merged PR straight back to `deployed`, and the report agent would respawn.
2731
+ */
2732
+ function closeWithoutReport(deps, batchId, why, now, result) {
2733
+ const closed = deps.store.withLock((s) => {
2734
+ const b = (0, state_1.findBatch)(s, batchId);
2735
+ if (!b || b.status !== 'deployed')
2736
+ return { state: s, result: false };
2737
+ let n = (0, state_1.transitionBatch)(s, batchId, 'reported', { agent_exits: null }, now);
2738
+ n = (0, state_1.transitionBatch)(n, batchId, 'done', {}, now);
2739
+ return { state: n, result: true };
2740
+ });
2741
+ if (!closed)
2742
+ return;
2743
+ journalEvent(deps, 'report-failed', unit(batchId), {
2744
+ detail: `${why} — batch closed without its report (the PR is merged); post the batch report by hand if one is wanted`,
2745
+ });
2746
+ result.failed.push(unit(batchId));
2747
+ teardownBatch(deps, batchId);
2748
+ }
2749
+ function reconcileTailSlot(deps, config, batchId, slot, now, result) {
2569
2750
  const state = deps.store.load();
2570
2751
  const batch = (0, state_1.findBatch)(state, batchId);
2571
2752
  if (!batch || batch.anchor === null)
@@ -2580,10 +2761,28 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
2580
2761
  const b = (0, state_1.findBatch)(s, batchId);
2581
2762
  if (!b || b.status !== 'reviewing')
2582
2763
  return { state: s, result: undefined };
2583
- return { state: (0, state_1.transitionBatch)(s, batchId, 'shipping', {}, now), result: undefined };
2764
+ const n = clearAgentExits(s, batchId, now);
2765
+ return { state: (0, state_1.transitionBatch)(n, batchId, 'shipping', {}, now), result: undefined };
2584
2766
  });
2585
2767
  return;
2586
2768
  }
2769
+ // #832: the tail handed back (`batch-review`/`batch-ship blocked`, e.g.
2770
+ // `members-mismatch`) — block the batch with its reason, never respawn.
2771
+ // Checked before liveness: the agent is done deciding whether or not its
2772
+ // process has exited yet, and the block releases its slot. Only for a slot
2773
+ // that recorded its dispatch time: a tail spawned before #832 stamped
2774
+ // `spawned_at` has no fence, and an unfenced read would let an older
2775
+ // `blocked` milestone on the anchor block this dispatch — that exit falls
2776
+ // through to the (capped) unverified path instead.
2777
+ const handedBack = slot.spawned_at === null
2778
+ ? null
2779
+ : (0, groundtruth_1.batchPhaseBlockedReason)(milestone, ['batch-review', 'batch-ship'], slot.spawned_at);
2780
+ if (handedBack !== null) {
2781
+ if (slot.pid !== null && !dead)
2782
+ deps.spawnDeps.kill(slot.pid, slot.pid_start ?? undefined);
2783
+ blockOnTailHandBack(deps, config, batch, `tail-blocked:${handedBack}`, now, result);
2784
+ return;
2785
+ }
2587
2786
  if ((0, groundtruth_1.isBatchTailParked)(milestone)) {
2588
2787
  const pr = (0, groundtruth_1.prOfMilestone)(milestone);
2589
2788
  journalEvent(deps, 'pr-parked', unit(batchId), { pr: pr ?? undefined });
@@ -2595,6 +2794,7 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
2595
2794
  const b = (0, state_1.findBatch)(n, batchId);
2596
2795
  if (!b)
2597
2796
  return { state: n, result: undefined };
2797
+ n = clearAgentExits(n, batchId, now);
2598
2798
  n = b.status === 'reviewing' ? (0, state_1.transitionBatch)(n, batchId, 'shipping', {}, now) : n;
2599
2799
  n = (0, state_1.transitionBatch)(n, batchId, 'awaiting-merge', { pr }, now);
2600
2800
  return { state: n, result: undefined };
@@ -2610,12 +2810,20 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
2610
2810
  if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
2611
2811
  return;
2612
2812
  }
2613
- deps.journal.append((0, journal_1.unitEvent)('unit-failed', unit(batchId), { reason: 'tail-agent-exited-unverified' }), now);
2614
- deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
2813
+ // #832: counted — once the respawns are spent the batch blocks instead.
2814
+ const outcome = releaseAndCountAgentExit(deps, batchId, 'tail', {
2815
+ event: 'unit-failed',
2816
+ extra: { reason: 'tail-agent-exited-unverified' },
2817
+ log: (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId),
2818
+ }, now);
2819
+ if (outcome === 'capped') {
2820
+ blockBatchForOperator(deps, config, batchId, { reason: 'respawn-cap:tail', milestonePhase: 'batch-review' }, now, result);
2821
+ return;
2822
+ }
2615
2823
  result.failed.push(unit(batchId));
2616
2824
  }
2617
2825
  }
2618
- function reconcileReportSlot(deps, batchId, slot, now, result) {
2826
+ function reconcileReportSlot(deps, config, batchId, slot, now, result) {
2619
2827
  const state = deps.store.load();
2620
2828
  const batch = (0, state_1.findBatch)(state, batchId);
2621
2829
  if (!batch || batch.anchor === null)
@@ -2630,7 +2838,7 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
2630
2838
  // #629: a verified report completion is proof dispatch is healthy —
2631
2839
  // reset the confirmed-failure streak, mirroring the per-issue path's
2632
2840
  // own verified-complete branch.
2633
- let n = (0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now));
2841
+ let n = clearAgentExits((0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now)), batchId, now);
2634
2842
  const b = (0, state_1.findBatch)(n, batchId);
2635
2843
  if (!b || b.status !== 'deployed')
2636
2844
  return { state: n, result: undefined };
@@ -2650,8 +2858,15 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
2650
2858
  if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
2651
2859
  return;
2652
2860
  }
2653
- deps.journal.append((0, journal_1.unitEvent)('report-failed', unit(batchId), { detail: 'unverified exit' }), now);
2654
- deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
2861
+ // #832: counted — once the respawns are spent the merged batch closes
2862
+ // without its report (see `closeWithoutReport` for why not a block).
2863
+ const outcome = releaseAndCountAgentExit(deps, batchId, 'report', {
2864
+ event: 'report-failed',
2865
+ extra: { detail: 'unverified exit' },
2866
+ log: (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId),
2867
+ }, now);
2868
+ if (outcome === 'capped')
2869
+ closeWithoutReport(deps, batchId, 'respawn-cap:report', now, result);
2655
2870
  }
2656
2871
  }
2657
2872
  // --- PR watch for `awaiting-merge` batches (mirrors `pollParkedPrs`/`reconcileParked`) ---
@@ -4158,7 +4373,7 @@ function runBatchTick(deps, config, dispatch) {
4158
4373
  if (!fresh || types_1.TERMINAL_BATCH_STATUSES.has(fresh.status) || fresh.status === 'blocked') {
4159
4374
  return { state, result: undefined };
4160
4375
  }
4161
- const blocked = (0, state_1.transitionBatch)(state, batchId, 'blocked', { blocked_reason: 'reconcile-error' }, now);
4376
+ const blocked = (0, state_1.transitionBatch)(state, batchId, 'blocked', { blocked_reason: 'reconcile-error', agent_exits: null }, now);
4162
4377
  return { state: (0, state_1.releaseAllBatchSlots)(blocked, batchId, now), result: undefined };
4163
4378
  });
4164
4379
  journalEvent(deps, 'unit-failed', unit(batchId), { reason: 'reconcile-error', detail });
@@ -4272,10 +4487,10 @@ function runBatchTick(deps, config, dispatch) {
4272
4487
  reconcileFixSlot(deps, config, batch.id, slot, now, result);
4273
4488
  }
4274
4489
  else if (batch.status === 'reviewing' || batch.status === 'shipping') {
4275
- reconcileTailSlot(deps, batch.id, slot, now, result);
4490
+ reconcileTailSlot(deps, config, batch.id, slot, now, result);
4276
4491
  }
4277
4492
  else if (batch.status === 'deployed') {
4278
- reconcileReportSlot(deps, batch.id, slot, now, result);
4493
+ reconcileReportSlot(deps, config, batch.id, slot, now, result);
4279
4494
  }
4280
4495
  continue;
4281
4496
  }