@ai-dossier/sched 0.45.0 → 0.46.1

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 (48) hide show
  1. package/README.md +67 -5
  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 +8 -0
  7. package/dist/batch-dispatch.d.ts.map +1 -1
  8. package/dist/batch-dispatch.js +215 -14
  9. package/dist/batch-dispatch.js.map +1 -1
  10. package/dist/dispatch.d.ts +11 -1
  11. package/dist/dispatch.d.ts.map +1 -1
  12. package/dist/dispatch.js +25 -4
  13. package/dist/dispatch.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 +102 -0
  19. package/dist/groundtruth.d.ts.map +1 -1
  20. package/dist/groundtruth.js +162 -3
  21. package/dist/groundtruth.js.map +1 -1
  22. package/dist/index.d.ts +4 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +26 -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.js +1 -1
  35. package/dist/recovery.js.map +1 -1
  36. package/dist/state.d.ts +12 -0
  37. package/dist/state.d.ts.map +1 -1
  38. package/dist/state.js +39 -4
  39. package/dist/state.js.map +1 -1
  40. package/dist/status.d.ts +16 -1
  41. package/dist/status.d.ts.map +1 -1
  42. package/dist/status.js +10 -1
  43. package/dist/status.js.map +1 -1
  44. package/dist/types.d.ts +25 -2
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js +5 -1
  47. package/dist/types.js.map +1 -1
  48. package/package.json +1 -1
@@ -122,6 +122,14 @@ export interface BatchDispatchDeps {
122
122
  * Undefined defers to `appendSchedRunLog`'s own `os.homedir()` default.
123
123
  */
124
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;
125
133
  }
126
134
  /** What one `runBatchTick` call did, merged into `engine.ts`'s `TickResult` by the caller. */
127
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");
@@ -2172,7 +2173,8 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
2172
2173
  // the old `reason=` here is what made the journal misdirect, naming a
2173
2174
  // precondition that had already been fixed on a run where it no longer
2174
2175
  // applied.
2175
- const rawReason = blockedNow ? milestone?.keys.reason : undefined;
2176
+ // #804: `memberBlockedReason` also names a `review partial` hand-back.
2177
+ const rawReason = blockedNow && milestone ? (0, groundtruth_1.memberBlockedReason)(milestone) : undefined;
2176
2178
  const reason = typeof rawReason === 'string' && rawReason.length > 0
2177
2179
  ? sanitizeUntrustedText(rawReason)
2178
2180
  : dead
@@ -2635,9 +2637,10 @@ const DISPATCHED_MEMBER_STATUSES = new Set([
2635
2637
  * of merge evidence ("The batch branch is in `main` at c277ff59b", `pr=None`):
2636
2638
  * the branch was merged out of band, so its commits are IN the base even
2637
2639
  * though the batch never shipped a PR of its own. Two probes against the
2638
- * remote-tracking ref (refreshed best-effort first — a merge that landed on
2639
- * the remote is invisible to `rev-list` until fetched; fetch failure leaves
2640
- * the stale ref, the same degradation the per-issue path tolerates):
2640
+ * remote-tracking ref (refreshed first — a merge that landed on the remote is
2641
+ * invisible to `rev-list` until fetched; since #768 a FAILED fetch yields no
2642
+ * evidence at all: a stale ref is not proof, and this answer can reconcile a
2643
+ * batch to shipped):
2641
2644
  *
2642
2645
  * - `git rev-list --count origin/<base>..<branch>` is 0 — nothing of the
2643
2646
  * branch is missing from the base, i.e. every member commit on it is an
@@ -2658,7 +2661,8 @@ function branchMergedIntoBase(deps, batch) {
2658
2661
  if (!attribution_1.SAFE_REF_RE.test(batch.branch) || !attribution_1.SAFE_REF_RE.test(batch.base_branch))
2659
2662
  return false;
2660
2663
  const base = `origin/${batch.base_branch}`;
2661
- deps.exec('git', ['fetch', 'origin', batch.base_branch], deps.repoDir);
2664
+ if (deps.exec('git', ['fetch', 'origin', batch.base_branch], deps.repoDir) === null)
2665
+ return false;
2662
2666
  const ahead = deps.exec('git', ['rev-list', '--count', `${base}..${batch.branch}`], deps.repoDir);
2663
2667
  if ((ahead ?? '').trim() !== '0')
2664
2668
  return false;
@@ -2700,7 +2704,7 @@ function branchMergedIntoBase(deps, batch) {
2700
2704
  * one journal line fires on the one-way transition, never per-tick (the
2701
2705
  * #632 lesson: no journal inside a loop that doesn't terminate the unit).
2702
2706
  */
2703
- function reconcileStaleBlockedBatches(deps, now, result) {
2707
+ function reconcileStaleBlockedBatches(deps, now, result, anchorCtx) {
2704
2708
  const state = deps.store.load();
2705
2709
  const nowMs = now.getTime();
2706
2710
  for (const batch of state.batches) {
@@ -2720,8 +2724,31 @@ function reconcileStaleBlockedBatches(deps, now, result) {
2720
2724
  if (evidence === null && branchMergedIntoBase(deps, batch)) {
2721
2725
  evidence = { kind: 'commits-in-base' };
2722
2726
  }
2727
+ // #768: the evidence that survives a rebase-merge (which rewrites SHAs, so
2728
+ // the ancestry probe above can never pass) and a member shipped through a
2729
+ // PR of its own (nothing batch-shaped ever merges): every member issue is
2730
+ // CLOSED as completed BY CODE IN THE BASE (a merged PR / reachable commit —
2731
+ // never a bare hand close) with no failure trail — the SAME strict
2732
+ // predicate the anchor close uses, so the two never disagree.
2733
+ if (evidence === null &&
2734
+ anchorCtx !== undefined &&
2735
+ (0, anchor_close_1.membersShippedVerdict)(state, batch, anchorCtx.read, {
2736
+ repo: anchorCtx.repo,
2737
+ commitInBase: anchorCtx.commitInBase,
2738
+ }).kind === 'closable') {
2739
+ evidence = { kind: 'members-closed' };
2740
+ }
2723
2741
  if (evidence === null)
2724
2742
  continue;
2743
+ // A members-closed reconcile has no merged batch PR for a report agent to
2744
+ // report on, whatever `batch.pr` recorded — finish the rail inline, as
2745
+ // for `pr === null`.
2746
+ const finishInline = batch.pr === null || evidence.kind === 'members-closed';
2747
+ // ...but never tear its worktree down: the members shipped OUTSIDE the
2748
+ // batch branch, so the blocked integration worktree may hold an
2749
+ // operator's unpushed repair work. It is left in place, inert, and the
2750
+ // journal says so.
2751
+ const keepWorktree = evidence.kind === 'members-closed';
2725
2752
  const blockedReason = batch.blocked_reason ?? 'blocked';
2726
2753
  const failedAt = batch.updated_at;
2727
2754
  let reconciled = false;
@@ -2757,9 +2784,9 @@ function reconcileStaleBlockedBatches(deps, now, result) {
2757
2784
  entry = (0, state_1.findEntry)(n, issue);
2758
2785
  }
2759
2786
  }
2760
- if (b.pr === null) {
2761
- // No PR → no report agent is possible (its prompt names the PR).
2762
- // Finish the terminal rail and let teardown run below.
2787
+ if (finishInline) {
2788
+ // No (merged) PR → no report agent is possible (its prompt names the
2789
+ // PR). Finish the terminal rail and let teardown run below.
2763
2790
  n = (0, state_1.transitionBatch)(n, batch.id, 'reported', {}, now);
2764
2791
  n = (0, state_1.transitionBatch)(n, batch.id, 'done', {}, now);
2765
2792
  }
@@ -2776,20 +2803,192 @@ function reconcileStaleBlockedBatches(deps, now, result) {
2776
2803
  ...(evidence.kind === 'pr-merged' ? { mergedAt: evidence.mergedAt } : {}),
2777
2804
  detail: (evidence.kind === 'pr-merged'
2778
2805
  ? `PR #${evidence.pr} is MERGED — ledger reconciled blocked (${blockedReason}) to the merged rail (blocked at ${failedAt}); report and teardown will now dispatch`
2779
- : `every member commit is an ancestor of ${batch.base_branch} — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`) +
2780
- (batch.pr === null
2781
- ? ' — no PR of its own, so no report agent can be prompted; teardown dispatched inline'
2806
+ : evidence.kind === 'members-closed'
2807
+ ? `every member issue is CLOSED as completed on GitHub with no failure trail — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`
2808
+ : `every member commit is an ancestor of ${batch.base_branch} — ledger reconciled blocked (${blockedReason}) to shipped (blocked at ${failedAt})`) +
2809
+ (finishInline
2810
+ ? ' — no merged PR of its own, so no report agent can be prompted; ' +
2811
+ (keepWorktree
2812
+ ? `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)`
2813
+ : 'teardown dispatched inline')
2782
2814
  : '') +
2783
2815
  (unwalkable.length > 0
2784
2816
  ? `; members NOT walked (no legal edge): ${unwalkable.join(',')}`
2785
2817
  : ''),
2786
2818
  });
2787
2819
  result.mergeAccepted.push(unit(batch.id));
2788
- if (batch.pr === null) {
2820
+ if (finishInline && !keepWorktree) {
2789
2821
  teardownBatch(deps, batch.id);
2790
2822
  }
2791
2823
  }
2792
2824
  }
2825
+ function anchorTickContext(deps) {
2826
+ const reader = (0, groundtruth_1.issueCloseReader)(deps.groundTruth);
2827
+ if (reader === undefined || deps.anchorRepo === undefined)
2828
+ return undefined;
2829
+ const cache = new Map();
2830
+ const read = (issue) => {
2831
+ if (!cache.has(issue))
2832
+ cache.set(issue, reader(issue));
2833
+ return cache.get(issue);
2834
+ };
2835
+ const fetched = new Map();
2836
+ const commitInBase = (oid, baseBranch) => {
2837
+ // CWE-88: both are interpolated into git argv — the oid comes from GitHub.
2838
+ if (!groundtruth_1.GIT_OID_RE.test(oid) || !attribution_1.SAFE_REF_RE.test(baseBranch))
2839
+ return false;
2840
+ if (!fetched.has(baseBranch)) {
2841
+ fetched.set(baseBranch, deps.exec('git', ['fetch', 'origin', baseBranch], deps.repoDir) !== null);
2842
+ }
2843
+ // A failed fetch leaves a possibly stale `origin/<base>` (a force-push may
2844
+ // have dropped the commit since): unverifiable, so never evidence.
2845
+ if (fetched.get(baseBranch) !== true)
2846
+ return false;
2847
+ // `--is-ancestor` exits 0 (non-null) iff reachable; 1 and any failure → null.
2848
+ return (deps.exec('git', ['merge-base', '--is-ancestor', oid, `origin/${baseBranch}`], deps.repoDir) !== null);
2849
+ };
2850
+ return { repo: deps.anchorRepo, read, readFresh: reader, commitInBase };
2851
+ }
2852
+ /**
2853
+ * #768: close a batch's anchor issue when — and ONLY when — the positive
2854
+ * evidence holds: every member CLOSED as completed by code that landed in the
2855
+ * base, and no member evicted, handed back, requeued, or failed
2856
+ * (`anchor-close.ts`). Before this, the anchor closed only on the happy path
2857
+ * (batch-integrate's #720 step), so a blocked or hand-recovered batch — or one
2858
+ * whose members shipped through PRs of their own — left it open forever
2859
+ * (imboard#4244, imboard#4253).
2860
+ *
2861
+ * Every other shape is left strictly alone: a blocked, abandoned, or
2862
+ * partially shipped batch keeps its anchor open, and `sched status --anchors`
2863
+ * shows it as `needs-operator`. Closing an anchor on a terminal transition
2864
+ * alone was rejected on #768 — it would bury the failure trail.
2865
+ *
2866
+ * Bounded: only `blocked`/`done` batches touched within
2867
+ * `STALE_BLOCKED_RECONCILE_WINDOW_MS` whose anchor is not yet recorded closed
2868
+ * are examined (an older one is only ever surfaced, never auto-closed); the
2869
+ * ledger check runs first and costs no GitHub call; member reads stop at the
2870
+ * first disqualifier. When this pass reads an anchor and finds it closed it
2871
+ * records `anchor_closed_at` and never polls it again. A batch the ledger
2872
+ * rules out is never read here at all, so its `anchor_closed_at` stays null —
2873
+ * the sweep reads (but never closes) those anchors when asked.
2874
+ */
2875
+ function reconcileAnchorClosure(deps, now, ctx) {
2876
+ if (ctx === undefined)
2877
+ return;
2878
+ const state = deps.store.load();
2879
+ for (const batch of (0, anchor_close_1.openAnchorBatches)(state, anchor_close_1.ANCHOR_CLOSE_BATCH_STATUSES)) {
2880
+ if (now.getTime() - Date.parse(batch.updated_at) >= STALE_BLOCKED_RECONCILE_WINDOW_MS) {
2881
+ continue;
2882
+ }
2883
+ // Deliberately BEFORE classifyAnchor (which repeats it): a ledger-ruled-out
2884
+ // batch must cost no GitHub read, and a blocked one is re-checked every tick.
2885
+ if ((0, anchor_close_1.anchorLedgerBlockers)(state, batch).length > 0)
2886
+ continue;
2887
+ const verdict = (0, anchor_close_1.classifyAnchor)(state, batch, ctx.read, {
2888
+ repo: ctx.repo,
2889
+ commitInBase: ctx.commitInBase,
2890
+ });
2891
+ if (verdict.kind === 'anchor-closed') {
2892
+ // Closed outside this pass — or by a prior pass that crashed before
2893
+ // recording it; either way the trail gets one line.
2894
+ recordAnchorClosed(deps, batch.id, now);
2895
+ journalEvent(deps, 'anchor-closed', unit(batch.id), {
2896
+ issue: batch.anchor,
2897
+ detail: `anchor #${batch.anchor} found already closed — recorded, never polled again`,
2898
+ });
2899
+ continue;
2900
+ }
2901
+ if (verdict.kind !== 'closable') {
2902
+ clearAnchorCloseFailed(deps, batch, now);
2903
+ continue;
2904
+ }
2905
+ // The GitHub reads above took time: re-check the ledger on a FRESH load,
2906
+ // immediately before the irreversible close, so an eviction, `abandon` or
2907
+ // `stop` that landed meanwhile is never closed over.
2908
+ const stillClean = deps.store.withLock((s) => {
2909
+ const fresh = (0, state_1.findBatch)(s, batch.id);
2910
+ const ok = fresh !== undefined &&
2911
+ fresh.anchor_closed_at === null &&
2912
+ anchor_close_1.ANCHOR_CLOSE_BATCH_STATUSES.has(fresh.status) &&
2913
+ (0, anchor_close_1.anchorLedgerBlockers)(s, fresh).length === 0;
2914
+ return { state: s, result: ok };
2915
+ });
2916
+ // ...and GitHub itself, uncached: a member reopened or the anchor handed
2917
+ // back since the (cached) verdict read must stop the close too.
2918
+ const freshVerdict = stillClean
2919
+ ? (0, anchor_close_1.classifyAnchor)(state, batch, ctx.readFresh, {
2920
+ repo: ctx.repo,
2921
+ commitInBase: ctx.commitInBase,
2922
+ })
2923
+ : undefined;
2924
+ if (freshVerdict?.kind !== 'closable') {
2925
+ clearAnchorCloseFailed(deps, batch, now);
2926
+ continue;
2927
+ }
2928
+ const outcome = (0, anchor_close_1.closeAnchor)(deps.exec, deps.repoDir, ctx.repo, batch, batch.anchor, verdict.members);
2929
+ const shipped = verdict.members.map((m) => `#${m.issue}: ${m.shipped_by}`).join('; ');
2930
+ if (outcome !== 'closed') {
2931
+ recordAnchorCloseFailed(deps, batch, outcome, shipped, now);
2932
+ continue;
2933
+ }
2934
+ // Journal before recording: a crash between the two leaves the line, and
2935
+ // the next tick records the (already-closed) anchor.
2936
+ journalEvent(deps, 'anchor-closed', unit(batch.id), {
2937
+ issue: batch.anchor,
2938
+ detail: `every member closed as completed by code in ${batch.base_branch}, no failure trail — ${shipped}`,
2939
+ });
2940
+ recordAnchorClosed(deps, batch.id, now);
2941
+ }
2942
+ }
2943
+ function recordAnchorClosed(deps, batchId, now) {
2944
+ deps.store.withLock((s) => ({
2945
+ // Bookkeeping, not activity — `updated_at` is the blocked-window clock.
2946
+ state: (0, state_1.patchBatch)(s, batchId, { anchor_closed_at: now.toISOString(), ...state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS }, now, false),
2947
+ result: undefined,
2948
+ }));
2949
+ }
2950
+ /**
2951
+ * The anchor stopped qualifying: end any `anchor-close-failed` streak, so the
2952
+ * next failure episode journals its own first line instead of continuing a
2953
+ * stale streak's count. A no-op (no write) when no streak is recorded.
2954
+ */
2955
+ function clearAnchorCloseFailed(deps, batch, now) {
2956
+ if (batch.anchor_close_failed_reason === null)
2957
+ return;
2958
+ deps.store.withLock((s) => ({
2959
+ state: (0, state_1.patchBatch)(s, batch.id, state_1.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS, now, false),
2960
+ result: undefined,
2961
+ }));
2962
+ }
2963
+ /**
2964
+ * A close that qualified but failed: journal it on the streak's first tick and
2965
+ * every `JOURNAL_DEDUP_REANNOUNCE_TICKS` after — never every tick (#632) —
2966
+ * exactly `reconcilePrWatch`'s `pr-watch-failed` dedup.
2967
+ */
2968
+ function recordAnchorCloseFailed(deps, batch, outcome, shipped, now) {
2969
+ const priorSince = batch.anchor_close_failed_since;
2970
+ const isNewStreak = batch.anchor_close_failed_reason !== outcome || priorSince === null;
2971
+ const ticks = isNewStreak ? 1 : batch.anchor_close_failed_ticks + 1;
2972
+ const since = isNewStreak || priorSince === null ? now.toISOString() : priorSince;
2973
+ if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
2974
+ journalEvent(deps, 'anchor-close-failed', unit(batch.id), {
2975
+ issue: batch.anchor ?? undefined,
2976
+ reason: outcome,
2977
+ at: now.toISOString(),
2978
+ since,
2979
+ ticks_persisted: ticks,
2980
+ detail: `anchor #${batch.anchor} qualifies for close (${shipped}) but ${outcome}; retried every tick — the gh error is on the engine's stderr ('⚠ sched …')`,
2981
+ });
2982
+ }
2983
+ deps.store.withLock((s) => ({
2984
+ state: (0, state_1.patchBatch)(s, batch.id, {
2985
+ anchor_close_failed_reason: outcome,
2986
+ anchor_close_failed_since: since,
2987
+ anchor_close_failed_ticks: ticks,
2988
+ }, now, false),
2989
+ result: undefined,
2990
+ }));
2991
+ }
2793
2992
  /**
2794
2993
  * Remove the batch's shared worktree — called both on the happy path
2795
2994
  * (`reconcileReportSlot`, after `batch-report done`) and on every dissolve
@@ -3090,7 +3289,9 @@ function runBatchTick(deps, config, dispatch) {
3090
3289
  }
3091
3290
  }
3092
3291
  reconcilePrWatch(deps, now, result);
3093
- reconcileStaleBlockedBatches(deps, now, result);
3292
+ const anchorCtx = anchorTickContext(deps);
3293
+ reconcileStaleBlockedBatches(deps, now, result, anchorCtx);
3294
+ reconcileAnchorClosure(deps, now, anchorCtx);
3094
3295
  return result;
3095
3296
  }
3096
3297
  //# sourceMappingURL=batch-dispatch.js.map