@ai-dossier/sched 0.44.0 → 0.46.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.
Files changed (50) hide show
  1. package/README.md +69 -2
  2. package/dist/anchor-close.d.ts +172 -0
  3. package/dist/anchor-close.d.ts.map +1 -0
  4. package/dist/anchor-close.js +348 -0
  5. package/dist/anchor-close.js.map +1 -0
  6. package/dist/batch-dispatch.d.ts +16 -3
  7. package/dist/batch-dispatch.d.ts.map +1 -1
  8. package/dist/batch-dispatch.js +225 -18
  9. package/dist/batch-dispatch.js.map +1 -1
  10. package/dist/batch-stats.d.ts +23 -0
  11. package/dist/batch-stats.d.ts.map +1 -1
  12. package/dist/batch-stats.js +81 -6
  13. package/dist/batch-stats.js.map +1 -1
  14. package/dist/engine.d.ts +2 -0
  15. package/dist/engine.d.ts.map +1 -1
  16. package/dist/engine.js +1 -0
  17. package/dist/engine.js.map +1 -1
  18. package/dist/groundtruth.d.ts +86 -0
  19. package/dist/groundtruth.d.ts.map +1 -1
  20. package/dist/groundtruth.js +128 -2
  21. package/dist/groundtruth.js.map +1 -1
  22. package/dist/index.d.ts +6 -5
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +27 -5
  25. package/dist/index.js.map +1 -1
  26. package/dist/labels.d.ts +8 -0
  27. package/dist/labels.d.ts.map +1 -1
  28. package/dist/labels.js +14 -2
  29. package/dist/labels.js.map +1 -1
  30. package/dist/project.d.ts +10 -0
  31. package/dist/project.d.ts.map +1 -1
  32. package/dist/project.js +27 -0
  33. package/dist/project.js.map +1 -1
  34. package/dist/recovery.d.ts +11 -0
  35. package/dist/recovery.d.ts.map +1 -1
  36. package/dist/recovery.js +1 -1
  37. package/dist/recovery.js.map +1 -1
  38. package/dist/state.d.ts +12 -0
  39. package/dist/state.d.ts.map +1 -1
  40. package/dist/state.js +39 -4
  41. package/dist/state.js.map +1 -1
  42. package/dist/status.d.ts +16 -1
  43. package/dist/status.d.ts.map +1 -1
  44. package/dist/status.js +10 -1
  45. package/dist/status.js.map +1 -1
  46. package/dist/types.d.ts +26 -3
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/types.js +5 -1
  49. package/dist/types.js.map +1 -1
  50. package/package.json +1 -1
@@ -65,7 +65,7 @@ import { type GroundTruth } from './groundtruth';
65
65
  import { type Journal } from './journal';
66
66
  import type { SchedStore } from './persist';
67
67
  import type { ExecFn } from './project';
68
- import { type SuiteResult } from './recovery';
68
+ import { type BatchSuiteContext, type SuiteResult } from './recovery';
69
69
  import { type FsExists } from './teardown';
70
70
  import type { BatchEntry, CapabilityGateResult, SchedConfig } from './types';
71
71
  export type { CapOutcome } from './types';
@@ -88,8 +88,13 @@ export interface BatchDispatchDeps {
88
88
  * timeout, e.g. `@ai-dossier/worktree-pool`'s `WARM_COMMAND_TIMEOUT_MS`).
89
89
  */
90
90
  warmExec?: ExecFn;
91
- /** Runs the aggregate suite inside a batch worktree; batches never leave `validating` without one. */
92
- runSuite: (worktree: string) => SuiteResult;
91
+ /**
92
+ * Runs the aggregate suite inside a batch worktree; batches never leave
93
+ * `validating` without one. `ctx` (#777) names the batch and its base ref so
94
+ * the runner can hand them to the repo's `gate.batch` capability; optional
95
+ * so runners written before it keep working unchanged.
96
+ */
97
+ runSuite: (worktree: string, ctx?: BatchSuiteContext) => SuiteResult;
93
98
  /**
94
99
  * Runs one `ai-dossier cap run <capabilityId>` in a batch worktree. Two call
95
100
  * sites, different degrade contracts:
@@ -117,6 +122,14 @@ export interface BatchDispatchDeps {
117
122
  * Undefined defers to `appendSchedRunLog`'s own `os.homedir()` default.
118
123
  */
119
124
  homeDir?: string;
125
+ /**
126
+ * #768: the `owner/name` repository whose issues are this project's batch
127
+ * anchors and members, verified against the project by the caller
128
+ * (`resolveProjectRepo`). Without it the anchor close and the members-closed
129
+ * reconcile evidence never fire — they never act on whatever repository the
130
+ * cwd resolves to.
131
+ */
132
+ anchorRepo?: string;
120
133
  }
121
134
  /** What one `runBatchTick` call did, merged into `engine.ts`'s `TickResult` by the caller. */
122
135
  export interface BatchTickResult {
@@ -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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"}
@@ -103,6 +103,7 @@ const fs = __importStar(require("node:fs"));
103
103
  const path = __importStar(require("node:path"));
104
104
  const core_1 = require("@ai-dossier/core");
105
105
  const worktree_pool_1 = require("@ai-dossier/worktree-pool");
106
+ const anchor_close_1 = require("./anchor-close");
106
107
  const attribution_1 = require("./attribution");
107
108
  const dispatch_1 = require("./dispatch");
108
109
  const dispatch_health_1 = require("./dispatch-health");
@@ -356,9 +357,10 @@ function applyBatchAndIssues(fresh, computed, batchId, issues, membersBeforeReco
356
357
  * into `tick()`'s own catch — a bare `tick-failed` with no unit id, repeating
357
358
  * every reconcile interval forever since nothing about the batch changed.
358
359
  */
359
- function safeSuite(deps, batchId, worktree) {
360
+ function safeSuite(deps, batch, worktree) {
361
+ const batchId = batch.id;
360
362
  try {
361
- return deps.runSuite(worktree);
363
+ return deps.runSuite(worktree, batchSuiteContext(batch));
362
364
  }
363
365
  catch (err) {
364
366
  const detail = `suite runner threw: ${err.message}`;
@@ -368,13 +370,19 @@ function safeSuite(deps, batchId, worktree) {
368
370
  return { ok: false, failing: [], readable: false, detail };
369
371
  }
370
372
  }
373
+ /** What the aggregate-suite runner is told about the batch it gates (#777). */
374
+ function batchSuiteContext(batch) {
375
+ return { batchId: batch.id, baseRef: `origin/${batch.base_branch}` };
376
+ }
371
377
  function recoveryDeps(deps, config, batch, now) {
372
378
  return {
373
379
  exec: deps.exec,
374
380
  repoDir: batch.worktree ?? deps.repoDir,
375
381
  journal: deps.journal,
376
382
  postMilestone: (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir }),
377
- runSuite: batch.worktree !== null ? () => deps.runSuite(batch.worktree) : undefined,
383
+ runSuite: batch.worktree !== null
384
+ ? () => deps.runSuite(batch.worktree, batchSuiteContext(batch))
385
+ : undefined,
378
386
  dissolvePolicy: (0, types_1.resolveDissolvePolicy)(config.dissolve_policy),
379
387
  now: () => now,
380
388
  };
@@ -1347,7 +1355,7 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
1347
1355
  const batch = (0, state_1.findBatch)(state, batchId);
1348
1356
  if (!batch || batch.worktree === null)
1349
1357
  return;
1350
- const suite = safeSuite(deps, batchId, batch.worktree);
1358
+ const suite = safeSuite(deps, batch, batch.worktree);
1351
1359
  const rDeps = recoveryDeps(deps, config, batch, now);
1352
1360
  const poster = (0, recovery_1.createExecMilestonePoster)(deps.exec, { repoDir: deps.repoDir });
1353
1361
  if (suite.ok) {
@@ -2341,7 +2349,7 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
2341
2349
  // packages/sched/README.md's dispatch-health section.
2342
2350
  handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchFixLogPath)(deps.store.runsDir, batchId, offenderRecord.issue), slot.log_offset_at_spawn ?? 0, now, { release: false });
2343
2351
  deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
2344
- const suite = safeSuite(deps, batchId, batch.worktree);
2352
+ const suite = safeSuite(deps, batch, batch.worktree);
2345
2353
  const rDeps = recoveryDeps(deps, config, batch, now);
2346
2354
  const { state: resolved } = (0, recovery_1.resolveFixAttempt)(deps.store.load(), batchId, offenderRecord.issue, suite.ok ? 'green' : 'red', rDeps);
2347
2355
  deps.store.withLock((s) => ({
@@ -2628,9 +2636,10 @@ const DISPATCHED_MEMBER_STATUSES = new Set([
2628
2636
  * of merge evidence ("The batch branch is in `main` at c277ff59b", `pr=None`):
2629
2637
  * the branch was merged out of band, so its commits are IN the base even
2630
2638
  * though the batch never shipped a PR of its own. Two probes against the
2631
- * remote-tracking ref (refreshed best-effort first — a merge that landed on
2632
- * the remote is invisible to `rev-list` until fetched; fetch failure leaves
2633
- * the stale ref, the same degradation the per-issue path tolerates):
2639
+ * remote-tracking ref (refreshed first — a merge that landed on the remote is
2640
+ * invisible to `rev-list` until fetched; since #768 a FAILED fetch yields no
2641
+ * evidence at all: a stale ref is not proof, and this answer can reconcile a
2642
+ * batch to shipped):
2634
2643
  *
2635
2644
  * - `git rev-list --count origin/<base>..<branch>` is 0 — nothing of the
2636
2645
  * branch is missing from the base, i.e. every member commit on it is an
@@ -2651,7 +2660,8 @@ function branchMergedIntoBase(deps, batch) {
2651
2660
  if (!attribution_1.SAFE_REF_RE.test(batch.branch) || !attribution_1.SAFE_REF_RE.test(batch.base_branch))
2652
2661
  return false;
2653
2662
  const base = `origin/${batch.base_branch}`;
2654
- deps.exec('git', ['fetch', 'origin', batch.base_branch], deps.repoDir);
2663
+ if (deps.exec('git', ['fetch', 'origin', batch.base_branch], deps.repoDir) === null)
2664
+ return false;
2655
2665
  const ahead = deps.exec('git', ['rev-list', '--count', `${base}..${batch.branch}`], deps.repoDir);
2656
2666
  if ((ahead ?? '').trim() !== '0')
2657
2667
  return false;
@@ -2693,7 +2703,7 @@ function branchMergedIntoBase(deps, batch) {
2693
2703
  * one journal line fires on the one-way transition, never per-tick (the
2694
2704
  * #632 lesson: no journal inside a loop that doesn't terminate the unit).
2695
2705
  */
2696
- function reconcileStaleBlockedBatches(deps, now, result) {
2706
+ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2697
2707
  const state = deps.store.load();
2698
2708
  const nowMs = now.getTime();
2699
2709
  for (const batch of state.batches) {
@@ -2713,8 +2723,31 @@ function reconcileStaleBlockedBatches(deps, now, result) {
2713
2723
  if (evidence === null && branchMergedIntoBase(deps, batch)) {
2714
2724
  evidence = { kind: 'commits-in-base' };
2715
2725
  }
2726
+ // #768: the evidence that survives a rebase-merge (which rewrites SHAs, so
2727
+ // the ancestry probe above can never pass) and a member shipped through a
2728
+ // PR of its own (nothing batch-shaped ever merges): every member issue is
2729
+ // CLOSED as completed BY CODE IN THE BASE (a merged PR / reachable commit —
2730
+ // never a bare hand close) with no failure trail — the SAME strict
2731
+ // predicate the anchor close uses, so the two never disagree.
2732
+ if (evidence === null &&
2733
+ anchorCtx !== undefined &&
2734
+ (0, anchor_close_1.membersShippedVerdict)(state, batch, anchorCtx.read, {
2735
+ repo: anchorCtx.repo,
2736
+ commitInBase: anchorCtx.commitInBase,
2737
+ }).kind === 'closable') {
2738
+ evidence = { kind: 'members-closed' };
2739
+ }
2716
2740
  if (evidence === null)
2717
2741
  continue;
2742
+ // A members-closed reconcile has no merged batch PR for a report agent to
2743
+ // report on, whatever `batch.pr` recorded — finish the rail inline, as
2744
+ // for `pr === null`.
2745
+ const finishInline = batch.pr === null || evidence.kind === 'members-closed';
2746
+ // ...but never tear its worktree down: the members shipped OUTSIDE the
2747
+ // batch branch, so the blocked integration worktree may hold an
2748
+ // operator's unpushed repair work. It is left in place, inert, and the
2749
+ // journal says so.
2750
+ const keepWorktree = evidence.kind === 'members-closed';
2718
2751
  const blockedReason = batch.blocked_reason ?? 'blocked';
2719
2752
  const failedAt = batch.updated_at;
2720
2753
  let reconciled = false;
@@ -2750,9 +2783,9 @@ function reconcileStaleBlockedBatches(deps, now, result) {
2750
2783
  entry = (0, state_1.findEntry)(n, issue);
2751
2784
  }
2752
2785
  }
2753
- if (b.pr === null) {
2754
- // No PR → no report agent is possible (its prompt names the PR).
2755
- // Finish the terminal rail and let teardown run below.
2786
+ if (finishInline) {
2787
+ // No (merged) PR → no report agent is possible (its prompt names the
2788
+ // PR). Finish the terminal rail and let teardown run below.
2756
2789
  n = (0, state_1.transitionBatch)(n, batch.id, 'reported', {}, now);
2757
2790
  n = (0, state_1.transitionBatch)(n, batch.id, 'done', {}, now);
2758
2791
  }
@@ -2769,20 +2802,192 @@ function reconcileStaleBlockedBatches(deps, now, result) {
2769
2802
  ...(evidence.kind === 'pr-merged' ? { mergedAt: evidence.mergedAt } : {}),
2770
2803
  detail: (evidence.kind === 'pr-merged'
2771
2804
  ? `PR #${evidence.pr} is MERGED — ledger reconciled blocked (${blockedReason}) to the merged rail (blocked at ${failedAt}); report and teardown will now dispatch`
2772
- : `every member commit is an ancestor of ${batch.base_branch} — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`) +
2773
- (batch.pr === null
2774
- ? ' — no PR of its own, so no report agent can be prompted; teardown dispatched inline'
2805
+ : evidence.kind === 'members-closed'
2806
+ ? `every member issue is CLOSED as completed on GitHub with no failure trail — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`
2807
+ : `every member commit is an ancestor of ${batch.base_branch} — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`) +
2808
+ (finishInline
2809
+ ? ' — no merged PR of its own, so no report agent can be prompted; ' +
2810
+ (keepWorktree
2811
+ ? `worktree KEPT in place (${batch.worktree ?? 'none'}) — the work shipped outside the batch branch, which may hold unpushed work; once inspected, remove it with \`git worktree remove\` (or \`worktree-pool return\` if pool-claimed)`
2812
+ : 'teardown dispatched inline')
2775
2813
  : '') +
2776
2814
  (unwalkable.length > 0
2777
2815
  ? `; members NOT walked (no legal edge): ${unwalkable.join(',')}`
2778
2816
  : ''),
2779
2817
  });
2780
2818
  result.mergeAccepted.push(unit(batch.id));
2781
- if (batch.pr === null) {
2819
+ if (finishInline && !keepWorktree) {
2782
2820
  teardownBatch(deps, batch.id);
2783
2821
  }
2784
2822
  }
2785
2823
  }
2824
+ function anchorTickContext(deps) {
2825
+ const reader = (0, groundtruth_1.issueCloseReader)(deps.groundTruth);
2826
+ if (reader === undefined || deps.anchorRepo === undefined)
2827
+ return undefined;
2828
+ const cache = new Map();
2829
+ const read = (issue) => {
2830
+ if (!cache.has(issue))
2831
+ cache.set(issue, reader(issue));
2832
+ return cache.get(issue);
2833
+ };
2834
+ const fetched = new Map();
2835
+ const commitInBase = (oid, baseBranch) => {
2836
+ // CWE-88: both are interpolated into git argv — the oid comes from GitHub.
2837
+ if (!groundtruth_1.GIT_OID_RE.test(oid) || !attribution_1.SAFE_REF_RE.test(baseBranch))
2838
+ return false;
2839
+ if (!fetched.has(baseBranch)) {
2840
+ fetched.set(baseBranch, deps.exec('git', ['fetch', 'origin', baseBranch], deps.repoDir) !== null);
2841
+ }
2842
+ // A failed fetch leaves a possibly stale `origin/<base>` (a force-push may
2843
+ // have dropped the commit since): unverifiable, so never evidence.
2844
+ if (fetched.get(baseBranch) !== true)
2845
+ return false;
2846
+ // `--is-ancestor` exits 0 (non-null) iff reachable; 1 and any failure → null.
2847
+ return (deps.exec('git', ['merge-base', '--is-ancestor', oid, `origin/${baseBranch}`], deps.repoDir) !== null);
2848
+ };
2849
+ return { repo: deps.anchorRepo, read, readFresh: reader, commitInBase };
2850
+ }
2851
+ /**
2852
+ * #768: close a batch's anchor issue when — and ONLY when — the positive
2853
+ * evidence holds: every member CLOSED as completed by code that landed in the
2854
+ * base, and no member evicted, handed back, requeued, or failed
2855
+ * (`anchor-close.ts`). Before this, the anchor closed only on the happy path
2856
+ * (batch-integrate's #720 step), so a blocked or hand-recovered batch — or one
2857
+ * whose members shipped through PRs of their own — left it open forever
2858
+ * (imboard#4244, imboard#4253).
2859
+ *
2860
+ * Every other shape is left strictly alone: a blocked, abandoned, or
2861
+ * partially shipped batch keeps its anchor open, and `sched status --anchors`
2862
+ * shows it as `needs-operator`. Closing an anchor on a terminal transition
2863
+ * alone was rejected on #768 — it would bury the failure trail.
2864
+ *
2865
+ * Bounded: only `blocked`/`done` batches touched within
2866
+ * `STALE_BLOCKED_RECONCILE_WINDOW_MS` whose anchor is not yet recorded closed
2867
+ * are examined (an older one is only ever surfaced, never auto-closed); the
2868
+ * ledger check runs first and costs no GitHub call; member reads stop at the
2869
+ * first disqualifier. When this pass reads an anchor and finds it closed it
2870
+ * records `anchor_closed_at` and never polls it again. A batch the ledger
2871
+ * rules out is never read here at all, so its `anchor_closed_at` stays null —
2872
+ * the sweep reads (but never closes) those anchors when asked.
2873
+ */
2874
+ function reconcileAnchorClosure(deps, now, ctx) {
2875
+ if (ctx === undefined)
2876
+ return;
2877
+ const state = deps.store.load();
2878
+ for (const batch of (0, anchor_close_1.openAnchorBatches)(state, anchor_close_1.ANCHOR_CLOSE_BATCH_STATUSES)) {
2879
+ if (now.getTime() - Date.parse(batch.updated_at) >= STALE_BLOCKED_RECONCILE_WINDOW_MS) {
2880
+ continue;
2881
+ }
2882
+ // Deliberately BEFORE classifyAnchor (which repeats it): a ledger-ruled-out
2883
+ // batch must cost no GitHub read, and a blocked one is re-checked every tick.
2884
+ if ((0, anchor_close_1.anchorLedgerBlockers)(state, batch).length > 0)
2885
+ continue;
2886
+ const verdict = (0, anchor_close_1.classifyAnchor)(state, batch, ctx.read, {
2887
+ repo: ctx.repo,
2888
+ commitInBase: ctx.commitInBase,
2889
+ });
2890
+ if (verdict.kind === 'anchor-closed') {
2891
+ // Closed outside this pass — or by a prior pass that crashed before
2892
+ // recording it; either way the trail gets one line.
2893
+ recordAnchorClosed(deps, batch.id, now);
2894
+ journalEvent(deps, 'anchor-closed', unit(batch.id), {
2895
+ issue: batch.anchor,
2896
+ detail: `anchor #${batch.anchor} found already closed — recorded, never polled again`,
2897
+ });
2898
+ continue;
2899
+ }
2900
+ if (verdict.kind !== 'closable') {
2901
+ clearAnchorCloseFailed(deps, batch, now);
2902
+ continue;
2903
+ }
2904
+ // The GitHub reads above took time: re-check the ledger on a FRESH load,
2905
+ // immediately before the irreversible close, so an eviction, `abandon` or
2906
+ // `stop` that landed meanwhile is never closed over.
2907
+ const stillClean = deps.store.withLock((s) => {
2908
+ const fresh = (0, state_1.findBatch)(s, batch.id);
2909
+ const ok = fresh !== undefined &&
2910
+ fresh.anchor_closed_at === null &&
2911
+ anchor_close_1.ANCHOR_CLOSE_BATCH_STATUSES.has(fresh.status) &&
2912
+ (0, anchor_close_1.anchorLedgerBlockers)(s, fresh).length === 0;
2913
+ return { state: s, result: ok };
2914
+ });
2915
+ // ...and GitHub itself, uncached: a member reopened or the anchor handed
2916
+ // back since the (cached) verdict read must stop the close too.
2917
+ const freshVerdict = stillClean
2918
+ ? (0, anchor_close_1.classifyAnchor)(state, batch, ctx.readFresh, {
2919
+ repo: ctx.repo,
2920
+ commitInBase: ctx.commitInBase,
2921
+ })
2922
+ : undefined;
2923
+ if (freshVerdict?.kind !== 'closable') {
2924
+ clearAnchorCloseFailed(deps, batch, now);
2925
+ continue;
2926
+ }
2927
+ const outcome = (0, anchor_close_1.closeAnchor)(deps.exec, deps.repoDir, ctx.repo, batch, batch.anchor, verdict.members);
2928
+ const shipped = verdict.members.map((m) => `#${m.issue}: ${m.shipped_by}`).join('; ');
2929
+ if (outcome !== 'closed') {
2930
+ recordAnchorCloseFailed(deps, batch, outcome, shipped, now);
2931
+ continue;
2932
+ }
2933
+ // Journal before recording: a crash between the two leaves the line, and
2934
+ // the next tick records the (already-closed) anchor.
2935
+ journalEvent(deps, 'anchor-closed', unit(batch.id), {
2936
+ issue: batch.anchor,
2937
+ detail: `every member closed as completed by code in ${batch.base_branch}, no failure trail — ${shipped}`,
2938
+ });
2939
+ recordAnchorClosed(deps, batch.id, now);
2940
+ }
2941
+ }
2942
+ function recordAnchorClosed(deps, batchId, now) {
2943
+ deps.store.withLock((s) => ({
2944
+ // Bookkeeping, not activity — `updated_at` is the blocked-window clock.
2945
+ state: (0, state_1.patchBatch)(s, batchId, { anchor_closed_at: now.toISOString(), ...state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS }, now, false),
2946
+ result: undefined,
2947
+ }));
2948
+ }
2949
+ /**
2950
+ * The anchor stopped qualifying: end any `anchor-close-failed` streak, so the
2951
+ * next failure episode journals its own first line instead of continuing a
2952
+ * stale streak's count. A no-op (no write) when no streak is recorded.
2953
+ */
2954
+ function clearAnchorCloseFailed(deps, batch, now) {
2955
+ if (batch.anchor_close_failed_reason === null)
2956
+ return;
2957
+ deps.store.withLock((s) => ({
2958
+ state: (0, state_1.patchBatch)(s, batch.id, state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS, now, false),
2959
+ result: undefined,
2960
+ }));
2961
+ }
2962
+ /**
2963
+ * A close that qualified but failed: journal it on the streak's first tick and
2964
+ * every `JOURNAL_DEDUP_REANNOUNCE_TICKS` after — never every tick (#632) —
2965
+ * exactly `reconcilePrWatch`'s `pr-watch-failed` dedup.
2966
+ */
2967
+ function recordAnchorCloseFailed(deps, batch, outcome, shipped, now) {
2968
+ const priorSince = batch.anchor_close_failed_since;
2969
+ const isNewStreak = batch.anchor_close_failed_reason !== outcome || priorSince === null;
2970
+ const ticks = isNewStreak ? 1 : batch.anchor_close_failed_ticks + 1;
2971
+ const since = isNewStreak || priorSince === null ? now.toISOString() : priorSince;
2972
+ if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
2973
+ journalEvent(deps, 'anchor-close-failed', unit(batch.id), {
2974
+ issue: batch.anchor ?? undefined,
2975
+ reason: outcome,
2976
+ at: now.toISOString(),
2977
+ since,
2978
+ ticks_persisted: ticks,
2979
+ detail: `anchor #${batch.anchor} qualifies for close (${shipped}) but ${outcome}; retried every tick — the gh error is on the engine's stderr ('⚠ sched …')`,
2980
+ });
2981
+ }
2982
+ deps.store.withLock((s) => ({
2983
+ state: (0, state_1.patchBatch)(s, batch.id, {
2984
+ anchor_close_failed_reason: outcome,
2985
+ anchor_close_failed_since: since,
2986
+ anchor_close_failed_ticks: ticks,
2987
+ }, now, false),
2988
+ result: undefined,
2989
+ }));
2990
+ }
2786
2991
  /**
2787
2992
  * Remove the batch's shared worktree — called both on the happy path
2788
2993
  * (`reconcileReportSlot`, after `batch-report done`) and on every dissolve
@@ -3083,7 +3288,9 @@ function runBatchTick(deps, config, dispatch) {
3083
3288
  }
3084
3289
  }
3085
3290
  reconcilePrWatch(deps, now, result);
3086
- reconcileStaleBlockedBatches(deps, now, result);
3291
+ const anchorCtx = anchorTickContext(deps);
3292
+ reconcileStaleBlockedBatches(deps, now, result, anchorCtx);
3293
+ reconcileAnchorClosure(deps, now, anchorCtx);
3087
3294
  return result;
3088
3295
  }
3089
3296
  //# sourceMappingURL=batch-dispatch.js.map