@ai-dossier/sched 0.49.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.
@@ -96,9 +96,12 @@ 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;
104
+ exports.reconcileStaleBlockedBatches = reconcileStaleBlockedBatches;
102
105
  exports.memberDispatchModeFor = memberDispatchModeFor;
103
106
  exports.runBatchTick = runBatchTick;
104
107
  const fs = __importStar(require("node:fs"));
@@ -1308,6 +1311,50 @@ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
1308
1311
  }
1309
1312
  claimAndSpawn(deps, config, batchId, 'member', now, (nextState, slot) => spawnMember(deps, dispatch, nextState, slot, batchId, now, result, member));
1310
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
+ }
1311
1358
  /**
1312
1359
  * Tail/report/fix dispatches (this function, `spawnReportAgent`,
1313
1360
  * `reconcileFixSlot`) are NOT recorded live to `runs.jsonl` — only member
@@ -1316,11 +1363,27 @@ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
1316
1363
  * the only way to see their cost today, reconstructed from the raw log
1317
1364
  * after the fact. Wiring in live recording for these later means repeating
1318
1365
  * `spawnMember`'s own #564 fix first: none of these three spawn functions'
1319
- * patches stamp `SlotEntry.spawned_at` either, so a `recordXRunLog` guarded
1366
+ * patches stamped `SlotEntry.spawned_at` either, so a `recordXRunLog` guarded
1320
1367
  * on `spawned_at !== null` (mirroring `recordMemberRunLog`) would silently
1321
- * 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.
1322
1371
  */
1323
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
+ }
1324
1387
  claimAndSpawn(deps, config, batchId, 'reviewing', now, (state, slot) => {
1325
1388
  const batch = (0, state_1.findBatch)(state, batchId);
1326
1389
  if (!batch || batch.worktree === null || batch.anchor === null) {
@@ -1330,9 +1393,20 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1330
1393
  });
1331
1394
  return releaseSlot(state, batchId, now);
1332
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
+ }
1333
1407
  const spawnSpec = (0, dispatch_1.resolveTierSpawn)(dispatch, 'strong', batch.anchor);
1334
1408
  const cmd = spawnSpec.cmd;
1335
- 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);
1336
1410
  const logFile = (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId);
1337
1411
  // #629: fences `handleDeadDispatchApiError`'s classification to this
1338
1412
  // dispatch's slice — see `spawnMember`'s identical comment.
@@ -1355,6 +1429,14 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1355
1429
  phase: 'reviewing',
1356
1430
  last_progress_at: now.toISOString(),
1357
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(),
1358
1440
  };
1359
1441
  const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(state, slot.id, 'running', patch, now) : state;
1360
1442
  deps.journal.append((0, journal_1.unitEvent)('spawned', unit(batchId), {
@@ -1369,6 +1451,13 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1369
1451
  });
1370
1452
  }
1371
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
+ }
1372
1461
  claimAndSpawn(deps, config, batchId, 'report', now, (state, slot) => {
1373
1462
  const batch = (0, state_1.findBatch)(state, batchId);
1374
1463
  if (!batch || batch.anchor === null) {
@@ -1405,6 +1494,8 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
1405
1494
  phase: 'report',
1406
1495
  last_progress_at: now.toISOString(),
1407
1496
  log_offset_at_spawn: logOffset,
1497
+ // #832: the fence `reconcileReportSlot`'s hand-back check reads.
1498
+ spawned_at: now.toISOString(),
1408
1499
  };
1409
1500
  const next = slot.status === 'assigned'
1410
1501
  ? (0, state_1.transitionSlot)(state, slot.id, 'running', patchState, now)
@@ -2564,7 +2655,98 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
2564
2655
  }
2565
2656
  evictOffender(deps, config, batchId, offenderRecord.issue, 'overlap', now, result);
2566
2657
  }
2567
- 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) {
2568
2750
  const state = deps.store.load();
2569
2751
  const batch = (0, state_1.findBatch)(state, batchId);
2570
2752
  if (!batch || batch.anchor === null)
@@ -2579,10 +2761,28 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
2579
2761
  const b = (0, state_1.findBatch)(s, batchId);
2580
2762
  if (!b || b.status !== 'reviewing')
2581
2763
  return { state: s, result: undefined };
2582
- 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 };
2583
2766
  });
2584
2767
  return;
2585
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
+ }
2586
2786
  if ((0, groundtruth_1.isBatchTailParked)(milestone)) {
2587
2787
  const pr = (0, groundtruth_1.prOfMilestone)(milestone);
2588
2788
  journalEvent(deps, 'pr-parked', unit(batchId), { pr: pr ?? undefined });
@@ -2594,6 +2794,7 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
2594
2794
  const b = (0, state_1.findBatch)(n, batchId);
2595
2795
  if (!b)
2596
2796
  return { state: n, result: undefined };
2797
+ n = clearAgentExits(n, batchId, now);
2597
2798
  n = b.status === 'reviewing' ? (0, state_1.transitionBatch)(n, batchId, 'shipping', {}, now) : n;
2598
2799
  n = (0, state_1.transitionBatch)(n, batchId, 'awaiting-merge', { pr }, now);
2599
2800
  return { state: n, result: undefined };
@@ -2609,12 +2810,20 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
2609
2810
  if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
2610
2811
  return;
2611
2812
  }
2612
- deps.journal.append((0, journal_1.unitEvent)('unit-failed', unit(batchId), { reason: 'tail-agent-exited-unverified' }), now);
2613
- 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
+ }
2614
2823
  result.failed.push(unit(batchId));
2615
2824
  }
2616
2825
  }
2617
- function reconcileReportSlot(deps, batchId, slot, now, result) {
2826
+ function reconcileReportSlot(deps, config, batchId, slot, now, result) {
2618
2827
  const state = deps.store.load();
2619
2828
  const batch = (0, state_1.findBatch)(state, batchId);
2620
2829
  if (!batch || batch.anchor === null)
@@ -2629,7 +2838,7 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
2629
2838
  // #629: a verified report completion is proof dispatch is healthy —
2630
2839
  // reset the confirmed-failure streak, mirroring the per-issue path's
2631
2840
  // own verified-complete branch.
2632
- 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);
2633
2842
  const b = (0, state_1.findBatch)(n, batchId);
2634
2843
  if (!b || b.status !== 'deployed')
2635
2844
  return { state: n, result: undefined };
@@ -2649,24 +2858,57 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
2649
2858
  if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
2650
2859
  return;
2651
2860
  }
2652
- deps.journal.append((0, journal_1.unitEvent)('report-failed', unit(batchId), { detail: 'unverified exit' }), now);
2653
- 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);
2654
2870
  }
2655
2871
  }
2656
2872
  // --- PR watch for `awaiting-merge` batches (mirrors `pollParkedPrs`/`reconcileParked`) ---
2657
2873
  /**
2658
- * Reset a batch's `pr-watch-failed` dedup marker (#630/#633). A no-op when no
2659
- * streak is recorded, so it is safe to call on every non-`awaiting-merge`
2660
- * batch every tick.
2874
+ * Clear one `<prefix>_reason`/`_since`/`_ticks` batch dedup marker — a no-op
2875
+ * when no streak is recorded (`currentReason === null`), so every caller is
2876
+ * safe to invoke unconditionally, every tick, on a batch that may or may not
2877
+ * have an open streak. Shared by `pr-watch-failed` (#630/#633),
2878
+ * `anchor-close-failed` (#768) and `pr-detect-ambiguous` (#789) — each
2879
+ * family previously carried its own copy of this exact body (#789 review).
2661
2880
  */
2662
- function clearPrWatchFailed(deps, batch, now) {
2663
- if (batch.pr_watch_failed_reason === null)
2881
+ function clearBatchDedupMarker(deps, batch, currentReason, clearedFields, now) {
2882
+ if (currentReason === null)
2664
2883
  return;
2665
2884
  deps.store.withLock((s) => ({
2666
- state: (0, state_1.patchBatch)(s, batch.id, state_1.CLEARED_PR_WATCH_FIELDS, now, false),
2885
+ state: (0, state_1.patchBatch)(s, batch.id, clearedFields, now, false),
2667
2886
  result: undefined,
2668
2887
  }));
2669
2888
  }
2889
+ /**
2890
+ * Advance one `<prefix>_reason`/`_since`/`_ticks` batch dedup marker by one
2891
+ * tick and decide whether THIS tick should journal — the streak's first tick,
2892
+ * or every `JOURNAL_DEDUP_REANNOUNCE_TICKS` after, never every tick
2893
+ * (#610/#630/#632/#768/#789's shared shape). Pure — the caller still owns
2894
+ * building its own journal payload (each family logs different extra fields)
2895
+ * and the `deps.store.withLock` persistence write; this only computes
2896
+ * `since`/`ticks`/whether to announce.
2897
+ */
2898
+ function advanceBatchDedupStreak(priorReason, priorSince, priorTicks, reason, now) {
2899
+ const isNewStreak = priorReason !== reason || priorSince === null;
2900
+ const ticks = isNewStreak ? 1 : priorTicks + 1;
2901
+ const since = isNewStreak || priorSince === null ? now.toISOString() : priorSince;
2902
+ return { since, ticks, announce: isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0 };
2903
+ }
2904
+ /**
2905
+ * Reset a batch's `pr-watch-failed` dedup marker (#630/#633). A no-op when no
2906
+ * streak is recorded, so it is safe to call on every non-`awaiting-merge`
2907
+ * batch every tick.
2908
+ */
2909
+ function clearPrWatchFailed(deps, batch, now) {
2910
+ clearBatchDedupMarker(deps, batch, batch.pr_watch_failed_reason, state_1.CLEARED_PR_WATCH_FIELDS, now);
2911
+ }
2670
2912
  function reconcilePrWatch(deps, now, result) {
2671
2913
  const state = deps.store.load();
2672
2914
  for (const batch of state.batches) {
@@ -2715,9 +2957,6 @@ function reconcilePrWatch(deps, now, result) {
2715
2957
  }
2716
2958
  if (truth.blocked || truth.mergeable === 'CONFLICTING') {
2717
2959
  const reason = truth.blocked ? 'auto-merge-blocked' : 'pr-conflicting';
2718
- const isNewStreak = batch.pr_watch_failed_reason !== reason;
2719
- const ticks = isNewStreak ? 1 : batch.pr_watch_failed_ticks + 1;
2720
- const since = isNewStreak ? now.toISOString() : batch.pr_watch_failed_since;
2721
2960
  // #630: journal on the streak's first tick, then again only every
2722
2961
  // `JOURNAL_DEDUP_REANNOUNCE_TICKS` — never every tick — mirrors #610's
2723
2962
  // `stale_milestone_ignored_for` dedup for the "distinct condition"
@@ -2729,7 +2968,8 @@ function reconcilePrWatch(deps, now, result) {
2729
2968
  // of its own); `since` is the streak's onset, and is what makes the
2730
2969
  // duration legible — `ticks_persisted` is a tick count against an
2731
2970
  // operator-tunable `reconcile_interval_ms`, not a fixed wall-clock.
2732
- if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
2971
+ const { since, ticks, announce } = advanceBatchDedupStreak(batch.pr_watch_failed_reason, batch.pr_watch_failed_since, batch.pr_watch_failed_ticks, reason, now);
2972
+ if (announce) {
2733
2973
  journalEvent(deps, 'pr-watch-failed', unit(batch.id), {
2734
2974
  reason,
2735
2975
  pr,
@@ -2872,6 +3112,51 @@ function branchMergedIntoBase(deps, batch) {
2872
3112
  const baseTip = deps.exec('git', ['rev-parse', base], deps.repoDir);
2873
3113
  return branchTip !== null && baseTip !== null && branchTip.trim() !== '' && branchTip !== baseTip;
2874
3114
  }
3115
+ /**
3116
+ * The ambiguity condition stopped qualifying (the branch resolved to zero or
3117
+ * exactly one candidate), or the batch left `blocked` some other way (this
3118
+ * reconcile's own success, `sched resume --batch`, `sched abandon`, or the
3119
+ * batch aging out of the reconcile window): end any `pr-detect-ambiguous`
3120
+ * streak, so a future ambiguity episode — even a re-block of the SAME batch
3121
+ * with the SAME candidates — journals its own first line instead of silently
3122
+ * continuing a streak from a previous `blocked` stretch (#789 review). A
3123
+ * no-op (no write) when no streak is recorded.
3124
+ */
3125
+ function clearPrDetectAmbiguous(deps, batch, now) {
3126
+ clearBatchDedupMarker(deps, batch, batch.pr_detect_ambiguous_reason, state_1.CLEARED_PR_DETECT_AMBIGUOUS_FIELDS, now);
3127
+ }
3128
+ /**
3129
+ * #789: more than one MERGED PR matches `batch.branch` — positive evidence
3130
+ * only, so the caller records nothing. Journal it on the streak's first tick
3131
+ * and every `JOURNAL_DEDUP_REANNOUNCE_TICKS` after — never every tick — the
3132
+ * same dedup shape `recordAnchorCloseFailed`/`reconcilePrWatch` use.
3133
+ */
3134
+ function recordPrDetectAmbiguous(deps, batch, matches, now) {
3135
+ const { since, ticks, announce } = advanceBatchDedupStreak(batch.pr_detect_ambiguous_reason, batch.pr_detect_ambiguous_since, batch.pr_detect_ambiguous_ticks, state_1.PR_DETECT_AMBIGUOUS_REASON, now);
3136
+ if (announce) {
3137
+ journalEvent(deps, 'pr-detect-ambiguous', unit(batch.id), {
3138
+ reason: state_1.PR_DETECT_AMBIGUOUS_REASON,
3139
+ branch: batch.branch ?? undefined,
3140
+ matches: matches.join(','),
3141
+ at: now.toISOString(),
3142
+ since,
3143
+ ticks_persisted: ticks,
3144
+ // #789 review: neither "record it explicitly" (no command exists) nor
3145
+ // "close the extras" (a MERGED PR cannot be closed, so it never leaves
3146
+ // `gh pr list --state merged`) was ever an actionable remedy — state
3147
+ // what actually resolves it instead. #824 tracks the missing verb.
3148
+ detail: `${matches.length} MERGED PRs match head=${batch.branch ?? '?'} (#${matches.join(', #')}) — refusing to guess which is ours; nothing recorded. The batch still reconciles via commits-in-base/members-closed evidence if either holds; otherwise inspect and \`sched abandon --batch ${batch.id}\`, or use \`sched attach-pr\` once #824 ships it`,
3149
+ });
3150
+ }
3151
+ deps.store.withLock((s) => ({
3152
+ state: (0, state_1.patchBatch)(s, batch.id, {
3153
+ pr_detect_ambiguous_reason: state_1.PR_DETECT_AMBIGUOUS_REASON,
3154
+ pr_detect_ambiguous_since: since,
3155
+ pr_detect_ambiguous_ticks: ticks,
3156
+ }, now, false),
3157
+ result: undefined,
3158
+ }));
3159
+ }
2875
3160
  /**
2876
3161
  * #686: reconcile a `blocked` batch whose work demonstrably shipped anyway.
2877
3162
  * `reconcilePrWatch` above only watches `awaiting-merge` batches, and
@@ -2884,21 +3169,28 @@ function branchMergedIntoBase(deps, batch) {
2884
3169
  * (engine.ts) under the same principle: ground truth beats the ledger, for
2885
3170
  * ANY stale verdict — the blocked_reason is deliberately not examined.
2886
3171
  *
2887
- * Evidence (either suffices, cheapest first): the batch PR is MERGED with a
2888
- * real timestamp (a merged batch PR contains the whole batch branch), or the
2889
- * batch branch has fully landed in the base branch. A survivability guard
2890
- * applies to BOTH paths (see `everySurvivingMemberDispatched`) — a batch with
2891
- * a surviving member that was never dispatched is not reconcilable.
3172
+ * Evidence (cheapest first): the batch PR is MERGED with a real timestamp (a
3173
+ * merged batch PR contains the whole batch branch) — including one
3174
+ * `batch.pr` never recorded at all (#789): when `pr === null`, before falling
3175
+ * through to the weaker branch/members evidence below, look for exactly one
3176
+ * MERGED PR whose head is the batch branch (`imboard#4255`, a hand-opened PR
3177
+ * the ledger never saw). Positive evidence only — zero candidates changes
3178
+ * nothing, more than one is `ambiguous` and journaled rather than guessed —
3179
+ * or the batch branch has fully landed in the base branch. A survivability
3180
+ * guard applies to every path (see `everySurvivingMemberDispatched`) — a
3181
+ * batch with a surviving member that was never dispatched is not
3182
+ * reconcilable.
2892
3183
  *
2893
3184
  * On reconcile the batch joins the SAME rail a normally-merged batch takes:
2894
3185
  * `blocked → merged → deployed` (the `blocked → merged` edge exists for
2895
3186
  * exactly this caller), surviving members walked to `done` along legal
2896
- * edges, and — when `batch.pr` is set — the ordinary `deployed` machinery
2897
- * dispatches the report agent next tick, with teardown after `batch-report
2898
- * done`. With `pr === null` no report agent can be prompted (it names the
2899
- * PR), so the same lock continues `deployed → reported → done` and the
2900
- * worktree is torn down here instead; the journal line says why no report
2901
- * agent was dispatched rather than leaving the gap silent.
3187
+ * edges, and — when the batch now carries a PR (recorded before or detected
3188
+ * here) — the ordinary `deployed` machinery dispatches the report agent next
3189
+ * tick, with teardown after `batch-report done`. With no PR at all no report
3190
+ * agent can be prompted (it names the PR), so the same lock continues
3191
+ * `deployed → reported → done` and the worktree is torn down here instead;
3192
+ * the journal line says why no report agent was dispatched rather than
3193
+ * leaving the gap silent.
2902
3194
  *
2903
3195
  * Bounded by construction: only `blocked` batches within the window are
2904
3196
  * examined, the PR check is one `gh` call, git runs only when PR evidence
@@ -2910,19 +3202,48 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2910
3202
  const state = deps.store.load();
2911
3203
  const nowMs = now.getTime();
2912
3204
  for (const batch of state.batches) {
2913
- if (batch.status !== 'blocked')
3205
+ if (batch.status !== 'blocked') {
3206
+ // #789 review: the marker is scoped to ONE `blocked` stretch, exactly
3207
+ // like `pr_watch_failed_*`'s own `awaiting-merge` scoping (#633) — the
3208
+ // batch left `blocked` (this reconcile's own success below, `sched
3209
+ // resume --batch`, or `sched abandon`), so a LATER re-block — even of
3210
+ // the SAME batch with the SAME candidate PRs — must journal a fresh
3211
+ // first line, never silently continue a stale streak from before.
3212
+ clearPrDetectAmbiguous(deps, batch, now);
2914
3213
  continue;
3214
+ }
2915
3215
  if (nowMs - Date.parse(batch.updated_at) >= STALE_BLOCKED_RECONCILE_WINDOW_MS)
2916
3216
  continue;
2917
3217
  if (!everySurvivingMemberDispatched(state, batch))
2918
3218
  continue;
2919
3219
  let evidence = null;
3220
+ // #789 review: one outer variable for the whole lookup outcome (rather
3221
+ // than three separate booleans/values re-deriving pieces of it) — the
3222
+ // resolution block below reads `lookup.kind` directly instead of a
3223
+ // parallel flag that has to be kept in sync with it by hand.
3224
+ let lookup;
2920
3225
  if (batch.pr !== null) {
2921
3226
  const truth = deps.groundTruth.prState(batch.pr);
2922
3227
  if (truth !== undefined && truth.state === 'MERGED' && truth.mergedAt !== null) {
2923
3228
  evidence = { kind: 'pr-merged', pr: batch.pr, mergedAt: truth.mergedAt };
2924
3229
  }
2925
3230
  }
3231
+ else if (batch.branch !== null) {
3232
+ // #789: batch.pr was never recorded — a hand-opened or manually
3233
+ // recovered PR the ledger never saw (the imboard#4255 shape). Look for
3234
+ // exactly one MERGED PR whose head is the batch branch before falling
3235
+ // through to the weaker branch/members evidence below. Positive
3236
+ // evidence only: `undefined` (unreachable) touches nothing, and
3237
+ // `ambiguous` (more than one candidate) is journaled, never guessed.
3238
+ lookup = deps.groundTruth.mergedPrForBranch?.(batch.branch, batch.base_branch, batch.created_at);
3239
+ if (lookup?.kind === 'found') {
3240
+ evidence = { kind: 'pr-merged', pr: lookup.pr, mergedAt: lookup.mergedAt };
3241
+ }
3242
+ }
3243
+ // Set only when THIS tick's automatic lookup found the PR — never when
3244
+ // `batch.pr` was already recorded (that PR is reported via `evidence.pr`
3245
+ // as before, and there is nothing new to persist).
3246
+ const detectedPr = lookup?.kind === 'found' ? lookup.pr : null;
2926
3247
  if (evidence === null && branchMergedIntoBase(deps, batch)) {
2927
3248
  evidence = { kind: 'commits-in-base' };
2928
3249
  }
@@ -2940,12 +3261,20 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2940
3261
  }).kind === 'closable') {
2941
3262
  evidence = { kind: 'members-closed' };
2942
3263
  }
3264
+ // #789: resolve the ambiguity streak exactly once per tick. An ambiguous
3265
+ // lookup records only when the batch ISN'T reconciling by other evidence
3266
+ // this same tick; any evidence at all (including a `found`/`none` lookup
3267
+ // result) clears a stale or same-tick streak — reconciling via ANY
3268
+ // evidence means the batch is leaving `blocked`, so a same-tick ambiguity
3269
+ // is moot too.
3270
+ if (lookup?.kind === 'ambiguous' && evidence === null) {
3271
+ recordPrDetectAmbiguous(deps, batch, lookup.matches, now);
3272
+ }
3273
+ else if (evidence !== null || lookup?.kind === 'found' || lookup?.kind === 'none') {
3274
+ clearPrDetectAmbiguous(deps, batch, now);
3275
+ }
2943
3276
  if (evidence === null)
2944
3277
  continue;
2945
- // A members-closed reconcile has no merged batch PR for a report agent to
2946
- // report on, whatever `batch.pr` recorded — finish the rail inline, as
2947
- // for `pr === null`.
2948
- const finishInline = batch.pr === null || evidence.kind === 'members-closed';
2949
3278
  // ...but never tear its worktree down: the members shipped OUTSIDE the
2950
3279
  // batch branch, so the blocked integration worktree may hold an
2951
3280
  // operator's unpushed repair work. It is left in place, inert, and the
@@ -2955,6 +3284,12 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2955
3284
  const failedAt = batch.updated_at;
2956
3285
  let reconciled = false;
2957
3286
  let unwalkable = [];
3287
+ // #789 review: what the lock ACTUALLY applied, not what the outer
3288
+ // snapshot predicted — re-derived from `b` (read under the lock) below,
3289
+ // since `batch.pr` could in principle have changed between the snapshot
3290
+ // above and the lock acquiring (e.g. a future `sched attach-pr`, #824).
3291
+ let effectivePr = null;
3292
+ let prWasRecorded = false;
2958
3293
  deps.store.withLock((s) => {
2959
3294
  const b = (0, state_1.findBatch)(s, batch.id);
2960
3295
  // Re-check under the lock: `sched resume --batch` (a passing recheck)
@@ -2962,7 +3297,19 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2962
3297
  // snapshot this pass started from.
2963
3298
  if (!b || b.status !== 'blocked')
2964
3299
  return { state: s, result: undefined };
2965
- let n = (0, state_1.transitionBatch)(s, batch.id, 'merged', {}, now);
3300
+ // #789: record the auto-detected PR as part of the SAME transition that
3301
+ // leaves `blocked` — a batch whose ledger never had one now does. Only
3302
+ // when `b.pr` is STILL null under the lock — never overwrite a PR
3303
+ // something else recorded between the snapshot and here.
3304
+ prWasRecorded = detectedPr !== null && b.pr === null;
3305
+ effectivePr = prWasRecorded ? detectedPr : b.pr;
3306
+ let n = (0, state_1.transitionBatch)(s, batch.id, 'merged', {
3307
+ // The ambiguity streak (this tick's own, or a stale one) is moot
3308
+ // the instant the batch reconciles by any evidence — fold the
3309
+ // clear into this same transition instead of a separate lock write.
3310
+ ...state_1.CLEARED_PR_DETECT_AMBIGUOUS_FIELDS,
3311
+ ...(prWasRecorded ? { pr: detectedPr } : {}),
3312
+ }, now);
2966
3313
  n = (0, state_1.transitionBatch)(n, batch.id, 'deployed', {}, now);
2967
3314
  unwalkable = [];
2968
3315
  for (const issue of b.members) {
@@ -2986,7 +3333,11 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2986
3333
  entry = (0, state_1.findEntry)(n, issue);
2987
3334
  }
2988
3335
  }
2989
- if (finishInline) {
3336
+ // The batch now carries a PR — recorded before, recorded just above,
3337
+ // or still null — only when one exists does the ordinary `deployed`
3338
+ // machinery have anything to hand the report agent. A members-closed
3339
+ // reconcile has no merged batch PR to report on regardless.
3340
+ if (effectivePr === null || evidence.kind === 'members-closed') {
2990
3341
  // No (merged) PR → no report agent is possible (its prompt names the
2991
3342
  // PR). Finish the terminal rail and let teardown run below.
2992
3343
  n = (0, state_1.transitionBatch)(n, batch.id, 'reported', {}, now);
@@ -2997,14 +3348,19 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2997
3348
  });
2998
3349
  if (!reconciled)
2999
3350
  continue;
3351
+ const finishInline = effectivePr === null || evidence.kind === 'members-closed';
3000
3352
  journalEvent(deps, 'stale-failure-reconciled', unit(batch.id), {
3001
- pr: batch.pr ?? undefined,
3353
+ pr: effectivePr ?? undefined,
3002
3354
  reason: blockedReason,
3003
3355
  failedAt,
3004
3356
  evidence: evidence.kind,
3005
3357
  ...(evidence.kind === 'pr-merged' ? { mergedAt: evidence.mergedAt } : {}),
3358
+ ...(prWasRecorded ? { pr_detected: true } : {}),
3006
3359
  detail: (evidence.kind === 'pr-merged'
3007
- ? `PR #${evidence.pr} is MERGED — ledger reconciled blocked (${blockedReason}) to the merged rail (blocked at ${failedAt}); report and teardown will now dispatch`
3360
+ ? (prWasRecorded
3361
+ ? `batch.pr was never recorded — detected PR #${evidence.pr} MERGED with head=${batch.branch} (gh pr list --state merged), recorded batch.pr=${evidence.pr}`
3362
+ : `PR #${evidence.pr} is MERGED`) +
3363
+ ` — ledger reconciled blocked (${blockedReason}) to the merged rail (blocked at ${failedAt}); report and teardown will now dispatch`
3008
3364
  : evidence.kind === 'members-closed'
3009
3365
  ? `every member issue is CLOSED as completed on GitHub with no failure trail — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`
3010
3366
  : `every member commit is an ancestor of ${batch.base_branch} — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`) +
@@ -3221,12 +3577,7 @@ function recordAnchorClosed(deps, batchId, now) {
3221
3577
  * stale streak's count. A no-op (no write) when no streak is recorded.
3222
3578
  */
3223
3579
  function clearAnchorCloseFailed(deps, batch, now) {
3224
- if (batch.anchor_close_failed_reason === null)
3225
- return;
3226
- deps.store.withLock((s) => ({
3227
- state: (0, state_1.patchBatch)(s, batch.id, state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS, now, false),
3228
- result: undefined,
3229
- }));
3580
+ clearBatchDedupMarker(deps, batch, batch.anchor_close_failed_reason, state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS, now);
3230
3581
  }
3231
3582
  /**
3232
3583
  * A close that qualified but failed: journal it on the streak's first tick and
@@ -3234,11 +3585,8 @@ function clearAnchorCloseFailed(deps, batch, now) {
3234
3585
  * exactly `reconcilePrWatch`'s `pr-watch-failed` dedup.
3235
3586
  */
3236
3587
  function recordAnchorCloseFailed(deps, batch, outcome, shipped, now) {
3237
- const priorSince = batch.anchor_close_failed_since;
3238
- const isNewStreak = batch.anchor_close_failed_reason !== outcome || priorSince === null;
3239
- const ticks = isNewStreak ? 1 : batch.anchor_close_failed_ticks + 1;
3240
- const since = isNewStreak || priorSince === null ? now.toISOString() : priorSince;
3241
- if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
3588
+ const { since, ticks, announce } = advanceBatchDedupStreak(batch.anchor_close_failed_reason, batch.anchor_close_failed_since, batch.anchor_close_failed_ticks, outcome, now);
3589
+ if (announce) {
3242
3590
  journalEvent(deps, 'anchor-close-failed', unit(batch.id), {
3243
3591
  issue: batch.anchor ?? undefined,
3244
3592
  reason: outcome,
@@ -4025,7 +4373,7 @@ function runBatchTick(deps, config, dispatch) {
4025
4373
  if (!fresh || types_1.TERMINAL_BATCH_STATUSES.has(fresh.status) || fresh.status === 'blocked') {
4026
4374
  return { state, result: undefined };
4027
4375
  }
4028
- 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);
4029
4377
  return { state: (0, state_1.releaseAllBatchSlots)(blocked, batchId, now), result: undefined };
4030
4378
  });
4031
4379
  journalEvent(deps, 'unit-failed', unit(batchId), { reason: 'reconcile-error', detail });
@@ -4139,10 +4487,10 @@ function runBatchTick(deps, config, dispatch) {
4139
4487
  reconcileFixSlot(deps, config, batch.id, slot, now, result);
4140
4488
  }
4141
4489
  else if (batch.status === 'reviewing' || batch.status === 'shipping') {
4142
- reconcileTailSlot(deps, batch.id, slot, now, result);
4490
+ reconcileTailSlot(deps, config, batch.id, slot, now, result);
4143
4491
  }
4144
4492
  else if (batch.status === 'deployed') {
4145
- reconcileReportSlot(deps, batch.id, slot, now, result);
4493
+ reconcileReportSlot(deps, config, batch.id, slot, now, result);
4146
4494
  }
4147
4495
  continue;
4148
4496
  }