1515// FAIL CLOSED: an unreachable/failed live-state fetch is treated as stale (aborts), never as "no evidence of
1616// staleness, so proceed" -- mirrors this package's fail-closed convention elsewhere (harness-submission-
1717// trigger.js's predicted_gate_unavailable/slop_assessment_unavailable, iterate-loop.ts's ambiguous-on-error).
18+ // That fail-closed OUTCOME is unchanged; a single transient blip just gets a bounded retry-with-backoff to
19+ // resolve itself FIRST (#7089) -- the same window claim-conflict-resolver.js's resolveClaimConflict (#6058)
20+ // already gives its own call to this identical fetchLiveIssueSnapshot, reusing http-retry.js's shared backoff.
21+ // Aborting here discards a fully-completed create/iterate loop's local work, so riding out a brief 5xx /
22+ // GraphQL-index propagation lag before failing closed matters more here than in the post-submission case.
1823//
1924// NOT a rejection outcome: staleness is caught BEFORE any PR exists, so it is not the same lifecycle event as
2025// rejection-state-machine.js's DISENGAGED_OUTCOME (which handles an EXISTING PR a maintainer closed). "No PR,
2126// no noisy failure" here just means: return a quiet not-fresh result, same shape as any other blocked gate
2227// decision in this package -- never throw, never surface anything to the target repo.
2328
29+ import { defaultRetryBackoffMs } from "./http-retry.js" ;
30+
2431export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort" ;
2532
33+ // Bounded retry for the pre-submission live-state fetch (#7089), mirroring claim-conflict-resolver.js's
34+ // resolveClaimConflict (#6058): a few attempts with exponential backoff let a transient GitHub blur (a brief
35+ // 5xx, or GraphQL-index propagation lag) resolve itself before we fail closed, without an unbounded loop.
36+ const DEFAULT_SNAPSHOT_MAX_ATTEMPTS = 3 ;
37+ const defaultSnapshotSleep = ( delayMs ) => new Promise ( ( resolve ) => setTimeout ( resolve , delayMs ) ) ;
38+
2639/**
2740 * Evaluate whether a submission candidate's live repo state is still fresh enough to proceed toward open_pr.
2841 * Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the
@@ -34,8 +47,14 @@ export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort";
3447 * fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<{ state: "open"|"closed", referencingPrs: Array<{ number: number, state: "open"|"closed"|"merged", authorLogin: string }> } | null>,
3548 * eventLedger: { appendEvent(event: { type: string, repoFullName?: string, payload: Record<string, unknown> }): unknown },
3649 * }} deps
50+ * @param {{ maxAttempts?: number, sleepFn?: (ms: number) => Promise<unknown>, backoffMs?: (attempt: number) => number } } [options]
51+ * Bounded retry for the live-state snapshot fetch (#7089): up to `maxAttempts` (default 3) attempts with
52+ * `backoffMs(attempt)` backoff between them, returning as soon as a real (non-null, well-formed) snapshot is
53+ * obtained. Optional -- every existing caller works unchanged. Pure over the injected `sleepFn`/`backoffMs`
54+ * -- no real timers in tests. Only the fetch itself is retried; a well-formed snapshot's own signals
55+ * (issue_closed / already_addressed) are decided once, never retried.
3756 */
38- export async function checkSubmissionFreshness ( candidate , deps ) {
57+ export async function checkSubmissionFreshness ( candidate , deps , options = { } ) {
3958 if ( ! candidate || typeof candidate !== "object" ) throw new Error ( "invalid_freshness_candidate" ) ;
4059 const repoFullName = typeof candidate . repoFullName === "string" ? candidate . repoFullName . trim ( ) : "" ;
4160 if ( ! repoFullName ) throw new Error ( "invalid_repo_full_name" ) ;
@@ -49,18 +68,34 @@ export async function checkSubmissionFreshness(candidate, deps) {
4968 if ( typeof fetchLiveIssueSnapshot !== "function" ) throw new Error ( "invalid_live_state_fetcher" ) ;
5069 if ( ! eventLedger || typeof eventLedger . appendEvent !== "function" ) throw new Error ( "invalid_event_ledger" ) ;
5170
71+ const maxAttempts =
72+ Number . isFinite ( options . maxAttempts ) && options . maxAttempts >= 1 ? Math . floor ( options . maxAttempts ) : DEFAULT_SNAPSHOT_MAX_ATTEMPTS ;
73+ const sleepFn = typeof options . sleepFn === "function" ? options . sleepFn : defaultSnapshotSleep ;
74+ const backoffMs = typeof options . backoffMs === "function" ? options . backoffMs : defaultRetryBackoffMs ;
75+
5276 const claim = claimLedger . listClaims ( { repoFullName } ) . find ( ( c ) => c . issueNumber === candidate . issueNumber ) ;
5377 if ( ! claim || claim . status !== "active" ) {
5478 return abort ( eventLedger , repoFullName , candidate . issueNumber , "claim_superseded" ) ;
5579 }
5680
57- let snapshot ;
58- try {
59- snapshot = await fetchLiveIssueSnapshot ( repoFullName , candidate . issueNumber ) ;
60- } catch {
61- snapshot = null ;
81+ let snapshot = null ;
82+ for ( let attempt = 1 ; attempt <= maxAttempts ; attempt += 1 ) {
83+ let current ;
84+ try {
85+ current = await fetchLiveIssueSnapshot ( repoFullName , candidate . issueNumber ) ;
86+ } catch {
87+ current = null ;
88+ }
89+ if ( current && typeof current === "object" ) {
90+ // A real, well-formed snapshot resolves the transient window: stop retrying and decide on it now.
91+ snapshot = current ;
92+ break ;
93+ }
94+ // Back off before the next attempt (transient 5xx / index-propagation lag); never after the last one.
95+ if ( attempt < maxAttempts ) await sleepFn ( backoffMs ( attempt ) ) ;
6296 }
63- if ( ! snapshot || typeof snapshot !== "object" ) {
97+ if ( ! snapshot ) {
98+ // Retry budget exhausted with no usable snapshot -- fail closed exactly as before (#7089 only widens the window).
6499 return abort ( eventLedger , repoFullName , candidate . issueNumber , "live_state_unavailable" ) ;
65100 }
66101
0 commit comments