@ai-dossier/sched 0.49.0 → 0.50.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
@@ -1040,7 +1040,9 @@ import {
1040
1040
  // runBatchCapability — #523)
1041
1041
  createSpawnDeps, // real detached-spawn process I/O
1042
1042
  createExecGroundTruth, // runstate/gh/git ground truth via subprocesses (injectable exec);
1043
- // since #468 also gh pr view PR state + setup info from comments
1043
+ // since #468 also gh pr view PR state + setup info from comments;
1044
+ // since #789 also mergedPrForBranch when constructed with a
1045
+ // verified `repo` (resolveProjectRepo)
1044
1046
  resolveDispatch, // config → resolved command/prompt/report-prompt/tier-models/timers/
1045
1047
  // per-tier spawn specs (tiers — #527)
1046
1048
  buildTierCommand, // resolved dispatch + tier + issue → argv, using that tier's OWN
@@ -1068,6 +1070,12 @@ import {
1068
1070
  prOfMilestone, // a milestone's pr= key as a positive integer
1069
1071
  parsePrViewJson, // gh pr view --json → PR truth (mergedAt/mergeable/blocked label)
1070
1072
  parseOpenPrListJson, // gh pr list --head <b> --state open → the open PR we opened (#596)
1073
+ parseMergedPrListJson, // gh pr list --head <b> --base <base> --state merged → MergedPrLookup
1074
+ // found/none/ambiguous — a hand-opened batch PR the ledger never
1075
+ // recorded (#789)
1076
+ type MergedPrLookup, // { kind: 'found', pr, mergedAt } | { kind: 'none' } |
1077
+ // { kind: 'ambiguous', matches } — GroundTruth.mergedPrForBranch's
1078
+ // own return type (#789)
1071
1079
  parseSetupInfo, // gh issue view --json comments → teardown inputs
1072
1080
  runTeardown, // #468 script teardown for a merged unit (pool return / worktree remove)
1073
1081
  isSafeWorktree, // worktree-path containment check (CWE-22)
@@ -1138,7 +1146,7 @@ import {
1138
1146
  issueCloseReader, parseIssueCloseTruthJson, parseRepoName,
1139
1147
  resolveProjectRepo, // #768 owner/name of the cwd repo only when it IS the project's
1140
1148
  hasLabel, // case-insensitive label match (#768)
1141
- validateState, // strict persisted-state validation (1.0.0-1.13.0 files migrate)
1149
+ validateState, // strict persisted-state validation (1.0.0-1.23.0 files migrate)
1142
1150
  DEFAULT_ISSUE_PRIORITY, DEFAULT_BATCH_PRIORITY, // priority defaults (0 / 10, #565)
1143
1151
  IllegalTransitionError, EnqueueError, CorruptStateError, LockTimeoutError,
1144
1152
  SchedNotFoundError,
@@ -1306,8 +1314,9 @@ after-the-fact recovery, not a missing-data bug.
1306
1314
  queue data.
1307
1315
  - **Schema**: state/config files from #460 (schema 1.0.0), #464 (1.1.0), #468 (1.2.0),
1308
1316
  #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)
1309
- load and migrate to the current schema (1.24.0 — #810: no backfill, `kind`/`branch`
1310
- optional, absent = an `evicted` record with no branch) automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
1317
+ load and migrate to the current schema (1.25.0 — 1.24.0 was #810: no backfill,
1318
+ `kind`/`branch` optional, absent = an `evicted` record with no branch) automatically
1319
+ (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
1311
1320
  unit's queue entry, with the persisted `phase` as a fallback — #500), entry
1312
1321
  `pr`/`cleanup`/`failure_evidence`, batch `anchor`/`branch`/`run_id`/`eviction_groups`/
1313
1322
  `evictions`/`fix_attempts`/`rebase_attempts`, state-level `last_pr_poll_at` backfill to
@@ -1320,7 +1329,10 @@ after-the-fact recovery, not a missing-data bug.
1320
1329
  `consecutive_dispatch_api_errors`/`dispatch_pause_reset_at` backfill to `0`/`null` —
1321
1330
  #629; batch `anchor_closed_at` and `anchor_close_failed_reason`/`_since`/`_ticks`
1322
1331
  backfill to `null`/`null`/`null`/`0` — #768, schema 1.22.0; batch `member_dispatch`/`member_runs`
1323
- backfill to `null`/`[]` — #809, schema 1.23.0 — a null mode past `ready` runs serially).
1332
+ backfill to `null`/`[]` — #809, schema 1.23.0 — a null mode past `ready` runs serially;
1333
+ `IssueStatus` gains `handed-back`, `EvictionRecord`/`FailureEvidence` gain optional
1334
+ `kind`/`branch` — #810, schema 1.24.0; batch `pr_detect_ambiguous_reason`/`_since`/`_ticks`
1335
+ backfill to `null`/`null`/`0` — #789, schema 1.25.0).
1324
1336
  - **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
1325
1337
  edges gate readiness — an issue with an unmerged dependency, and a batch behind an
1326
1338
  unmerged batch, are never runnable.
@@ -1413,6 +1425,31 @@ predicate in `anchor-close.ts`) over `blocked`/`done` batches touched within the
1413
1425
  the journal line says the worktree was kept, and `sched status` then raises a
1414
1426
  `kept-worktree` warning for it on every run until the ledger field clears — see
1415
1427
  above and `reconcileKeptWorktrees` (#791).
1428
+ - **PR-detection evidence (#789).** When `batch.pr` was never recorded at all — a PR
1429
+ opened by hand, e.g. imboard#4255 — the stale-blocked reconcile also looks for
1430
+ exactly one MERGED pull request whose head is the batch branch, based against
1431
+ `batch.base_branch`, created at or after the batch's own `created_at`, and not from a
1432
+ fork (`GroundTruth.mergedPrForBranch`: `gh pr list -R <repo> --head <branch> --base
1433
+ <base> --state merged`, gated on the same verified `resolveProjectRepo` #768 uses —
1434
+ absent entirely without one). Positive evidence only: zero candidates records
1435
+ nothing and falls through to the branch/members evidence below; two or more is
1436
+ `ambiguous` — no PR is recorded, and only when no OTHER evidence reconciles the
1437
+ batch that same tick is the ambiguity journaled (`pr-detect-ambiguous`, deduped like
1438
+ `pr-watch-failed`: once per `blocked` stretch, re-announced every
1439
+ `JOURNAL_DEDUP_REANNOUNCE_TICKS`; a batch that leaves `blocked` any way — this
1440
+ reconcile's own success, `sched resume --batch`, `sched abandon` — clears the marker,
1441
+ so a later re-block never inherits a stale streak). A batch that still reconciles via
1442
+ `commits-in-base`/`members-closed` does so without a PR and finishes inline, same as
1443
+ before #789. On a match, `batch.pr` is recorded as part of the SAME `blocked →
1444
+ merged` transition (the `stale-failure-reconciled` line then also carries
1445
+ `pr_detected: true`), so the ordinary `deployed` machinery (item 6, "Report dispatch",
1446
+ under "The PR watcher + tail work (#468)" below — shared infrastructure, not
1447
+ per-issue-only despite the section's origin) dispatches the report agent exactly as if the
1448
+ fleet had opened the PR itself, and `sched status`'s existing `pr` column shows it.
1449
+ Recording `batch.pr` never closes the anchor by itself — that stays #768's own
1450
+ evidence-gated `reconcileAnchorClosure`, unchanged. An ambiguous match with no
1451
+ explicit way to resolve it today (#824 tracks a `sched attach-pr` verb) still lets the
1452
+ batch reconcile via its other evidence, or falls to `sched abandon --batch`.
1416
1453
 
1417
1454
  Everything else is surfaced, never closed: `sched status --anchors` (opt-in; `status`
1418
1455
  makes no GitHub call without it) lists each still-open anchor of a batch no longer in
@@ -61,6 +61,7 @@
61
61
  * before returning, so a member's first command is never the one that
62
62
  * discovers `node_modules` is missing (`env-cold`, `docs/agent-traps.md`).
63
63
  */
64
+ import { type CommitInBase, type IssueCloseReader } from './anchor-close';
64
65
  import { type ResolvedDispatch, type SpawnDeps } from './dispatch';
65
66
  import { type GroundTruth } from './groundtruth';
66
67
  import { type Journal } from './journal';
@@ -237,6 +238,63 @@ export declare function resumeBlockedGate(deps: BatchDispatchDeps, config: Sched
237
238
  blockedBy?: 'inconclusive' | 'unevidenced-failure';
238
239
  detail?: string;
239
240
  };
241
+ /**
242
+ * #686: reconcile a `blocked` batch whose work demonstrably shipped anyway.
243
+ * `reconcilePrWatch` above only watches `awaiting-merge` batches, and
244
+ * `resumeBlockedGate` only re-runs the gate — so a batch blocked on a stale
245
+ * verdict (the concrete case: `gate-inconclusive:test.focused`, itself often
246
+ * a #690-declined gate TIMEOUT) whose PR merged out of band — or whose every
247
+ * member commit is already in the base — stayed blocked FOREVER: no report,
248
+ * no teardown, an operator-visible `blocked` row, and members stuck
249
+ * `in-work`. Sibling of the per-issue `reconcileStaleFailedParks`
250
+ * (engine.ts) under the same principle: ground truth beats the ledger, for
251
+ * ANY stale verdict — the blocked_reason is deliberately not examined.
252
+ *
253
+ * Evidence (cheapest first): the batch PR is MERGED with a real timestamp (a
254
+ * merged batch PR contains the whole batch branch) — including one
255
+ * `batch.pr` never recorded at all (#789): when `pr === null`, before falling
256
+ * through to the weaker branch/members evidence below, look for exactly one
257
+ * MERGED PR whose head is the batch branch (`imboard#4255`, a hand-opened PR
258
+ * the ledger never saw). Positive evidence only — zero candidates changes
259
+ * nothing, more than one is `ambiguous` and journaled rather than guessed —
260
+ * or the batch branch has fully landed in the base branch. A survivability
261
+ * guard applies to every path (see `everySurvivingMemberDispatched`) — a
262
+ * batch with a surviving member that was never dispatched is not
263
+ * reconcilable.
264
+ *
265
+ * On reconcile the batch joins the SAME rail a normally-merged batch takes:
266
+ * `blocked → merged → deployed` (the `blocked → merged` edge exists for
267
+ * exactly this caller), surviving members walked to `done` along legal
268
+ * edges, and — when the batch now carries a PR (recorded before or detected
269
+ * here) — the ordinary `deployed` machinery dispatches the report agent next
270
+ * tick, with teardown after `batch-report done`. With no PR at all no report
271
+ * agent can be prompted (it names the PR), so the same lock continues
272
+ * `deployed → reported → done` and the worktree is torn down here instead;
273
+ * the journal line says why no report agent was dispatched rather than
274
+ * leaving the gap silent.
275
+ *
276
+ * Bounded by construction: only `blocked` batches within the window are
277
+ * examined, the PR check is one `gh` call, git runs only when PR evidence
278
+ * didn't already reconcile, and a reconciled batch leaves `blocked` — the
279
+ * one journal line fires on the one-way transition, never per-tick (the
280
+ * #632 lesson: no journal inside a loop that doesn't terminate the unit).
281
+ */
282
+ export declare function reconcileStaleBlockedBatches(deps: BatchDispatchDeps, now: Date, result: BatchTickResult, anchorCtx: AnchorTickContext | undefined): void;
283
+ /**
284
+ * #768: what one tick's anchor work reads GitHub and git through — built once
285
+ * per tick so the stale-blocked reconcile and the anchor close never read the
286
+ * same issue twice in a tick. `undefined` when the tick cannot verify anything
287
+ * (no issue-closure ground truth, or no verified `anchorRepo`): then neither
288
+ * pass acts at all.
289
+ */
290
+ interface AnchorTickContext {
291
+ repo: string;
292
+ /** Cached for the tick — the verdict passes. */
293
+ read: IssueCloseReader;
294
+ /** Uncached — the re-read immediately before an irreversible close. */
295
+ readFresh: IssueCloseReader;
296
+ commitInBase: CommitInBase;
297
+ }
240
298
  /**
241
299
  * How a batch should dispatch its members (#809) — decided once, at the
242
300
  * `ready → executing` claim, and persisted on `BatchEntry.member_dispatch`.
@@ -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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"}
@@ -99,6 +99,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
99
99
  exports.memberBranchFor = memberBranchFor;
100
100
  exports.evictMemberAndContinue = evictMemberAndContinue;
101
101
  exports.resumeBlockedGate = resumeBlockedGate;
102
+ exports.reconcileStaleBlockedBatches = reconcileStaleBlockedBatches;
102
103
  exports.memberDispatchModeFor = memberDispatchModeFor;
103
104
  exports.runBatchTick = runBatchTick;
104
105
  const fs = __importStar(require("node:fs"));
@@ -2655,18 +2656,44 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
2655
2656
  }
2656
2657
  // --- PR watch for `awaiting-merge` batches (mirrors `pollParkedPrs`/`reconcileParked`) ---
2657
2658
  /**
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.
2659
+ * Clear one `<prefix>_reason`/`_since`/`_ticks` batch dedup marker — a no-op
2660
+ * when no streak is recorded (`currentReason === null`), so every caller is
2661
+ * safe to invoke unconditionally, every tick, on a batch that may or may not
2662
+ * have an open streak. Shared by `pr-watch-failed` (#630/#633),
2663
+ * `anchor-close-failed` (#768) and `pr-detect-ambiguous` (#789) — each
2664
+ * family previously carried its own copy of this exact body (#789 review).
2661
2665
  */
2662
- function clearPrWatchFailed(deps, batch, now) {
2663
- if (batch.pr_watch_failed_reason === null)
2666
+ function clearBatchDedupMarker(deps, batch, currentReason, clearedFields, now) {
2667
+ if (currentReason === null)
2664
2668
  return;
2665
2669
  deps.store.withLock((s) => ({
2666
- state: (0, state_1.patchBatch)(s, batch.id, state_1.CLEARED_PR_WATCH_FIELDS, now, false),
2670
+ state: (0, state_1.patchBatch)(s, batch.id, clearedFields, now, false),
2667
2671
  result: undefined,
2668
2672
  }));
2669
2673
  }
2674
+ /**
2675
+ * Advance one `<prefix>_reason`/`_since`/`_ticks` batch dedup marker by one
2676
+ * tick and decide whether THIS tick should journal — the streak's first tick,
2677
+ * or every `JOURNAL_DEDUP_REANNOUNCE_TICKS` after, never every tick
2678
+ * (#610/#630/#632/#768/#789's shared shape). Pure — the caller still owns
2679
+ * building its own journal payload (each family logs different extra fields)
2680
+ * and the `deps.store.withLock` persistence write; this only computes
2681
+ * `since`/`ticks`/whether to announce.
2682
+ */
2683
+ function advanceBatchDedupStreak(priorReason, priorSince, priorTicks, reason, now) {
2684
+ const isNewStreak = priorReason !== reason || priorSince === null;
2685
+ const ticks = isNewStreak ? 1 : priorTicks + 1;
2686
+ const since = isNewStreak || priorSince === null ? now.toISOString() : priorSince;
2687
+ return { since, ticks, announce: isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0 };
2688
+ }
2689
+ /**
2690
+ * Reset a batch's `pr-watch-failed` dedup marker (#630/#633). A no-op when no
2691
+ * streak is recorded, so it is safe to call on every non-`awaiting-merge`
2692
+ * batch every tick.
2693
+ */
2694
+ function clearPrWatchFailed(deps, batch, now) {
2695
+ clearBatchDedupMarker(deps, batch, batch.pr_watch_failed_reason, state_1.CLEARED_PR_WATCH_FIELDS, now);
2696
+ }
2670
2697
  function reconcilePrWatch(deps, now, result) {
2671
2698
  const state = deps.store.load();
2672
2699
  for (const batch of state.batches) {
@@ -2715,9 +2742,6 @@ function reconcilePrWatch(deps, now, result) {
2715
2742
  }
2716
2743
  if (truth.blocked || truth.mergeable === 'CONFLICTING') {
2717
2744
  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
2745
  // #630: journal on the streak's first tick, then again only every
2722
2746
  // `JOURNAL_DEDUP_REANNOUNCE_TICKS` — never every tick — mirrors #610's
2723
2747
  // `stale_milestone_ignored_for` dedup for the "distinct condition"
@@ -2729,7 +2753,8 @@ function reconcilePrWatch(deps, now, result) {
2729
2753
  // of its own); `since` is the streak's onset, and is what makes the
2730
2754
  // duration legible — `ticks_persisted` is a tick count against an
2731
2755
  // operator-tunable `reconcile_interval_ms`, not a fixed wall-clock.
2732
- if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
2756
+ const { since, ticks, announce } = advanceBatchDedupStreak(batch.pr_watch_failed_reason, batch.pr_watch_failed_since, batch.pr_watch_failed_ticks, reason, now);
2757
+ if (announce) {
2733
2758
  journalEvent(deps, 'pr-watch-failed', unit(batch.id), {
2734
2759
  reason,
2735
2760
  pr,
@@ -2872,6 +2897,51 @@ function branchMergedIntoBase(deps, batch) {
2872
2897
  const baseTip = deps.exec('git', ['rev-parse', base], deps.repoDir);
2873
2898
  return branchTip !== null && baseTip !== null && branchTip.trim() !== '' && branchTip !== baseTip;
2874
2899
  }
2900
+ /**
2901
+ * The ambiguity condition stopped qualifying (the branch resolved to zero or
2902
+ * exactly one candidate), or the batch left `blocked` some other way (this
2903
+ * reconcile's own success, `sched resume --batch`, `sched abandon`, or the
2904
+ * batch aging out of the reconcile window): end any `pr-detect-ambiguous`
2905
+ * streak, so a future ambiguity episode — even a re-block of the SAME batch
2906
+ * with the SAME candidates — journals its own first line instead of silently
2907
+ * continuing a streak from a previous `blocked` stretch (#789 review). A
2908
+ * no-op (no write) when no streak is recorded.
2909
+ */
2910
+ function clearPrDetectAmbiguous(deps, batch, now) {
2911
+ clearBatchDedupMarker(deps, batch, batch.pr_detect_ambiguous_reason, state_1.CLEARED_PR_DETECT_AMBIGUOUS_FIELDS, now);
2912
+ }
2913
+ /**
2914
+ * #789: more than one MERGED PR matches `batch.branch` — positive evidence
2915
+ * only, so the caller records nothing. Journal it on the streak's first tick
2916
+ * and every `JOURNAL_DEDUP_REANNOUNCE_TICKS` after — never every tick — the
2917
+ * same dedup shape `recordAnchorCloseFailed`/`reconcilePrWatch` use.
2918
+ */
2919
+ function recordPrDetectAmbiguous(deps, batch, matches, now) {
2920
+ 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);
2921
+ if (announce) {
2922
+ journalEvent(deps, 'pr-detect-ambiguous', unit(batch.id), {
2923
+ reason: state_1.PR_DETECT_AMBIGUOUS_REASON,
2924
+ branch: batch.branch ?? undefined,
2925
+ matches: matches.join(','),
2926
+ at: now.toISOString(),
2927
+ since,
2928
+ ticks_persisted: ticks,
2929
+ // #789 review: neither "record it explicitly" (no command exists) nor
2930
+ // "close the extras" (a MERGED PR cannot be closed, so it never leaves
2931
+ // `gh pr list --state merged`) was ever an actionable remedy — state
2932
+ // what actually resolves it instead. #824 tracks the missing verb.
2933
+ 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`,
2934
+ });
2935
+ }
2936
+ deps.store.withLock((s) => ({
2937
+ state: (0, state_1.patchBatch)(s, batch.id, {
2938
+ pr_detect_ambiguous_reason: state_1.PR_DETECT_AMBIGUOUS_REASON,
2939
+ pr_detect_ambiguous_since: since,
2940
+ pr_detect_ambiguous_ticks: ticks,
2941
+ }, now, false),
2942
+ result: undefined,
2943
+ }));
2944
+ }
2875
2945
  /**
2876
2946
  * #686: reconcile a `blocked` batch whose work demonstrably shipped anyway.
2877
2947
  * `reconcilePrWatch` above only watches `awaiting-merge` batches, and
@@ -2884,21 +2954,28 @@ function branchMergedIntoBase(deps, batch) {
2884
2954
  * (engine.ts) under the same principle: ground truth beats the ledger, for
2885
2955
  * ANY stale verdict — the blocked_reason is deliberately not examined.
2886
2956
  *
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.
2957
+ * Evidence (cheapest first): the batch PR is MERGED with a real timestamp (a
2958
+ * merged batch PR contains the whole batch branch) — including one
2959
+ * `batch.pr` never recorded at all (#789): when `pr === null`, before falling
2960
+ * through to the weaker branch/members evidence below, look for exactly one
2961
+ * MERGED PR whose head is the batch branch (`imboard#4255`, a hand-opened PR
2962
+ * the ledger never saw). Positive evidence only — zero candidates changes
2963
+ * nothing, more than one is `ambiguous` and journaled rather than guessed —
2964
+ * or the batch branch has fully landed in the base branch. A survivability
2965
+ * guard applies to every path (see `everySurvivingMemberDispatched`) — a
2966
+ * batch with a surviving member that was never dispatched is not
2967
+ * reconcilable.
2892
2968
  *
2893
2969
  * On reconcile the batch joins the SAME rail a normally-merged batch takes:
2894
2970
  * `blocked → merged → deployed` (the `blocked → merged` edge exists for
2895
2971
  * 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.
2972
+ * edges, and — when the batch now carries a PR (recorded before or detected
2973
+ * here) — the ordinary `deployed` machinery dispatches the report agent next
2974
+ * tick, with teardown after `batch-report done`. With no PR at all no report
2975
+ * agent can be prompted (it names the PR), so the same lock continues
2976
+ * `deployed → reported → done` and the worktree is torn down here instead;
2977
+ * the journal line says why no report agent was dispatched rather than
2978
+ * leaving the gap silent.
2902
2979
  *
2903
2980
  * Bounded by construction: only `blocked` batches within the window are
2904
2981
  * examined, the PR check is one `gh` call, git runs only when PR evidence
@@ -2910,19 +2987,48 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2910
2987
  const state = deps.store.load();
2911
2988
  const nowMs = now.getTime();
2912
2989
  for (const batch of state.batches) {
2913
- if (batch.status !== 'blocked')
2990
+ if (batch.status !== 'blocked') {
2991
+ // #789 review: the marker is scoped to ONE `blocked` stretch, exactly
2992
+ // like `pr_watch_failed_*`'s own `awaiting-merge` scoping (#633) — the
2993
+ // batch left `blocked` (this reconcile's own success below, `sched
2994
+ // resume --batch`, or `sched abandon`), so a LATER re-block — even of
2995
+ // the SAME batch with the SAME candidate PRs — must journal a fresh
2996
+ // first line, never silently continue a stale streak from before.
2997
+ clearPrDetectAmbiguous(deps, batch, now);
2914
2998
  continue;
2999
+ }
2915
3000
  if (nowMs - Date.parse(batch.updated_at) >= STALE_BLOCKED_RECONCILE_WINDOW_MS)
2916
3001
  continue;
2917
3002
  if (!everySurvivingMemberDispatched(state, batch))
2918
3003
  continue;
2919
3004
  let evidence = null;
3005
+ // #789 review: one outer variable for the whole lookup outcome (rather
3006
+ // than three separate booleans/values re-deriving pieces of it) — the
3007
+ // resolution block below reads `lookup.kind` directly instead of a
3008
+ // parallel flag that has to be kept in sync with it by hand.
3009
+ let lookup;
2920
3010
  if (batch.pr !== null) {
2921
3011
  const truth = deps.groundTruth.prState(batch.pr);
2922
3012
  if (truth !== undefined && truth.state === 'MERGED' && truth.mergedAt !== null) {
2923
3013
  evidence = { kind: 'pr-merged', pr: batch.pr, mergedAt: truth.mergedAt };
2924
3014
  }
2925
3015
  }
3016
+ else if (batch.branch !== null) {
3017
+ // #789: batch.pr was never recorded — a hand-opened or manually
3018
+ // recovered PR the ledger never saw (the imboard#4255 shape). Look for
3019
+ // exactly one MERGED PR whose head is the batch branch before falling
3020
+ // through to the weaker branch/members evidence below. Positive
3021
+ // evidence only: `undefined` (unreachable) touches nothing, and
3022
+ // `ambiguous` (more than one candidate) is journaled, never guessed.
3023
+ lookup = deps.groundTruth.mergedPrForBranch?.(batch.branch, batch.base_branch, batch.created_at);
3024
+ if (lookup?.kind === 'found') {
3025
+ evidence = { kind: 'pr-merged', pr: lookup.pr, mergedAt: lookup.mergedAt };
3026
+ }
3027
+ }
3028
+ // Set only when THIS tick's automatic lookup found the PR — never when
3029
+ // `batch.pr` was already recorded (that PR is reported via `evidence.pr`
3030
+ // as before, and there is nothing new to persist).
3031
+ const detectedPr = lookup?.kind === 'found' ? lookup.pr : null;
2926
3032
  if (evidence === null && branchMergedIntoBase(deps, batch)) {
2927
3033
  evidence = { kind: 'commits-in-base' };
2928
3034
  }
@@ -2940,12 +3046,20 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2940
3046
  }).kind === 'closable') {
2941
3047
  evidence = { kind: 'members-closed' };
2942
3048
  }
3049
+ // #789: resolve the ambiguity streak exactly once per tick. An ambiguous
3050
+ // lookup records only when the batch ISN'T reconciling by other evidence
3051
+ // this same tick; any evidence at all (including a `found`/`none` lookup
3052
+ // result) clears a stale or same-tick streak — reconciling via ANY
3053
+ // evidence means the batch is leaving `blocked`, so a same-tick ambiguity
3054
+ // is moot too.
3055
+ if (lookup?.kind === 'ambiguous' && evidence === null) {
3056
+ recordPrDetectAmbiguous(deps, batch, lookup.matches, now);
3057
+ }
3058
+ else if (evidence !== null || lookup?.kind === 'found' || lookup?.kind === 'none') {
3059
+ clearPrDetectAmbiguous(deps, batch, now);
3060
+ }
2943
3061
  if (evidence === null)
2944
3062
  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
3063
  // ...but never tear its worktree down: the members shipped OUTSIDE the
2950
3064
  // batch branch, so the blocked integration worktree may hold an
2951
3065
  // operator's unpushed repair work. It is left in place, inert, and the
@@ -2955,6 +3069,12 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2955
3069
  const failedAt = batch.updated_at;
2956
3070
  let reconciled = false;
2957
3071
  let unwalkable = [];
3072
+ // #789 review: what the lock ACTUALLY applied, not what the outer
3073
+ // snapshot predicted — re-derived from `b` (read under the lock) below,
3074
+ // since `batch.pr` could in principle have changed between the snapshot
3075
+ // above and the lock acquiring (e.g. a future `sched attach-pr`, #824).
3076
+ let effectivePr = null;
3077
+ let prWasRecorded = false;
2958
3078
  deps.store.withLock((s) => {
2959
3079
  const b = (0, state_1.findBatch)(s, batch.id);
2960
3080
  // Re-check under the lock: `sched resume --batch` (a passing recheck)
@@ -2962,7 +3082,19 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2962
3082
  // snapshot this pass started from.
2963
3083
  if (!b || b.status !== 'blocked')
2964
3084
  return { state: s, result: undefined };
2965
- let n = (0, state_1.transitionBatch)(s, batch.id, 'merged', {}, now);
3085
+ // #789: record the auto-detected PR as part of the SAME transition that
3086
+ // leaves `blocked` — a batch whose ledger never had one now does. Only
3087
+ // when `b.pr` is STILL null under the lock — never overwrite a PR
3088
+ // something else recorded between the snapshot and here.
3089
+ prWasRecorded = detectedPr !== null && b.pr === null;
3090
+ effectivePr = prWasRecorded ? detectedPr : b.pr;
3091
+ let n = (0, state_1.transitionBatch)(s, batch.id, 'merged', {
3092
+ // The ambiguity streak (this tick's own, or a stale one) is moot
3093
+ // the instant the batch reconciles by any evidence — fold the
3094
+ // clear into this same transition instead of a separate lock write.
3095
+ ...state_1.CLEARED_PR_DETECT_AMBIGUOUS_FIELDS,
3096
+ ...(prWasRecorded ? { pr: detectedPr } : {}),
3097
+ }, now);
2966
3098
  n = (0, state_1.transitionBatch)(n, batch.id, 'deployed', {}, now);
2967
3099
  unwalkable = [];
2968
3100
  for (const issue of b.members) {
@@ -2986,7 +3118,11 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2986
3118
  entry = (0, state_1.findEntry)(n, issue);
2987
3119
  }
2988
3120
  }
2989
- if (finishInline) {
3121
+ // The batch now carries a PR — recorded before, recorded just above,
3122
+ // or still null — only when one exists does the ordinary `deployed`
3123
+ // machinery have anything to hand the report agent. A members-closed
3124
+ // reconcile has no merged batch PR to report on regardless.
3125
+ if (effectivePr === null || evidence.kind === 'members-closed') {
2990
3126
  // No (merged) PR → no report agent is possible (its prompt names the
2991
3127
  // PR). Finish the terminal rail and let teardown run below.
2992
3128
  n = (0, state_1.transitionBatch)(n, batch.id, 'reported', {}, now);
@@ -2997,14 +3133,19 @@ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2997
3133
  });
2998
3134
  if (!reconciled)
2999
3135
  continue;
3136
+ const finishInline = effectivePr === null || evidence.kind === 'members-closed';
3000
3137
  journalEvent(deps, 'stale-failure-reconciled', unit(batch.id), {
3001
- pr: batch.pr ?? undefined,
3138
+ pr: effectivePr ?? undefined,
3002
3139
  reason: blockedReason,
3003
3140
  failedAt,
3004
3141
  evidence: evidence.kind,
3005
3142
  ...(evidence.kind === 'pr-merged' ? { mergedAt: evidence.mergedAt } : {}),
3143
+ ...(prWasRecorded ? { pr_detected: true } : {}),
3006
3144
  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`
3145
+ ? (prWasRecorded
3146
+ ? `batch.pr was never recorded — detected PR #${evidence.pr} MERGED with head=${batch.branch} (gh pr list --state merged), recorded batch.pr=${evidence.pr}`
3147
+ : `PR #${evidence.pr} is MERGED`) +
3148
+ ` — ledger reconciled blocked (${blockedReason}) to the merged rail (blocked at ${failedAt}); report and teardown will now dispatch`
3008
3149
  : evidence.kind === 'members-closed'
3009
3150
  ? `every member issue is CLOSED as completed on GitHub with no failure trail — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`
3010
3151
  : `every member commit is an ancestor of ${batch.base_branch} — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`) +
@@ -3221,12 +3362,7 @@ function recordAnchorClosed(deps, batchId, now) {
3221
3362
  * stale streak's count. A no-op (no write) when no streak is recorded.
3222
3363
  */
3223
3364
  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
- }));
3365
+ clearBatchDedupMarker(deps, batch, batch.anchor_close_failed_reason, state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS, now);
3230
3366
  }
3231
3367
  /**
3232
3368
  * A close that qualified but failed: journal it on the streak's first tick and
@@ -3234,11 +3370,8 @@ function clearAnchorCloseFailed(deps, batch, now) {
3234
3370
  * exactly `reconcilePrWatch`'s `pr-watch-failed` dedup.
3235
3371
  */
3236
3372
  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) {
3373
+ const { since, ticks, announce } = advanceBatchDedupStreak(batch.anchor_close_failed_reason, batch.anchor_close_failed_since, batch.anchor_close_failed_ticks, outcome, now);
3374
+ if (announce) {
3242
3375
  journalEvent(deps, 'anchor-close-failed', unit(batch.id), {
3243
3376
  issue: batch.anchor ?? undefined,
3244
3377
  reason: outcome,