@ai-dossier/sched 0.57.7 → 0.58.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.
package/README.md CHANGED
@@ -1606,14 +1606,32 @@ predicate in `anchor-close.ts`) over `blocked`/`done` batches touched within the
1606
1606
  issue's author can close their own issue. One more path counts for a hand close: a
1607
1607
  PR of the project's repository, MERGED into the base, that GitHub lists as closing
1608
1608
  the issue (`closedByPullRequestsReferences` — `Closes #N` parsed but not acted on,
1609
- the imboard#4116 shape); that still needs a merge, i.e. write access. That reference must
1610
- also have merged AFTER the member's last reopen (#799): one merged before it did not finish
1611
- the issue — that is why it was reopened — so it reads `member-closed-by-hand-ref-pr-<n>-predates-reopen`
1612
- (or `…-merge-time-unreadable`), and a reopen timeline that cannot be read reads
1613
- `member-closed-by-hand-reopen-unreadable` — needs-operator either way, never closable. A member
1614
- never reopened is unaffected. Remedy: a new PR or commit that closes the member, or close the
1615
- anchor by hand. A member issue that no longer resolves
1616
- (deleted/transferred) reads `member-missing` — needs-operator, not an outage.
1609
+ the imboard#4116 shape); that still needs a PR merged by someone with write access — though
1610
+ not necessarily by whoever wrote its `Closes #N`. That reference must also have merged inside
1611
+ the member's last open stretch, on every hand close: strictly AFTER the stretch began — the
1612
+ last reopen (#799; one merged before it did not finish the issue, that is why it was
1613
+ reopened: `member-closed-by-hand-ref-pr-<n>-predates-reopen`), or, never reopened, the
1614
+ issue's creation (#850; a PR merged before the issue existed can only have been linked by an
1615
+ edit made after the fact: `…-predates-issue`) — and no later than its close (#850; one merged
1616
+ after the close did not back it: `…-postdates-close`). The bounds are on when the PR merged,
1617
+ not on when its `Closes #N` was written: a reference edited into a PR that merged inside the
1618
+ stretch still vouches (#850 chose the time bound over reading when the link was made). A
1619
+ reopen, creation or close time that cannot be read refuses outright, however long the
1620
+ reference list (`member-closed-by-hand-reopen-unreadable`, `…-created-time-unreadable`,
1621
+ `…-close-time-unreadable`); a reference whose merge time cannot be read never vouches
1622
+ (`…-merge-time-unreadable`). A refusal names only the PRs failing the first bound that
1623
+ applies — merge time unreadable, then merged after the close, then merged before the
1624
+ stretch — so a mixed list does not name every rejected PR. The reference list is read one
1625
+ page (100) deep: a longer list whose first page holds no reference that vouches reads
1626
+ `unknown` (`member-closed-by-hand-refs-truncated`, #850), not a refusal — one may be on a
1627
+ later page — unless another member already makes the anchor needs-operator (the truncated
1628
+ member is then named among its reasons). It does not clear on a re-read: list every reference
1629
+ with `gh api graphql --paginate -f query='query($endCursor:String){repository(owner:"<owner>",name:"<name>"){issue(number:<n>){closedByPullRequestsReferences(first:100,after:$endCursor,includeClosedPrs:true){pageInfo{hasNextPage endCursor} nodes{number merged mergedAt baseRefName}}}}}'`
1630
+ and close the anchor by hand if one merged into the base inside the stretch. Remedy for a
1631
+ refusal: a new PR or commit that closes the member, or close the anchor by hand — reopening
1632
+ and re-closing the member does not help (the reference then predates the reopen). A member
1633
+ issue that no longer resolves (deleted/transferred) reads `member-missing` — needs-operator,
1634
+ not an outage.
1617
1635
  - **Never over a failure trail.** Any evicted member, any member in a failure status
1618
1636
  (`ISSUE_UNIVERSAL_FAILURE_EDGES` + `evicted`/`handed-back`/`requeued`), any member requeued out of
1619
1637
  the batch, a dissolved/stopped batch, or a `decision-pending` label on the anchor or a
@@ -1691,7 +1709,8 @@ predicate in `anchor-close.ts`) over `blocked`/`done` batches touched within the
1691
1709
  Everything else is surfaced, never closed: `sched status --anchors` (opt-in; `status`
1692
1710
  makes no GitHub call without it) lists each still-open anchor of a batch no longer in
1693
1711
  flight as `closable`, `needs-operator` or `unknown`, with every member's GitHub and
1694
- ledger state, stopping its reads at the first failed one. A blocked batch milestone now
1712
+ ledger state, stopping its reads at the first failed one (a truncated reference list's
1713
+ `unknown` is not a failed read and stops nothing, #850). A blocked batch milestone now
1695
1714
  reads `next=operator`, not `next=done`.
1696
1715
 
1697
1716
  Journal: `anchor-closed` (including "found already closed") and `anchor-close-failed`
@@ -21,7 +21,7 @@
21
21
  * Pure verdicts plus one idempotent effect (`closeAnchor`); everything that
22
22
  * talks to GitHub or git is injected, so tests never shell out.
23
23
  */
24
- import type { IssueCloseTruth } from './groundtruth';
24
+ import { type IssueCloseTruth } from './groundtruth';
25
25
  import type { ExecFn } from './project';
26
26
  import type { BatchEntry, BatchStatus, IssueStatus, SchedState } from './types';
27
27
  /** Reads one issue's closure record; `undefined` = the poll failed (unreachable). */
@@ -66,19 +66,49 @@ export interface AnchorMemberReport {
66
66
  /**
67
67
  * The anchor-close predicate's answer. Every open-anchor verdict carries
68
68
  * `reasons` (empty for `closable`; for `unknown`, which read failed — ground
69
- * truth unreachable, so nothing is decided this pass).
69
+ * truth unreachable — or, #850, which member GitHub answered for without
70
+ * settling it; either way nothing is decided this pass).
70
71
  */
71
72
  export type AnchorVerdict = {
72
73
  kind: 'anchor-closed';
73
74
  } | {
74
- kind: 'closable' | 'needs-operator' | 'unknown';
75
+ kind: 'closable' | 'needs-operator';
75
76
  reasons: string[];
76
77
  members: AnchorMemberReport[];
78
+ } | {
79
+ kind: 'unknown';
80
+ reasons: string[];
81
+ members: AnchorMemberReport[];
82
+ /**
83
+ * #850: `true` when a FAILED read produced it (ground truth unreachable
84
+ * — a sweep stops reading); `false` when GitHub answered but could not
85
+ * settle a member (a truncated closing-reference list — a sweep reads
86
+ * on). Required, so no `unknown` can leave the question open.
87
+ */
88
+ unreachable: boolean;
77
89
  };
78
90
  /** The verdict of an anchor that is still open. */
79
91
  export type OpenAnchorVerdict = Exclude<AnchorVerdict, {
80
92
  kind: 'anchor-closed';
81
93
  }>;
94
+ /**
95
+ * {@link shippingEvidence}'s answer: `shipped` (by what), `refused` (why), or —
96
+ * #850 — `unknown` (why GitHub's answer could not settle it).
97
+ */
98
+ export type ShippingEvidence = {
99
+ shipped: string;
100
+ } | {
101
+ refused: string;
102
+ } | {
103
+ unknown: string;
104
+ };
105
+ /**
106
+ * #850: the evidence reason for a hand close whose closing-reference list is
107
+ * longer than the page read and holds no vouching reference on it — `unknown`,
108
+ * never a refusal. A re-read does not clear it (the README's anchor section
109
+ * gives the operator's remedy).
110
+ */
111
+ export declare const REFS_TRUNCATED_REASON = "closed-by-hand-refs-truncated";
82
112
  /**
83
113
  * The verified shipping evidence for a member closed as completed, or the
84
114
  * reason it is not evidence. Only code that landed in `baseBranch` counts:
@@ -87,13 +117,11 @@ export type OpenAnchorVerdict = Exclude<AnchorVerdict, {
87
117
  * ({@link closingReferenceEvidence}) — or a commit reachable from it. A hand
88
118
  * close with no qualifying reference, an unmerged or other-base PR, and an
89
119
  * unverifiable commit are all refusals — anyone who can close the issue could
90
- * otherwise mint "shipped".
120
+ * otherwise mint "shipped". `unknown` (#850) is neither: GitHub answered, but
121
+ * only with the first page of a longer closing-reference list, none of which
122
+ * vouches — so a refusal could be wrong, and nothing is decided.
91
123
  */
92
- export declare function shippingEvidence(truth: IssueCloseTruth, baseBranch: string, repo: string | undefined, commitInBase: CommitInBase | undefined): {
93
- shipped: string;
94
- } | {
95
- refused: string;
96
- };
124
+ export declare function shippingEvidence(truth: IssueCloseTruth, baseBranch: string, repo: string | undefined, commitInBase: CommitInBase | undefined): ShippingEvidence;
97
125
  export interface MembersVerdictOptions {
98
126
  /** Read every member even after a disqualifier (the sweep); the engine stops early. */
99
127
  exhaustive?: boolean;
@@ -121,9 +149,10 @@ export type OrphanShippingCheckOptions = ShippingCheckOptions & {
121
149
  * members-closed evidence, so the two can never disagree about what
122
150
  * "shipped" means.
123
151
  *
124
- * `exhaustive: false` (the engine) stops at the first disqualifying fact and
125
- * skips GitHub entirely when the ledger already disqualifies — a batch that
126
- * stays blocked is re-checked every tick, so the cheap answer matters there.
152
+ * `exhaustive: false` (the engine) stops at the first disqualifying fact or
153
+ * undecided member (#850) and skips GitHub entirely when the ledger already
154
+ * disqualifies — a batch that stays blocked is re-checked every tick, so the
155
+ * cheap answer matters there.
127
156
  * `exhaustive: true` (the `sched status --anchors` sweep) reads every member
128
157
  * so the operator sees each one's state. Either way a failed read stops the
129
158
  * reads: GitHub being unreachable is not worth N more timeouts.
@@ -171,10 +200,11 @@ export interface AnchorReportItem {
171
200
  * `sched status --anchors`' sweep (#768): every still-open anchor of a batch
172
201
  * that is no longer in flight, marked `closable` (the strict condition holds —
173
202
  * the engine will close it, within its 7-day window), `needs-operator`
174
- * (anything else), or `unknown` (a GitHub read failed — nothing decided),
175
- * with each member's state. Report-only by construction: it takes no exec for
176
- * writes and has no way to close anything. The first failed read stops the
177
- * sweep's reads; every later batch is reported `unknown` without a call.
203
+ * (anything else), or `unknown` (a GitHub read failed, or — #850 — a member's
204
+ * closing-reference list was truncated; nothing decided), with each member's
205
+ * state. Report-only by construction: it takes no exec for writes and has no
206
+ * way to close anything. The first FAILED read stops the sweep's reads; every
207
+ * later batch is reported `unknown` without a call.
178
208
  */
179
209
  export declare function sweepAnchors(state: SchedState, read: IssueCloseReader, opts?: ShippingCheckOptions): AnchorReportItem[];
180
210
  /**
@@ -245,9 +275,15 @@ export interface OrphanAnchorCandidate {
245
275
  * confirm, not the ledger-backed `sweepAnchors`' stronger `closable`.
246
276
  */
247
277
  export type OrphanAnchorVerdict = {
248
- kind: 'orphan-closable-candidate' | 'orphan-needs-operator' | 'orphan-unknown';
278
+ kind: 'orphan-closable-candidate' | 'orphan-needs-operator';
279
+ reasons: string[];
280
+ members: AnchorMemberReport[];
281
+ } | {
282
+ kind: 'orphan-unknown';
249
283
  reasons: string[];
250
284
  members: AnchorMemberReport[];
285
+ /** #850: as on {@link AnchorVerdict}'s `unknown` — `true` only when a FAILED read produced it. */
286
+ unreachable: boolean;
251
287
  };
252
288
  /**
253
289
  * Classify one orphan anchor candidate, or `null` when it turns out not to be
@@ -326,7 +362,8 @@ export interface OrphanAnchorSweepResult {
326
362
  * left real orphan candidates unprobed. Classifying stops at the first
327
363
  * failed member/anchor read (the same fail-closed posture as
328
364
  * `sweepAnchors`): every anchor after that point is reported `orphan-unknown`
329
- * without a further call.
365
+ * without a further call. An `orphan-unknown` from a truncated
366
+ * closing-reference list (#850) is not a failed read and stops nothing.
330
367
  */
331
368
  export declare function sweepOrphanAnchors(state: SchedState, list: OpenAnchorLister, read: IssueCloseReader, opts?: OrphanShippingCheckOptions): OrphanAnchorSweepResult | null;
332
369
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"anchor-close.d.ts","sourceRoot":"","sources":["../src/anchor-close.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"anchor-close.d.ts","sourceRoot":"","sources":["../src/anchor-close.ts"],"names":[],"mappings":"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"}
@@ -23,7 +23,7 @@
23
23
  * talks to GitHub or git is injected, so tests never shell out.
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.ORPHAN_SWEEP_MAX_ANCHORS = exports.ORPHAN_SWEEP_MAX_MEMBERS = exports.DEFAULT_BASE_BRANCH = exports.ANCHOR_CLOSE_BATCH_STATUSES = void 0;
26
+ exports.ORPHAN_SWEEP_MAX_ANCHORS = exports.ORPHAN_SWEEP_MAX_MEMBERS = exports.DEFAULT_BASE_BRANCH = exports.REFS_TRUNCATED_REASON = exports.ANCHOR_CLOSE_BATCH_STATUSES = void 0;
27
27
  exports.openAnchorBatches = openAnchorBatches;
28
28
  exports.formatBatchStatus = formatBatchStatus;
29
29
  exports.anchorLedgerBlockers = anchorLedgerBlockers;
@@ -40,6 +40,7 @@ exports.orphanAnchorListArgs = orphanAnchorListArgs;
40
40
  exports.sweepOrphanAnchors = sweepOrphanAnchors;
41
41
  exports.batchAnchorStillOpen = batchAnchorStillOpen;
42
42
  const attribution_1 = require("./attribution");
43
+ const groundtruth_1 = require("./groundtruth");
43
44
  const labels_1 = require("./labels");
44
45
  const state_1 = require("./state");
45
46
  /**
@@ -118,6 +119,13 @@ function anchorLedgerBlockers(state, batch) {
118
119
  }
119
120
  return [...new Set(reasons)];
120
121
  }
122
+ /**
123
+ * #850: the evidence reason for a hand close whose closing-reference list is
124
+ * longer than the page read and holds no vouching reference on it — `unknown`,
125
+ * never a refusal. A re-read does not clear it (the README's anchor section
126
+ * gives the operator's remedy).
127
+ */
128
+ exports.REFS_TRUNCATED_REASON = 'closed-by-hand-refs-truncated';
121
129
  /**
122
130
  * The verified shipping evidence for a member closed as completed, or the
123
131
  * reason it is not evidence. Only code that landed in `baseBranch` counts:
@@ -126,7 +134,9 @@ function anchorLedgerBlockers(state, batch) {
126
134
  * ({@link closingReferenceEvidence}) — or a commit reachable from it. A hand
127
135
  * close with no qualifying reference, an unmerged or other-base PR, and an
128
136
  * unverifiable commit are all refusals — anyone who can close the issue could
129
- * otherwise mint "shipped".
137
+ * otherwise mint "shipped". `unknown` (#850) is neither: GitHub answered, but
138
+ * only with the first page of a longer closing-reference list, none of which
139
+ * vouches — so a refusal could be wrong, and nothing is decided.
130
140
  */
131
141
  function shippingEvidence(truth, baseBranch, repo, commitInBase) {
132
142
  const closer = truth.closer;
@@ -154,37 +164,96 @@ function shippingEvidence(truth, baseBranch, repo, commitInBase) {
154
164
  * A hand close (no closer on the close event) is still shipped when a PR of
155
165
  * the pinned repo, MERGED into the base, names the issue as closed
156
166
  * (`Closes #N` — GitHub parsed it, then did not act: the imboard#4116 shape).
157
- * A merge needs write access, so an author's self-close alone never counts.
167
+ * Only a merged PR counts, and a merge needs write access, so an author's
168
+ * self-close alone never counts.
169
+ *
170
+ * The reference must also have merged inside the member's last open stretch
171
+ * ({@link refBound}): strictly AFTER its start — the last reopen (#799; one
172
+ * merged before it did not finish the issue, that is why it was reopened), or
173
+ * the issue's creation when it was never reopened (#850 review; a PR merged
174
+ * before the issue existed can only have been linked by an edit made after
175
+ * the fact) — and no later than its close (#850; one merged after the close
176
+ * did not back it). Every bound applies to every hand close; a time that
177
+ * cannot be read is not evidence either way: fail closed. An unreadable
178
+ * reopen, creation or close time refuses outright; otherwise the refusal
179
+ * names only the PRs failing the first bound that applies — merge time
180
+ * unreadable, then merged after the close, then merged before the stretch
181
+ * began — so a mixed list does not name every rejected PR. The remedy is a
182
+ * new PR or commit that closes the member, or an operator closing the anchor
183
+ * by hand; reopening and re-closing the member does not help (the reference
184
+ * then predates the reopen).
185
+ *
186
+ * The bounds are on when the PR MERGED, not on when its `Closes #N` was
187
+ * written: a reference edited into a PR that merged inside the stretch still
188
+ * vouches (#850 chose the time bound over reading when the link was made).
158
189
  *
159
- * #799: the reference must also have merged AFTER the member's last reopen.
160
- * One merged before it did not finish the issue — that is why it was
161
- * reopened — so it cannot vouch for the later hand close. An unreadable
162
- * reopen time, or an unreadable merge time after a reopen, is not evidence
163
- * either way: fail closed, naming the rejected PRs so the operator can see
164
- * which reference was stale. The remedy is a new PR or commit that closes
165
- * the member, or an operator closing the anchor by hand.
190
+ * #850: `closingPrs` is one page. With more on the next
191
+ * (`closingPrsTruncated`), no vouching reference on this page is `unknown`
192
+ * ({@link REFS_TRUNCATED_REASON}), not a refusal — one may be on the next page.
193
+ * A definite answer still stands: a reference on this page that vouches is
194
+ * shipped, and an unreadable reopen, creation or close time refuses whatever
195
+ * the next page holds.
166
196
  */
167
197
  function closingReferenceEvidence(truth, baseBranch, repo) {
168
198
  const refs = truth.closingPrs.filter((pr) => pr.merged &&
169
199
  pr.baseRefName === baseBranch &&
170
200
  repo !== undefined &&
171
201
  pr.repo?.toLowerCase() === repo.toLowerCase());
172
- if (refs.length === 0)
202
+ const truncated = truth.closingPrsTruncated === true;
203
+ if (refs.length === 0 && !truncated)
173
204
  return { refused: 'closed-by-hand' };
174
- const reopenedAt = truth.lastReopenedAt;
175
- if (reopenedAt === undefined)
205
+ // `undefined` (an unreadable reopen timeline) parses to NaN, like garbage.
206
+ const reopened = truth.lastReopenedAt !== null;
207
+ const reopenedMs = reopened ? (0, groundtruth_1.timestampMs)(truth.lastReopenedAt) : Number.NEGATIVE_INFINITY;
208
+ if (Number.isNaN(reopenedMs))
176
209
  return { refused: 'closed-by-hand-reopen-unreadable' };
177
- const ref = reopenedAt === null
178
- ? refs[0]
179
- : refs.find((pr) => pr.mergedAt !== null && Date.parse(pr.mergedAt) > Date.parse(reopenedAt));
180
- if (ref !== undefined) {
181
- return { shipped: `PR #${ref.number} (closing reference; issue closed by hand)` };
210
+ const createdMs = (0, groundtruth_1.timestampMs)(truth.createdAt);
211
+ if (Number.isNaN(createdMs))
212
+ return { refused: 'closed-by-hand-created-time-unreadable' };
213
+ const closedMs = (0, groundtruth_1.timestampMs)(truth.closedAt);
214
+ if (Number.isNaN(closedMs))
215
+ return { refused: 'closed-by-hand-close-time-unreadable' };
216
+ const stretch = { openedMs: Math.max(createdMs, reopenedMs), closedMs };
217
+ const bounds = refs.map((pr) => ({ pr, bound: refBound(pr, stretch) }));
218
+ const vouching = bounds.find((b) => b.bound === 'vouches');
219
+ if (vouching !== undefined) {
220
+ return { shipped: `PR #${vouching.pr.number} (closing reference; issue closed by hand)` };
182
221
  }
183
- const prs = (list) => list.map((pr) => pr.number).join('+');
184
- const untimed = refs.filter((pr) => pr.mergedAt === null);
185
- return untimed.length > 0
186
- ? { refused: `closed-by-hand-ref-pr-${prs(untimed)}-merge-time-unreadable` }
187
- : { refused: `closed-by-hand-ref-pr-${prs(refs)}-predates-reopen` };
222
+ if (truncated)
223
+ return { unknown: exports.REFS_TRUNCATED_REASON };
224
+ // `refs` is non-empty here and none vouches, so some refusal bound applies;
225
+ // the `??` only satisfies the type checker.
226
+ const failed = REFUSAL_BOUND_ORDER.find((o) => bounds.some((b) => b.bound === o)) ?? 'predates-open';
227
+ const prs = bounds
228
+ .filter((b) => b.bound === failed)
229
+ .map((b) => b.pr.number)
230
+ .join('+');
231
+ const why = failed !== 'predates-open'
232
+ ? failed
233
+ : reopenedMs > createdMs
234
+ ? 'predates-reopen'
235
+ : 'predates-issue';
236
+ return { refused: `closed-by-hand-ref-pr-${prs}-${why}` };
237
+ }
238
+ /** The refusal bounds in the order {@link closingReferenceEvidence} names them. */
239
+ const REFUSAL_BOUND_ORDER = [
240
+ 'merge-time-unreadable',
241
+ 'postdates-close',
242
+ 'predates-open',
243
+ ];
244
+ /**
245
+ * Whether `pr` merged inside the stretch `(openedMs, closedMs]`, or which
246
+ * bound it fails. The merge time goes through the ground truth's one
247
+ * timestamp rule ({@link timestampMs}), so a missing or loose time can never
248
+ * pass a bound — even from an `IssueCloseTruth` the parser did not build.
249
+ */
250
+ function refBound(pr, stretch) {
251
+ const mergedMs = (0, groundtruth_1.timestampMs)(pr.mergedAt);
252
+ if (Number.isNaN(mergedMs))
253
+ return 'merge-time-unreadable';
254
+ if (mergedMs > stretch.closedMs)
255
+ return 'postdates-close';
256
+ return mergedMs > stretch.openedMs ? 'vouches' : 'predates-open';
188
257
  }
189
258
  /**
190
259
  * The blank `AnchorMemberReport` skeleton shared by {@link membersShippedVerdict}'s
@@ -214,7 +283,8 @@ function unreadMemberReports(members, ledgerStatus) {
214
283
  * can reuse the identical shipping-evidence logic for a batch that, by
215
284
  * definition, has no ledger entry to consult.
216
285
  *
217
- * `exhaustive: false` stops at the first disqualifying fact; `exhaustive:
286
+ * `exhaustive: false` stops at the first disqualifying fact — or undecided
287
+ * member (#850): either way the batch is not closable this pass; `exhaustive:
218
288
  * true` reads every member so the operator sees each one's state. Either way
219
289
  * a failed read stops the reads: GitHub being unreachable is not worth N
220
290
  * more timeouts.
@@ -222,11 +292,12 @@ function unreadMemberReports(members, ledgerStatus) {
222
292
  function readMembersShipping(members, read, baseBranch, opts, ledgerStatus) {
223
293
  const reports = unreadMemberReports(members, ledgerStatus);
224
294
  const reasons = [];
225
- let unreachable = null;
295
+ const undecided = [];
296
+ let unreachableIssue = null;
226
297
  for (const member of reports) {
227
298
  const truth = read(member.issue);
228
299
  if (truth === undefined) {
229
- unreachable = member.issue;
300
+ unreachableIssue = member.issue;
230
301
  break;
231
302
  }
232
303
  if (truth.state === 'MISSING') {
@@ -248,16 +319,18 @@ function readMembersShipping(members, read, baseBranch, opts, ledgerStatus) {
248
319
  const evidence = shippingEvidence(truth, baseBranch, opts.repo, opts.commitInBase);
249
320
  if ('shipped' in evidence)
250
321
  member.shipped_by = evidence.shipped;
251
- else
322
+ else if ('refused' in evidence)
252
323
  reasons.push(`member-${evidence.refused}:#${member.issue}`);
324
+ else
325
+ undecided.push(`member-${evidence.unknown}:#${member.issue}`);
253
326
  }
254
327
  if ((0, labels_1.hasLabel)(truth.labels, labels_1.DECISION_PENDING_LABEL)) {
255
328
  reasons.push(`member-handed-back:#${member.issue}`);
256
329
  }
257
- if (reasons.length > 0 && !opts.exhaustive)
330
+ if ((reasons.length > 0 || undecided.length > 0) && !opts.exhaustive)
258
331
  break;
259
332
  }
260
- return { reasons, members: reports, unreachable };
333
+ return { reasons, undecided, members: reports, unreachableIssue };
261
334
  }
262
335
  /**
263
336
  * The member half of the predicate: every member CLOSED as `COMPLETED` by
@@ -266,9 +339,10 @@ function readMembersShipping(members, read, baseBranch, opts, ledgerStatus) {
266
339
  * members-closed evidence, so the two can never disagree about what
267
340
  * "shipped" means.
268
341
  *
269
- * `exhaustive: false` (the engine) stops at the first disqualifying fact and
270
- * skips GitHub entirely when the ledger already disqualifies — a batch that
271
- * stays blocked is re-checked every tick, so the cheap answer matters there.
342
+ * `exhaustive: false` (the engine) stops at the first disqualifying fact or
343
+ * undecided member (#850) and skips GitHub entirely when the ledger already
344
+ * disqualifies — a batch that stays blocked is re-checked every tick, so the
345
+ * cheap answer matters there.
272
346
  * `exhaustive: true` (the `sched status --anchors` sweep) reads every member
273
347
  * so the operator sees each one's state. Either way a failed read stops the
274
348
  * reads: GitHub being unreachable is not worth N more timeouts.
@@ -285,8 +359,9 @@ function membersShippedVerdict(state, batch, read, opts = {}) {
285
359
  };
286
360
  }
287
361
  const gh = readMembersShipping(batch.members, read, batch.base_branch, { exhaustive, repo: opts.repo, commitInBase: opts.commitInBase }, ledgerStatus);
288
- // A known disqualifier outranks an unreachable read: whatever the missing
289
- // poll would have said, the batch is not closable on this pass.
362
+ // A known disqualifier outranks an unreachable read or an undecided member
363
+ // (#850): whatever the missing poll or the unread reference page would have
364
+ // said, the batch is not closable on this pass.
290
365
  return foldMembersVerdict(ledgerReasons, gh, {
291
366
  needsOperator: 'needs-operator',
292
367
  unknown: 'unknown',
@@ -298,23 +373,39 @@ function membersShippedVerdict(state, batch, read, opts = {}) {
298
373
  * {@link classifyOrphanAnchor} (#830): both fold `prefixReasons` (reasons
299
374
  * already known before {@link readMembersShipping} ran — ledger blockers for
300
375
  * one, the anchor's own handed-back check for the other) together with `gh`'s
301
- * per-member reasons and unreachable-read signal into the SAME three-way
302
- * verdict shape — differing only in which kind-name vocabulary each caller
303
- * uses (`needs-operator`/`unknown`/`closable` vs the ledger-less orphan
304
- * sweep's own `orphan-*` names, kept deliberately distinct — see
305
- * {@link OrphanAnchorVerdict}).
376
+ * per-member reasons, undecided members and unreachable-read signal into the
377
+ * SAME three-way verdict shape — differing only in which kind-name vocabulary
378
+ * each caller uses (`needs-operator`/`unknown`/`closable` vs the ledger-less
379
+ * orphan sweep's own `orphan-*` names, kept deliberately distinct — see
380
+ * {@link OrphanAnchorVerdict}). A known disqualifier outranks both kinds of
381
+ * `unknown`, but what could not be settled still rides along after it — each
382
+ * undecided member, then a failed read — so an exhaustive sweep's row names
383
+ * every member it could not settle. Only an `unknown` a failed read produced
384
+ * is `unreachable` (#850); a needs-operator verdict never is, so a sweep
385
+ * reads on past it (the #790 ruling).
306
386
  */
307
387
  function foldMembersVerdict(prefixReasons, gh, kinds) {
308
388
  const reasons = [...prefixReasons, ...gh.reasons];
309
- if (reasons.length > 0)
310
- return { kind: kinds.needsOperator, reasons, members: gh.members };
311
- if (gh.unreachable !== null) {
389
+ const failedRead = gh.unreachableIssue === null ? [] : [`issue #${gh.unreachableIssue} unreachable`];
390
+ if (reasons.length > 0) {
391
+ return {
392
+ kind: kinds.needsOperator,
393
+ reasons: [...reasons, ...gh.undecided, ...failedRead],
394
+ members: gh.members,
395
+ };
396
+ }
397
+ if (failedRead.length > 0) {
312
398
  return {
313
399
  kind: kinds.unknown,
314
- reasons: [`issue #${gh.unreachable} unreachable`],
400
+ reasons: [...gh.undecided, ...failedRead],
315
401
  members: gh.members,
402
+ unreachable: true,
316
403
  };
317
404
  }
405
+ // #850: every read answered, but not every answer settled its member.
406
+ if (gh.undecided.length > 0) {
407
+ return { kind: kinds.unknown, reasons: gh.undecided, members: gh.members, unreachable: false };
408
+ }
318
409
  return { kind: kinds.closable, reasons: [], members: gh.members };
319
410
  }
320
411
  function anchorGroundVerdict(read, anchorIssue) {
@@ -343,8 +434,9 @@ function classifyAnchor(state, batch, read, opts = {}) {
343
434
  if (ground.kind === 'anchor-missing') {
344
435
  return { kind: 'needs-operator', reasons: ['anchor-missing'], members: [] };
345
436
  }
346
- if (ground.kind === 'unknown')
347
- return { kind: 'unknown', reasons: ground.reasons, members: [] };
437
+ if (ground.kind === 'unknown') {
438
+ return { kind: 'unknown', reasons: ground.reasons, members: [], unreachable: true };
439
+ }
348
440
  const extraReasons = ground.handedBack ? ['anchor-handed-back'] : [];
349
441
  return membersShippedVerdict(state, batch, read, { ...opts, extraReasons });
350
442
  }
@@ -416,24 +508,28 @@ function closeAnchor(exec, repoDir, repo, batch, anchorIssue, members) {
416
508
  ? 'close-failed'
417
509
  : 'closed';
418
510
  }
511
+ /** The reason on every candidate a sweep reports without reading, after a failed read. */
512
+ const SWEEP_ABORTED_REASON = 'GitHub unreachable — sweep aborted';
419
513
  /**
420
514
  * The fail-closed sweep loop shared by {@link sweepAnchors} and
421
515
  * {@link sweepOrphanAnchors} (#830): classify each candidate in order via
422
516
  * `classify`, which returns `null` to SKIP a candidate entirely (both sweeps
423
517
  * hit this on the same list-then-read race — the anchor closed between the
424
- * list and this read). Once a classified verdict's `kind` is `unknownKind`,
425
- * every LATER candidate is reported `abortedVerdict` WITHOUT calling
518
+ * list and this read). Once a classified verdict is `unreachable` (a read
519
+ * failed), every LATER candidate is reported `abortedVerdict` WITHOUT calling
426
520
  * `classify` again — GitHub being unreachable is not worth N more timeouts,
427
- * and a partial outage must never look like "the rest are clean".
521
+ * and a partial outage must never look like "the rest are clean". An
522
+ * `unknown` GitHub answered but could not settle (#850) is no outage: the
523
+ * sweep reads on, so it never reports later batches as unreachable.
428
524
  */
429
- function sweepFailClosed(candidates, unknownKind, abortedVerdict, classify, toItem) {
525
+ function sweepFailClosed(candidates, abortedVerdict, classify, toItem) {
430
526
  const items = [];
431
527
  let aborted = false;
432
528
  for (const candidate of candidates) {
433
529
  const verdict = aborted ? abortedVerdict : classify(candidate);
434
530
  if (verdict === null)
435
531
  continue;
436
- if (verdict.kind === unknownKind)
532
+ if (verdict.unreachable === true)
437
533
  aborted = true;
438
534
  items.push(toItem(candidate, verdict));
439
535
  }
@@ -443,13 +539,14 @@ function sweepFailClosed(candidates, unknownKind, abortedVerdict, classify, toIt
443
539
  * `sched status --anchors`' sweep (#768): every still-open anchor of a batch
444
540
  * that is no longer in flight, marked `closable` (the strict condition holds —
445
541
  * the engine will close it, within its 7-day window), `needs-operator`
446
- * (anything else), or `unknown` (a GitHub read failed — nothing decided),
447
- * with each member's state. Report-only by construction: it takes no exec for
448
- * writes and has no way to close anything. The first failed read stops the
449
- * sweep's reads; every later batch is reported `unknown` without a call.
542
+ * (anything else), or `unknown` (a GitHub read failed, or — #850 — a member's
543
+ * closing-reference list was truncated; nothing decided), with each member's
544
+ * state. Report-only by construction: it takes no exec for writes and has no
545
+ * way to close anything. The first FAILED read stops the sweep's reads; every
546
+ * later batch is reported `unknown` without a call.
450
547
  */
451
548
  function sweepAnchors(state, read, opts = {}) {
452
- return sweepFailClosed(openAnchorBatches(state, SWEEP_BATCH_STATUSES), 'unknown', { kind: 'unknown', reasons: ['GitHub unreachable — sweep aborted'], members: [] }, (batch) => {
549
+ return sweepFailClosed(openAnchorBatches(state, SWEEP_BATCH_STATUSES), { kind: 'unknown', reasons: [SWEEP_ABORTED_REASON], members: [], unreachable: true }, (batch) => {
453
550
  const verdict = classifyAnchor(state, batch, read, { exhaustive: true, ...opts });
454
551
  return verdict.kind === 'anchor-closed' ? null : verdict;
455
552
  }, (batch, verdict) => ({
@@ -569,7 +666,7 @@ function classifyOrphanAnchor(candidate, read, opts = {}) {
569
666
  return { kind: 'orphan-needs-operator', reasons: ['anchor-missing'], members: [] };
570
667
  }
571
668
  if (ground.kind === 'unknown') {
572
- return { kind: 'orphan-unknown', reasons: ground.reasons, members: [] };
669
+ return { kind: 'orphan-unknown', reasons: ground.reasons, members: [], unreachable: true };
573
670
  }
574
671
  if (candidate.members.length === 0) {
575
672
  return { kind: 'orphan-needs-operator', reasons: ['no-members-recovered'], members: [] };
@@ -651,7 +748,8 @@ exports.ORPHAN_SWEEP_MAX_ANCHORS = 20;
651
748
  * left real orphan candidates unprobed. Classifying stops at the first
652
749
  * failed member/anchor read (the same fail-closed posture as
653
750
  * `sweepAnchors`): every anchor after that point is reported `orphan-unknown`
654
- * without a further call.
751
+ * without a further call. An `orphan-unknown` from a truncated
752
+ * closing-reference list (#850) is not a failed read and stops nothing.
655
753
  */
656
754
  function sweepOrphanAnchors(state, list, read, opts = {}) {
657
755
  const anchors = list();
@@ -660,7 +758,7 @@ function sweepOrphanAnchors(state, list, read, opts = {}) {
660
758
  const ledgerAnchors = new Set(state.batches.map((b) => b.anchor).filter((a) => a !== null));
661
759
  const orphanCandidates = anchors.filter((issue) => !ledgerAnchors.has(issue.number));
662
760
  const truncated = orphanCandidates.length > exports.ORPHAN_SWEEP_MAX_ANCHORS;
663
- const items = sweepFailClosed(orphanCandidates.slice(0, exports.ORPHAN_SWEEP_MAX_ANCHORS), 'orphan-unknown', { kind: 'orphan-unknown', reasons: ['GitHub unreachable — sweep aborted'], members: [] }, (issue) => {
761
+ const items = sweepFailClosed(orphanCandidates.slice(0, exports.ORPHAN_SWEEP_MAX_ANCHORS), { kind: 'orphan-unknown', reasons: [SWEEP_ABORTED_REASON], members: [], unreachable: true }, (issue) => {
664
762
  const { members, base_branch, members_over_cap } = parseOrphanAnchorBody(issue.body);
665
763
  return classifyOrphanAnchor({ anchor: issue.number, members, base_branch, members_over_cap }, read, opts);
666
764
  }, (issue, verdict) => ({