patchrelay 0.76.0 → 0.78.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/dist/build-info.json +3 -3
- package/dist/db/webhook-event-store.js +22 -0
- package/dist/failure-provenance.js +40 -0
- package/dist/github-webhook-late-publication-guard.js +49 -15
- package/dist/github-webhook-policy.js +36 -25
- package/dist/github-webhook-sequence-backstop.js +45 -2
- package/dist/github-webhook-state-projector.js +5 -12
- package/dist/idle-reconciliation.js +63 -38
- package/dist/pr-facts-derivation.js +81 -0
- package/dist/run-budgets.js +40 -6
- package/dist/run-completion-policy.js +50 -9
- package/dist/run-failure-policy.js +463 -0
- package/dist/run-finalizer.js +23 -22
- package/dist/run-launcher.js +21 -0
- package/dist/run-notification-handler.js +0 -2
- package/dist/run-orchestrator.js +26 -68
- package/dist/run-reconciler.js +34 -32
- package/dist/run-settlement.js +57 -0
- package/dist/service.js +22 -0
- package/package.json +1 -1
- package/dist/interrupted-run-recovery.js +0 -240
- package/dist/run-recovery-service.js +0 -239
- package/dist/zombie-recovery.js +0 -13
package/dist/build-info.json
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows older than this that are still `pending` were abandoned by a crash or
|
|
3
|
+
* restart mid-processing. They are never replayed — recovery is re-derivation
|
|
4
|
+
* from GitHub/Linear via reconciliation — so the startup sweep marks them
|
|
5
|
+
* `abandoned`, which makes them archiveable like any other terminal status.
|
|
6
|
+
*/
|
|
7
|
+
export const ABANDONED_PENDING_WEBHOOK_AGE_MS = 15 * 60 * 1000;
|
|
1
8
|
export class WebhookEventStore {
|
|
2
9
|
connection;
|
|
3
10
|
constructor(connection) {
|
|
@@ -39,6 +46,21 @@ export class WebhookEventStore {
|
|
|
39
46
|
markWebhookProcessed(id, status) {
|
|
40
47
|
this.connection.prepare("UPDATE webhook_events SET processing_status = ? WHERE id = ?").run(status, id);
|
|
41
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Startup maintenance (core simplification plan, phase C2): mark rows stuck
|
|
51
|
+
* at `pending` since before the cutoff as `abandoned` so the retention pass
|
|
52
|
+
* can archive them. Returns the number of rows marked — each one is a
|
|
53
|
+
* crash-interrupted processing attempt worth surfacing to the operator.
|
|
54
|
+
*/
|
|
55
|
+
markAbandonedPendingEventsBefore(cutoffIso) {
|
|
56
|
+
const result = this.connection.prepare(`
|
|
57
|
+
UPDATE webhook_events
|
|
58
|
+
SET processing_status = 'abandoned'
|
|
59
|
+
WHERE processing_status = 'pending'
|
|
60
|
+
AND received_at < ?
|
|
61
|
+
`).run(cutoffIso);
|
|
62
|
+
return Number(result.changes ?? 0);
|
|
63
|
+
}
|
|
42
64
|
assignWebhookProject(id, projectId) {
|
|
43
65
|
this.connection.prepare("UPDATE webhook_events SET project_id = ? WHERE id = ?").run(projectId, id);
|
|
44
66
|
}
|
|
@@ -21,3 +21,43 @@ export const CLEARED_FAILURE_PROVENANCE = {
|
|
|
21
21
|
lastAttemptedFailureSignature: null,
|
|
22
22
|
lastAttemptedFailureAt: null,
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* The single rule for clearing failure provenance (core simplification plan,
|
|
26
|
+
* phase C1): provenance may be cleared only when the observed evidence is
|
|
27
|
+
* NEWER than the recorded failure —
|
|
28
|
+
*
|
|
29
|
+
* 1. the PR merged or closed (nothing left to repair), or
|
|
30
|
+
* 2. the PR's current head differs from `lastGitHubFailureHeadSha` (the
|
|
31
|
+
* failing commit was superseded), or
|
|
32
|
+
* 3. the same kind of check that recorded the failure succeeded on the
|
|
33
|
+
* recorded failure head (the failure was actually fixed):
|
|
34
|
+
* - `queue_eviction` failures require the eviction check itself to
|
|
35
|
+
* succeed — a green *branch* gate proves nothing about integration
|
|
36
|
+
* with main (the swallowed-repair bug), while
|
|
37
|
+
* - `branch_ci` (and unclassified) failures are cleared by a green gate
|
|
38
|
+
* or a green eviction check on the failure head.
|
|
39
|
+
*
|
|
40
|
+
* A poll that merely "looks green" on the same head never clears a queue
|
|
41
|
+
* incident, and a stale check event for an unrelated head never clears
|
|
42
|
+
* anything.
|
|
43
|
+
*/
|
|
44
|
+
export function mayClearFailureProvenance(current, observed) {
|
|
45
|
+
if (observed.prState === "merged" || observed.prState === "closed") {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const failureHeadSha = current.lastGitHubFailureHeadSha;
|
|
49
|
+
if (!failureHeadSha) {
|
|
50
|
+
// Nothing concrete recorded to preserve — clearing is harmless.
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (observed.headSha && observed.headIsCurrentTruth && observed.headSha !== failureHeadSha) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (observed.headSha === failureHeadSha) {
|
|
57
|
+
if (current.lastGitHubFailureSource === "queue_eviction") {
|
|
58
|
+
return observed.evictionCheckSucceeded === true;
|
|
59
|
+
}
|
|
60
|
+
return observed.gateCheckStatus === "success" || observed.evictionCheckSucceeded === true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isTerminalRunStatus } from "./run-settlement.js";
|
|
1
2
|
function isPatchRelayBot(login) {
|
|
2
3
|
return login === "patchrelay[bot]" || login === "app/patchrelay";
|
|
3
4
|
}
|
|
@@ -7,6 +8,25 @@ function parseRepo(repoFullName) {
|
|
|
7
8
|
return undefined;
|
|
8
9
|
return { owner, repo };
|
|
9
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Late-publication guard (core simplification plan, phase C3).
|
|
13
|
+
*
|
|
14
|
+
* Detects a PatchRelay-authored `pr_opened` for an issue with no recorded PR
|
|
15
|
+
* while the latest implementation run is already settled — settleRun owns
|
|
16
|
+
* settlement, so a terminal run can no longer claim this PR as its own
|
|
17
|
+
* publication. Every such PR gets an operator-feed alert.
|
|
18
|
+
*
|
|
19
|
+
* Autonomous action is limited to one case: a run with status `released`,
|
|
20
|
+
* which PatchRelay itself stopped before publication (issue blocked,
|
|
21
|
+
* undelegated, or superseded mid-implementation) — its PR is unwanted by
|
|
22
|
+
* construction and is auto-closed. For any other settled status (`completed`,
|
|
23
|
+
* `failed`, `superseded`) the run may have legitimately published right at
|
|
24
|
+
* the end (the webhook can race settlement), so the PR is linked normally
|
|
25
|
+
* and the operator decides.
|
|
26
|
+
*
|
|
27
|
+
* Returns `true` when the PR was suppressed (closed) and the webhook should
|
|
28
|
+
* not be projected onto the issue.
|
|
29
|
+
*/
|
|
10
30
|
export async function maybeCloseLatePublishedImplementationPr(params) {
|
|
11
31
|
const { db, logger, feed, issue, event, fetchImpl } = params;
|
|
12
32
|
if (event.triggerEvent !== "pr_opened")
|
|
@@ -20,8 +40,32 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
|
|
|
20
40
|
const latestRun = db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
21
41
|
if (!latestRun || latestRun.runType !== "implementation")
|
|
22
42
|
return false;
|
|
23
|
-
|
|
43
|
+
// A non-terminal run (queued/running) is still allowed to publish — this
|
|
44
|
+
// is the normal mid-run PR creation path.
|
|
45
|
+
if (!isTerminalRunStatus(latestRun.status))
|
|
24
46
|
return false;
|
|
47
|
+
// Detection: the implementation run is settled, yet a bot PR just arrived.
|
|
48
|
+
logger.warn({
|
|
49
|
+
issueKey: issue.issueKey,
|
|
50
|
+
prNumber: event.prNumber,
|
|
51
|
+
latestRunId: latestRun.id,
|
|
52
|
+
latestRunStatus: latestRun.status,
|
|
53
|
+
}, "Late PatchRelay PR detected after the implementation run was settled");
|
|
54
|
+
feed?.publish({
|
|
55
|
+
level: "warn",
|
|
56
|
+
kind: "github",
|
|
57
|
+
issueKey: issue.issueKey,
|
|
58
|
+
projectId: issue.projectId,
|
|
59
|
+
stage: issue.factoryState,
|
|
60
|
+
status: "late_pr_detected",
|
|
61
|
+
summary: `Detected late PR #${event.prNumber} from a settled implementation run (${latestRun.status})`,
|
|
62
|
+
detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
|
|
63
|
+
});
|
|
64
|
+
if (latestRun.status !== "released") {
|
|
65
|
+
// The run may have published legitimately just before settling; link the
|
|
66
|
+
// PR and leave the decision to the operator.
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
25
69
|
const repo = parseRepo(event.repoFullName);
|
|
26
70
|
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
|
|
27
71
|
if (!repo || !token) {
|
|
@@ -30,17 +74,7 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
|
|
|
30
74
|
prNumber: event.prNumber,
|
|
31
75
|
latestRunId: latestRun.id,
|
|
32
76
|
latestRunStatus: latestRun.status,
|
|
33
|
-
}, "Late PatchRelay PR
|
|
34
|
-
feed?.publish({
|
|
35
|
-
level: "warn",
|
|
36
|
-
kind: "github",
|
|
37
|
-
issueKey: issue.issueKey,
|
|
38
|
-
projectId: issue.projectId,
|
|
39
|
-
stage: issue.factoryState,
|
|
40
|
-
status: "late_pr_detected",
|
|
41
|
-
summary: `Detected late PR #${event.prNumber} from an inactive implementation run`,
|
|
42
|
-
detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
|
|
43
|
-
});
|
|
77
|
+
}, "Late PatchRelay PR from a released implementation run could not be auto-closed (missing repo or token)");
|
|
44
78
|
return false;
|
|
45
79
|
}
|
|
46
80
|
const response = await fetchImpl(`https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/pulls/${event.prNumber}`, {
|
|
@@ -61,7 +95,7 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
|
|
|
61
95
|
status: response.status,
|
|
62
96
|
latestRunId: latestRun.id,
|
|
63
97
|
latestRunStatus: latestRun.status,
|
|
64
|
-
}, "Failed to auto-close late PatchRelay PR from
|
|
98
|
+
}, "Failed to auto-close late PatchRelay PR from a released implementation run");
|
|
65
99
|
feed?.publish({
|
|
66
100
|
level: "warn",
|
|
67
101
|
kind: "github",
|
|
@@ -79,7 +113,7 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
|
|
|
79
113
|
prNumber: event.prNumber,
|
|
80
114
|
latestRunId: latestRun.id,
|
|
81
115
|
latestRunStatus: latestRun.status,
|
|
82
|
-
}, "Auto-closed late PatchRelay PR from
|
|
116
|
+
}, "Auto-closed late PatchRelay PR from a released implementation run");
|
|
83
117
|
feed?.publish({
|
|
84
118
|
level: "warn",
|
|
85
119
|
kind: "github",
|
|
@@ -87,7 +121,7 @@ export async function maybeCloseLatePublishedImplementationPr(params) {
|
|
|
87
121
|
projectId: issue.projectId,
|
|
88
122
|
stage: issue.factoryState,
|
|
89
123
|
status: "late_pr_closed",
|
|
90
|
-
summary: `Auto-closed late PR #${event.prNumber} from
|
|
124
|
+
summary: `Auto-closed late PR #${event.prNumber} from a released implementation run`,
|
|
91
125
|
detail: latestRun.failureReason ?? `Latest implementation run status: ${latestRun.status}`,
|
|
92
126
|
});
|
|
93
127
|
return true;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mayClearFailureProvenance } from "./failure-provenance.js";
|
|
2
2
|
import { resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
|
|
3
|
+
import { deriveFactoryStateFromPrFacts } from "./pr-facts-derivation.js";
|
|
3
4
|
const DEFAULT_GATE_CHECK_NAMES = ["verify", "tests"];
|
|
4
5
|
/**
|
|
5
6
|
* GitHub sends both check_run and check_suite completion events.
|
|
@@ -61,28 +62,38 @@ export function isSettledBranchFailure(db, issue, event, project) {
|
|
|
61
62
|
return false;
|
|
62
63
|
return snapshot?.gateCheckStatus === "failure" && snapshot.headSha === event.headSha;
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Webhook adapter over {@link mayClearFailureProvenance} — translates a
|
|
67
|
+
* normalized GitHub event into the evidence object the shared rule expects.
|
|
68
|
+
* Check events can arrive out of order, so their head SHA only clears
|
|
69
|
+
* provenance when the success covers the recorded failure head; a
|
|
70
|
+
* `pr_synchronize` carries the freshly pushed head, which IS current truth.
|
|
71
|
+
*/
|
|
64
72
|
export function canClearFailureProvenance(issue, event, project) {
|
|
65
|
-
if (event.triggerEvent
|
|
73
|
+
if (event.triggerEvent === "pr_merged" || event.triggerEvent === "pr_closed") {
|
|
66
74
|
return true;
|
|
67
|
-
if (isQueueEvictionFailure(issue, event, project)) {
|
|
68
|
-
return !issue.lastGitHubFailureHeadSha || issue.lastGitHubFailureHeadSha === event.headSha;
|
|
69
75
|
}
|
|
70
|
-
if (
|
|
76
|
+
if (event.triggerEvent === "pr_synchronize") {
|
|
77
|
+
return mayClearFailureProvenance(issue, {
|
|
78
|
+
headSha: event.headSha,
|
|
79
|
+
headIsCurrentTruth: true,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (event.triggerEvent !== "check_passed") {
|
|
71
83
|
return true;
|
|
72
84
|
}
|
|
73
|
-
if (
|
|
74
|
-
return
|
|
85
|
+
if (isQueueEvictionFailure(issue, event, project)) {
|
|
86
|
+
return mayClearFailureProvenance(issue, {
|
|
87
|
+
headSha: event.headSha,
|
|
88
|
+
evictionCheckSucceeded: true,
|
|
89
|
+
});
|
|
75
90
|
}
|
|
76
|
-
return
|
|
91
|
+
return mayClearFailureProvenance(issue, {
|
|
92
|
+
headSha: event.headSha,
|
|
93
|
+
gateCheckStatus: "success",
|
|
94
|
+
});
|
|
77
95
|
}
|
|
78
96
|
export function resolveGitHubFactoryStateForEvent(issue, event, project, activeRun) {
|
|
79
|
-
if (event.triggerEvent === "pr_closed") {
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
const effectiveCurrentState = (issue.factoryState === "awaiting_input" || issue.factoryState === "delegated")
|
|
83
|
-
&& (event.prState === "open" || event.prNumber !== undefined)
|
|
84
|
-
? "pr_open"
|
|
85
|
-
: issue.factoryState;
|
|
86
97
|
// Classify check_failed events so the rule table can route them.
|
|
87
98
|
// The duplicate short-circuit that lived here before is gone — the
|
|
88
99
|
// table now handles queue_eviction via failureSource (plan §4.3).
|
|
@@ -92,19 +103,19 @@ export function resolveGitHubFactoryStateForEvent(issue, event, project, activeR
|
|
|
92
103
|
const approvalHeadSha = event.triggerEvent === "review_approved"
|
|
93
104
|
? (event.reviewCommitId ?? event.headSha)
|
|
94
105
|
: undefined;
|
|
95
|
-
|
|
106
|
+
return deriveFactoryStateFromPrFacts({
|
|
107
|
+
source: "webhook",
|
|
108
|
+
triggerEvent: event.triggerEvent,
|
|
109
|
+
prState: event.prState,
|
|
110
|
+
prNumber: event.prNumber,
|
|
111
|
+
headSha: event.headSha,
|
|
112
|
+
failureSource,
|
|
113
|
+
...(approvalHeadSha ? { approvalHeadSha } : {}),
|
|
114
|
+
}, {
|
|
115
|
+
factoryState: issue.factoryState,
|
|
96
116
|
prReviewState: issue.prReviewState,
|
|
97
117
|
activeRunId: issue.activeRunId,
|
|
98
|
-
failureSource,
|
|
99
118
|
...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
|
|
100
119
|
...(activeRun?.sourceHeadSha ? { activeRunSourceHeadSha: activeRun.sourceHeadSha } : {}),
|
|
101
|
-
...(approvalHeadSha ? { approvalHeadSha } : {}),
|
|
102
120
|
});
|
|
103
|
-
if (resolved !== undefined) {
|
|
104
|
-
return resolved;
|
|
105
|
-
}
|
|
106
|
-
if (effectiveCurrentState !== issue.factoryState) {
|
|
107
|
-
return effectiveCurrentState;
|
|
108
|
-
}
|
|
109
|
-
return undefined;
|
|
110
121
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
const MAX_CACHED_FILE_SETS = 512;
|
|
2
|
+
export function createSequenceBackstopCaches() {
|
|
3
|
+
return { alertedPrPairs: new Set(), changedFilesByHead: new Map() };
|
|
4
|
+
}
|
|
5
|
+
const processCaches = createSequenceBackstopCaches();
|
|
1
6
|
// Plan §8.2: backstop for missed sequence-checks. When a PR is
|
|
2
7
|
// opened and its changed-file set overlaps with another in-flight
|
|
3
8
|
// PR's, surface an operator event so the agent can be re-prompted
|
|
@@ -7,6 +12,7 @@
|
|
|
7
12
|
export async function maybeRunSequenceBackstop(params) {
|
|
8
13
|
const { db, logger, feed, event } = params;
|
|
9
14
|
const fetchImpl = params.fetchImpl ?? fetch;
|
|
15
|
+
const caches = params.caches ?? processCaches;
|
|
10
16
|
if (event.triggerEvent !== "pr_opened")
|
|
11
17
|
return;
|
|
12
18
|
if (!event.repoFullName || event.prNumber === undefined)
|
|
@@ -17,7 +23,15 @@ export async function maybeRunSequenceBackstop(params) {
|
|
|
17
23
|
const [owner, repo] = event.repoFullName.split("/", 2);
|
|
18
24
|
if (!owner || !repo)
|
|
19
25
|
return;
|
|
20
|
-
const newPrFiles = await
|
|
26
|
+
const newPrFiles = await listChangedFilesCached({
|
|
27
|
+
caches,
|
|
28
|
+
fetchImpl,
|
|
29
|
+
token,
|
|
30
|
+
owner,
|
|
31
|
+
repo,
|
|
32
|
+
prNumber: event.prNumber,
|
|
33
|
+
headSha: event.headSha,
|
|
34
|
+
});
|
|
21
35
|
if (!newPrFiles || newPrFiles.size === 0)
|
|
22
36
|
return;
|
|
23
37
|
const candidates = db.issues
|
|
@@ -28,12 +42,24 @@ export async function maybeRunSequenceBackstop(params) {
|
|
|
28
42
|
&& issue.branchName !== undefined
|
|
29
43
|
&& issue.branchName !== event.branchName);
|
|
30
44
|
for (const candidate of candidates) {
|
|
31
|
-
const
|
|
45
|
+
const pairKey = `${owner}/${repo}#${event.prNumber}->#${candidate.prNumber}`;
|
|
46
|
+
if (caches.alertedPrPairs.has(pairKey))
|
|
47
|
+
continue;
|
|
48
|
+
const candidateFiles = await listChangedFilesCached({
|
|
49
|
+
caches,
|
|
50
|
+
fetchImpl,
|
|
51
|
+
token,
|
|
52
|
+
owner,
|
|
53
|
+
repo,
|
|
54
|
+
prNumber: candidate.prNumber,
|
|
55
|
+
headSha: candidate.prHeadSha,
|
|
56
|
+
});
|
|
32
57
|
if (!candidateFiles)
|
|
33
58
|
continue;
|
|
34
59
|
const overlap = intersect(newPrFiles, candidateFiles);
|
|
35
60
|
if (overlap.length === 0)
|
|
36
61
|
continue;
|
|
62
|
+
caches.alertedPrPairs.add(pairKey);
|
|
37
63
|
logger.info({
|
|
38
64
|
event: "sequence_backstop_overlap_detected",
|
|
39
65
|
prNumber: event.prNumber,
|
|
@@ -53,6 +79,23 @@ export async function maybeRunSequenceBackstop(params) {
|
|
|
53
79
|
return;
|
|
54
80
|
}
|
|
55
81
|
}
|
|
82
|
+
async function listChangedFilesCached(params) {
|
|
83
|
+
const { caches } = params;
|
|
84
|
+
const cacheKey = params.headSha ? `${params.owner}/${params.repo}@${params.headSha}` : undefined;
|
|
85
|
+
if (cacheKey) {
|
|
86
|
+
const cached = caches.changedFilesByHead.get(cacheKey);
|
|
87
|
+
if (cached)
|
|
88
|
+
return cached;
|
|
89
|
+
}
|
|
90
|
+
const files = await listChangedFiles(params.fetchImpl, params.token, params.owner, params.repo, params.prNumber).catch(() => undefined);
|
|
91
|
+
if (cacheKey && files) {
|
|
92
|
+
if (caches.changedFilesByHead.size >= MAX_CACHED_FILE_SETS) {
|
|
93
|
+
caches.changedFilesByHead.clear();
|
|
94
|
+
}
|
|
95
|
+
caches.changedFilesByHead.set(cacheKey, files);
|
|
96
|
+
}
|
|
97
|
+
return files;
|
|
98
|
+
}
|
|
56
99
|
async function listChangedFiles(fetchImpl, token, owner, repo, prNumber) {
|
|
57
100
|
const result = new Set();
|
|
58
101
|
let page = 1;
|
|
@@ -123,6 +123,9 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
123
123
|
}
|
|
124
124
|
const freshIssue = deps.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
125
125
|
if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
|
|
126
|
+
// A push always resets the repair budgets and the CI snapshot for the new
|
|
127
|
+
// head; failure provenance is only cleared when the pushed head actually
|
|
128
|
+
// supersedes the recorded failure (mayClearFailureProvenance — phase C1).
|
|
126
129
|
deps.db.issueSessions.commitIssueState({
|
|
127
130
|
writer: WRITER,
|
|
128
131
|
update: {
|
|
@@ -130,22 +133,12 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
|
|
|
130
133
|
linearIssueId: issue.linearIssueId,
|
|
131
134
|
ciRepairAttempts: 0,
|
|
132
135
|
queueRepairAttempts: 0,
|
|
133
|
-
|
|
134
|
-
lastGitHubFailureHeadSha: null,
|
|
135
|
-
lastGitHubFailureSignature: null,
|
|
136
|
-
lastGitHubFailureCheckName: null,
|
|
137
|
-
lastGitHubFailureCheckUrl: null,
|
|
138
|
-
lastGitHubFailureContextJson: null,
|
|
139
|
-
lastGitHubFailureAt: null,
|
|
136
|
+
...(canClearFailureProvenance(freshIssue, event, project) ? CLEARED_FAILURE_PROVENANCE : {}),
|
|
140
137
|
lastGitHubCiSnapshotHeadSha: event.headSha ?? null,
|
|
141
138
|
lastGitHubCiSnapshotGateCheckName: getPrimaryGateCheckName(project),
|
|
142
139
|
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
143
140
|
lastGitHubCiSnapshotJson: null,
|
|
144
141
|
lastGitHubCiSnapshotSettledAt: null,
|
|
145
|
-
lastQueueIncidentJson: null,
|
|
146
|
-
lastAttemptedFailureHeadSha: null,
|
|
147
|
-
lastAttemptedFailureSignature: null,
|
|
148
|
-
lastAttemptedFailureAt: null,
|
|
149
142
|
},
|
|
150
143
|
});
|
|
151
144
|
}
|
|
@@ -370,7 +363,7 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
|
|
|
370
363
|
if ((event.triggerEvent === "check_passed" && (!isMetadataOnlyCheckEvent(event) || isQueueEvictionFailure(issue, event, project) || isGateCheckEvent(event, project)))
|
|
371
364
|
|| event.triggerEvent === "pr_synchronize"
|
|
372
365
|
|| event.triggerEvent === "pr_merged") {
|
|
373
|
-
if (
|
|
366
|
+
if (!canClearFailureProvenance(issue, event, project)) {
|
|
374
367
|
return;
|
|
375
368
|
}
|
|
376
369
|
deps.db.issueSessions.commitIssueState({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TERMINAL_STATES } from "./factory-state.js";
|
|
2
|
-
import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
|
|
2
|
+
import { CLEARED_FAILURE_PROVENANCE, mayClearFailureProvenance, } from "./failure-provenance.js";
|
|
3
|
+
import { deriveFactoryStateFromPrFacts } from "./pr-facts-derivation.js";
|
|
3
4
|
import { DEPLOY_WATCH_TIMEOUT_MS, evaluateDeploy, isDeployTrackingEnabled, } from "./post-merge-deploy.js";
|
|
4
5
|
import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCompletedReviewQuillVerdict, hasFailureProvenance, isDuplicateRepairAttempt, isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
|
|
5
6
|
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
@@ -73,11 +74,13 @@ export class IdleIssueReconciler {
|
|
|
73
74
|
if (issue.prNumber) {
|
|
74
75
|
await this.reconcileFromGitHub(issue);
|
|
75
76
|
}
|
|
76
|
-
else
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
else {
|
|
78
|
+
// No PR to poll means no fresh GitHub evidence — provenance may
|
|
79
|
+
// only be cleared when nothing concrete is recorded to preserve.
|
|
80
|
+
const clear = hasFailureProvenance(issue) && mayClearFailureProvenance(issue, {});
|
|
81
|
+
if (issue.factoryState !== "awaiting_queue" || clear) {
|
|
82
|
+
this.advanceIdleIssue(issue, "awaiting_queue", clear ? { clearFailureProvenance: true } : {});
|
|
83
|
+
}
|
|
81
84
|
}
|
|
82
85
|
continue;
|
|
83
86
|
}
|
|
@@ -526,8 +529,12 @@ export class IdleIssueReconciler {
|
|
|
526
529
|
if (!snapshot.ok) {
|
|
527
530
|
this.logger.debug({ issueKey: issue.issueKey, error: snapshot.error.message }, "Failed to query GitHub PR state during reconciliation");
|
|
528
531
|
if (issue.prReviewState === "approved") {
|
|
529
|
-
|
|
530
|
-
|
|
532
|
+
// The poll failed, so there is no fresh evidence: never clear
|
|
533
|
+
// recorded failure provenance on this path (a green-looking local
|
|
534
|
+
// row must not swallow a pending repair).
|
|
535
|
+
const clear = hasFailureProvenance(issue) && mayClearFailureProvenance(issue, {});
|
|
536
|
+
if (issue.factoryState !== "awaiting_queue" || clear) {
|
|
537
|
+
this.advanceIdleIssue(issue, "awaiting_queue", clear ? { clearFailureProvenance: true } : {});
|
|
531
538
|
}
|
|
532
539
|
}
|
|
533
540
|
return;
|
|
@@ -537,6 +544,34 @@ export class IdleIssueReconciler {
|
|
|
537
544
|
const previousHeadSha = issue.prHeadSha;
|
|
538
545
|
const gateCheckNames = getGateCheckNames(project);
|
|
539
546
|
const gateCheckStatus = deriveGateCheckStatusFromRollup(pr.statusCheckRollup, gateCheckNames);
|
|
547
|
+
const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== previousHeadSha);
|
|
548
|
+
const prState = pr.state === "MERGED" ? "merged" : pr.state === "CLOSED" ? "closed" : "open";
|
|
549
|
+
// Normalized level observation shared with the webhook path (plan §C1):
|
|
550
|
+
// every factory-state decision below goes through
|
|
551
|
+
// deriveFactoryStateFromPrFacts so both ingestion paths derive the same
|
|
552
|
+
// state from the same facts.
|
|
553
|
+
const observed = {
|
|
554
|
+
source: "poll",
|
|
555
|
+
prState,
|
|
556
|
+
prNumber,
|
|
557
|
+
...(pr.reviewDecision ? { reviewDecision: pr.reviewDecision } : {}),
|
|
558
|
+
...(gateCheckStatus ? { gateCheckStatus } : {}),
|
|
559
|
+
...(pr.headRefOid ? { headSha: pr.headRefOid } : {}),
|
|
560
|
+
headAdvanced,
|
|
561
|
+
...(prState === "closed" ? { closedPrDisposition: resolveClosedPrDisposition(issue) } : {}),
|
|
562
|
+
};
|
|
563
|
+
const currentFacts = (record) => ({
|
|
564
|
+
factoryState: record.factoryState,
|
|
565
|
+
prReviewState: record.prReviewState,
|
|
566
|
+
activeRunId: record.activeRunId,
|
|
567
|
+
});
|
|
568
|
+
// Evidence for the provenance rule: the polled head is current truth.
|
|
569
|
+
const provenanceEvidence = {
|
|
570
|
+
prState,
|
|
571
|
+
...(pr.headRefOid ? { headSha: pr.headRefOid } : {}),
|
|
572
|
+
headIsCurrentTruth: true,
|
|
573
|
+
...(gateCheckStatus ? { gateCheckStatus } : {}),
|
|
574
|
+
};
|
|
540
575
|
const factsCommit = this.db.issueSessions.commitIssueState({
|
|
541
576
|
writer: WRITER,
|
|
542
577
|
update: {
|
|
@@ -560,7 +595,9 @@ export class IdleIssueReconciler {
|
|
|
560
595
|
return;
|
|
561
596
|
}
|
|
562
597
|
if (pr.state === "CLOSED") {
|
|
563
|
-
|
|
598
|
+
// State decision shared with the webhook path; a closed PR is always
|
|
599
|
+
// newer evidence than any recorded failure, so clearing is allowed.
|
|
600
|
+
const closedState = deriveFactoryStateFromPrFacts(observed, currentFacts(issue));
|
|
564
601
|
const closedCommit = this.db.issueSessions.commitIssueState({
|
|
565
602
|
writer: WRITER,
|
|
566
603
|
update: {
|
|
@@ -573,12 +610,12 @@ export class IdleIssueReconciler {
|
|
|
573
610
|
if (closedCommit.outcome === "applied") {
|
|
574
611
|
issue = closedCommit.issue;
|
|
575
612
|
}
|
|
576
|
-
if (
|
|
613
|
+
if (closedState === "done") {
|
|
577
614
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber }, "Reconciliation: PR was closed for an already completed issue; preserving done state");
|
|
578
615
|
this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
|
|
579
616
|
return;
|
|
580
617
|
}
|
|
581
|
-
if (
|
|
618
|
+
if (closedState === undefined) {
|
|
582
619
|
this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, factoryState: issue.factoryState }, "Reconciliation: PR was closed on a terminal issue; preserving terminal state");
|
|
583
620
|
return;
|
|
584
621
|
}
|
|
@@ -595,9 +632,9 @@ export class IdleIssueReconciler {
|
|
|
595
632
|
}
|
|
596
633
|
return;
|
|
597
634
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const terminalRecoveryState =
|
|
635
|
+
if (issue.factoryState !== "awaiting_input"
|
|
636
|
+
&& (issue.factoryState === "escalated" || issue.factoryState === "failed")) {
|
|
637
|
+
const terminalRecoveryState = deriveFactoryStateFromPrFacts(observed, currentFacts(issue));
|
|
601
638
|
if (terminalRecoveryState) {
|
|
602
639
|
this.logger.info({
|
|
603
640
|
issueKey: issue.issueKey,
|
|
@@ -608,7 +645,8 @@ export class IdleIssueReconciler {
|
|
|
608
645
|
reviewDecision: pr.reviewDecision,
|
|
609
646
|
headAdvanced,
|
|
610
647
|
}, "Reconciliation: recovered terminal issue from newer GitHub truth");
|
|
611
|
-
|
|
648
|
+
const clear = mayClearFailureProvenance(issue, provenanceEvidence);
|
|
649
|
+
this.advanceIdleIssue(issue, terminalRecoveryState, clear ? { clearFailureProvenance: true } : {});
|
|
612
650
|
return;
|
|
613
651
|
}
|
|
614
652
|
}
|
|
@@ -669,7 +707,7 @@ export class IdleIssueReconciler {
|
|
|
669
707
|
this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
|
|
670
708
|
pendingRunType: reactiveIntent.runType,
|
|
671
709
|
...(pendingRunContext ? { pendingRunContext } : {}),
|
|
672
|
-
clearFailureProvenance: true,
|
|
710
|
+
...(mayClearFailureProvenance(issue, provenanceEvidence) ? { clearFailureProvenance: true } : {}),
|
|
673
711
|
});
|
|
674
712
|
return;
|
|
675
713
|
}
|
|
@@ -684,7 +722,7 @@ export class IdleIssueReconciler {
|
|
|
684
722
|
}, "Reconciliation: re-queued requested-changes follow-up from GitHub truth");
|
|
685
723
|
this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
|
|
686
724
|
pendingRunType: reactiveIntent.runType,
|
|
687
|
-
clearFailureProvenance: true,
|
|
725
|
+
...(mayClearFailureProvenance(issue, provenanceEvidence) ? { clearFailureProvenance: true } : {}),
|
|
688
726
|
});
|
|
689
727
|
this.feed?.publish({
|
|
690
728
|
level: "warn",
|
|
@@ -744,9 +782,14 @@ export class IdleIssueReconciler {
|
|
|
744
782
|
prReviewState: "approved",
|
|
745
783
|
},
|
|
746
784
|
});
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
785
|
+
const approvedState = deriveFactoryStateFromPrFacts(observed, currentFacts(issue));
|
|
786
|
+
if (approvedState === "awaiting_queue") {
|
|
787
|
+
// Provenance survives unless the polled evidence is newer than the
|
|
788
|
+
// recorded failure (head advanced, gate green on the failure head).
|
|
789
|
+
const clear = hasFailureProvenance(issue) && mayClearFailureProvenance(issue, provenanceEvidence);
|
|
790
|
+
if (issue.factoryState !== "awaiting_queue" || clear) {
|
|
791
|
+
this.advanceIdleIssue(issue, "awaiting_queue", clear ? { clearFailureProvenance: true } : undefined);
|
|
792
|
+
}
|
|
750
793
|
}
|
|
751
794
|
return;
|
|
752
795
|
}
|
|
@@ -755,22 +798,4 @@ export class IdleIssueReconciler {
|
|
|
755
798
|
}
|
|
756
799
|
}
|
|
757
800
|
}
|
|
758
|
-
deriveTerminalRecoveryState(issue, reviewDecision, gateCheckStatus, headAdvanced) {
|
|
759
|
-
if (issue.factoryState !== "escalated" && issue.factoryState !== "failed") {
|
|
760
|
-
return undefined;
|
|
761
|
-
}
|
|
762
|
-
if (isReviewDecisionApproved(reviewDecision) && !isFailingCheckStatus(gateCheckStatus)) {
|
|
763
|
-
return "awaiting_queue";
|
|
764
|
-
}
|
|
765
|
-
if (gateCheckStatus === "pending") {
|
|
766
|
-
return "pr_open";
|
|
767
|
-
}
|
|
768
|
-
if (headAdvanced && !isFailingCheckStatus(gateCheckStatus)) {
|
|
769
|
-
return "pr_open";
|
|
770
|
-
}
|
|
771
|
-
if (isReviewDecisionReviewRequired(reviewDecision) && !isFailingCheckStatus(gateCheckStatus)) {
|
|
772
|
-
return "pr_open";
|
|
773
|
-
}
|
|
774
|
-
return undefined;
|
|
775
|
-
}
|
|
776
801
|
}
|