patchrelay 0.37.1 → 0.38.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 +47 -9
- package/dist/awaiting-input-reason.js +9 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/cluster-health.js +59 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/output.js +2 -0
- package/dist/db/issue-session-store.js +0 -14
- package/dist/db/issue-store.js +8 -16
- package/dist/db/migrations.js +6 -13
- package/dist/db.js +1 -3
- package/dist/github-linear-session-sync.js +57 -0
- package/dist/github-pr-comment-handler.js +74 -0
- package/dist/github-webhook-failure-context.js +70 -0
- package/dist/github-webhook-handler.js +49 -965
- package/dist/github-webhook-issue-resolution.js +46 -0
- package/dist/github-webhook-policy.js +105 -0
- package/dist/github-webhook-reactive-run.js +302 -0
- package/dist/github-webhook-state-projector.js +231 -0
- package/dist/github-webhook-terminal-handler.js +111 -0
- package/dist/github-webhooks.js +4 -0
- package/dist/idle-reconciliation.js +22 -23
- package/dist/issue-overview-query.js +11 -57
- package/dist/issue-session-projector.js +1 -0
- package/dist/issue-session.js +8 -0
- package/dist/legacy-issue-overview.js +58 -0
- package/dist/linear-session-reporting.js +30 -1
- package/dist/linear-session-sync.js +9 -1
- package/dist/linear-status-comment-sync.js +34 -1
- package/dist/linear-workflow-state-sync.js +2 -2
- package/dist/operator-retry-event.js +15 -12
- package/dist/paused-issue-state.js +24 -0
- package/dist/reactive-pr-state.js +65 -0
- package/dist/reactive-run-policy.js +35 -118
- package/dist/remote-pr-state.js +11 -0
- package/dist/run-launcher.js +0 -1
- package/dist/run-orchestrator.js +22 -11
- package/dist/run-reconciler.js +10 -0
- package/dist/run-recovery-service.js +1 -10
- package/dist/service-issue-actions.js +5 -0
- package/dist/service-startup-recovery.js +9 -6
- package/dist/service.js +0 -1
- package/dist/tracked-issue-list-query.js +3 -1
- package/dist/tracked-issue-projector.js +3 -0
- package/dist/waiting-reason.js +10 -0
- package/dist/webhooks/agent-session-handler.js +9 -1
- package/dist/webhooks/comment-wake-handler.js +12 -0
- package/dist/webhooks/decision-helpers.js +44 -3
- package/dist/webhooks/dependency-readiness-handler.js +1 -0
- package/dist/webhooks/desired-stage-recorder.js +40 -10
- package/package.json +1 -1
|
@@ -9,7 +9,7 @@ function parseObjectJson(value) {
|
|
|
9
9
|
return undefined;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
-
export function buildOperatorRetryEvent(issue, runType) {
|
|
12
|
+
export function buildOperatorRetryEvent(issue, runType, source = "operator_retry") {
|
|
13
13
|
if (runType === "queue_repair") {
|
|
14
14
|
const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
|
|
15
15
|
const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
|
|
@@ -18,9 +18,9 @@ export function buildOperatorRetryEvent(issue, runType) {
|
|
|
18
18
|
eventJson: JSON.stringify({
|
|
19
19
|
...(queueIncident ?? {}),
|
|
20
20
|
...(failureContext ?? {}),
|
|
21
|
-
source
|
|
21
|
+
source,
|
|
22
22
|
}),
|
|
23
|
-
dedupeKey:
|
|
23
|
+
dedupeKey: `${source}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
if (runType === "ci_repair") {
|
|
@@ -29,9 +29,9 @@ export function buildOperatorRetryEvent(issue, runType) {
|
|
|
29
29
|
eventType: "settled_red_ci",
|
|
30
30
|
eventJson: JSON.stringify({
|
|
31
31
|
...(failureContext ?? {}),
|
|
32
|
-
source
|
|
32
|
+
source,
|
|
33
33
|
}),
|
|
34
|
-
dedupeKey:
|
|
34
|
+
dedupeKey: `${source}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
@@ -39,20 +39,23 @@ export function buildOperatorRetryEvent(issue, runType) {
|
|
|
39
39
|
eventType: "review_changes_requested",
|
|
40
40
|
eventJson: JSON.stringify({
|
|
41
41
|
reviewBody: runType === "branch_upkeep"
|
|
42
|
-
?
|
|
43
|
-
:
|
|
42
|
+
? `${humanizeSource(source)} requested retry of branch upkeep after requested changes.`
|
|
43
|
+
: `${humanizeSource(source)} requested retry of review-fix work.`,
|
|
44
44
|
...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
|
|
45
|
-
source
|
|
45
|
+
source,
|
|
46
46
|
}),
|
|
47
|
-
dedupeKey:
|
|
47
|
+
dedupeKey: `${source}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
return {
|
|
51
51
|
eventType: "delegated",
|
|
52
52
|
eventJson: JSON.stringify({
|
|
53
|
-
promptContext:
|
|
54
|
-
source
|
|
53
|
+
promptContext: `${humanizeSource(source)} requested PatchRelay work resume.`,
|
|
54
|
+
source,
|
|
55
55
|
}),
|
|
56
|
-
dedupeKey:
|
|
56
|
+
dedupeKey: `${source}:implementation:${issue.linearIssueId}`,
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
+
function humanizeSource(source) {
|
|
60
|
+
return source.replaceAll("_", " ");
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { resolveAwaitingInputReason } from "./awaiting-input-reason.js";
|
|
2
|
+
export function isUndelegatedPausedIssue(issue) {
|
|
3
|
+
return issue.delegatedToPatchRelay === false
|
|
4
|
+
&& issue.factoryState !== "done"
|
|
5
|
+
&& issue.factoryState !== "failed"
|
|
6
|
+
&& issue.factoryState !== "escalated";
|
|
7
|
+
}
|
|
8
|
+
export function isUndelegatedPausedNoPrWork(issue) {
|
|
9
|
+
return isUndelegatedPausedIssue(issue)
|
|
10
|
+
&& issue.prNumber === undefined
|
|
11
|
+
&& (issue.factoryState === "delegated" || issue.factoryState === "implementing");
|
|
12
|
+
}
|
|
13
|
+
export function isResumablePausedLocalWork(params) {
|
|
14
|
+
if (params.issue.delegatedToPatchRelay === false) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (params.issue.prNumber !== undefined) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (params.issue.factoryState === "delegated" || params.issue.factoryState === "implementing") {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return resolveAwaitingInputReason(params) === "paused_local_work";
|
|
24
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readRemotePrState } from "./remote-pr-state.js";
|
|
2
|
+
export function isRequestedChangesRunType(runType) {
|
|
3
|
+
return runType === "review_fix" || runType === "branch_upkeep";
|
|
4
|
+
}
|
|
5
|
+
export function normalizeRemotePrState(value) {
|
|
6
|
+
const normalized = value?.trim().toUpperCase();
|
|
7
|
+
if (normalized === "OPEN")
|
|
8
|
+
return "open";
|
|
9
|
+
if (normalized === "CLOSED")
|
|
10
|
+
return "closed";
|
|
11
|
+
if (normalized === "MERGED")
|
|
12
|
+
return "merged";
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function normalizeRemoteReviewDecision(value) {
|
|
16
|
+
const normalized = value?.trim().toUpperCase();
|
|
17
|
+
if (normalized === "APPROVED")
|
|
18
|
+
return "approved";
|
|
19
|
+
if (normalized === "CHANGES_REQUESTED")
|
|
20
|
+
return "changes_requested";
|
|
21
|
+
if (normalized === "REVIEW_REQUIRED")
|
|
22
|
+
return "commented";
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function isDirtyMergeStateStatus(value) {
|
|
26
|
+
return value?.trim().toUpperCase() === "DIRTY";
|
|
27
|
+
}
|
|
28
|
+
export function buildReviewFixBranchUpkeepContext(prNumber, baseBranch, pr, context) {
|
|
29
|
+
const promptContext = [
|
|
30
|
+
`The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${String(pr.mergeStateStatus)} against latest ${baseBranch}.`,
|
|
31
|
+
`This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
|
|
32
|
+
"Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
|
|
33
|
+
].join(" ");
|
|
34
|
+
return {
|
|
35
|
+
...(context ?? {}),
|
|
36
|
+
branchUpkeepRequired: true,
|
|
37
|
+
reviewFixMode: "branch_upkeep",
|
|
38
|
+
wakeReason: "branch_upkeep",
|
|
39
|
+
promptContext,
|
|
40
|
+
...(pr.mergeStateStatus ? { mergeStateStatus: pr.mergeStateStatus } : {}),
|
|
41
|
+
...(pr.headRefOid ? { failingHeadSha: pr.headRefOid } : {}),
|
|
42
|
+
baseBranch,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function readReactivePrSnapshot(config, projectId, prNumber) {
|
|
46
|
+
const project = config.projects.find((entry) => entry.id === projectId);
|
|
47
|
+
const repoFullName = project?.github?.repoFullName;
|
|
48
|
+
if (!repoFullName) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const pr = await readRemotePrState(repoFullName, prNumber);
|
|
52
|
+
if (!pr) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
projectId,
|
|
57
|
+
repoFullName,
|
|
58
|
+
baseBranch: project?.github?.baseBranch ?? "main",
|
|
59
|
+
pr,
|
|
60
|
+
prState: normalizeRemotePrState(pr.state),
|
|
61
|
+
reviewState: normalizeRemoteReviewDecision(pr.reviewDecision),
|
|
62
|
+
headSha: pr.headRefOid,
|
|
63
|
+
gateCheckName: project?.gateChecks?.find((entry) => entry.trim())?.trim() ?? "verify",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -1,47 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
function isRequestedChangesRunType(runType) {
|
|
3
|
-
return runType === "review_fix" || runType === "branch_upkeep";
|
|
4
|
-
}
|
|
5
|
-
function normalizeRemotePrState(value) {
|
|
6
|
-
const normalized = value?.trim().toUpperCase();
|
|
7
|
-
if (normalized === "OPEN")
|
|
8
|
-
return "open";
|
|
9
|
-
if (normalized === "CLOSED")
|
|
10
|
-
return "closed";
|
|
11
|
-
if (normalized === "MERGED")
|
|
12
|
-
return "merged";
|
|
13
|
-
return undefined;
|
|
14
|
-
}
|
|
15
|
-
function normalizeRemoteReviewDecision(value) {
|
|
16
|
-
const normalized = value?.trim().toUpperCase();
|
|
17
|
-
if (normalized === "APPROVED")
|
|
18
|
-
return "approved";
|
|
19
|
-
if (normalized === "CHANGES_REQUESTED")
|
|
20
|
-
return "changes_requested";
|
|
21
|
-
if (normalized === "REVIEW_REQUIRED")
|
|
22
|
-
return "commented";
|
|
23
|
-
return undefined;
|
|
24
|
-
}
|
|
25
|
-
function isDirtyMergeStateStatus(value) {
|
|
26
|
-
return value?.trim().toUpperCase() === "DIRTY";
|
|
27
|
-
}
|
|
28
|
-
function buildReviewFixBranchUpkeepContext(prNumber, baseBranch, pr, context) {
|
|
29
|
-
const promptContext = [
|
|
30
|
-
`The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${String(pr.mergeStateStatus)} against latest ${baseBranch}.`,
|
|
31
|
-
`This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
|
|
32
|
-
"Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
|
|
33
|
-
].join(" ");
|
|
34
|
-
return {
|
|
35
|
-
...(context ?? {}),
|
|
36
|
-
branchUpkeepRequired: true,
|
|
37
|
-
reviewFixMode: "branch_upkeep",
|
|
38
|
-
wakeReason: "branch_upkeep",
|
|
39
|
-
promptContext,
|
|
40
|
-
...(pr.mergeStateStatus ? { mergeStateStatus: pr.mergeStateStatus } : {}),
|
|
41
|
-
...(pr.headRefOid ? { failingHeadSha: pr.headRefOid } : {}),
|
|
42
|
-
baseBranch,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
1
|
+
import { buildReviewFixBranchUpkeepContext, isDirtyMergeStateStatus, isRequestedChangesRunType, readReactivePrSnapshot, } from "./reactive-pr-state.js";
|
|
45
2
|
export class ReactiveRunPolicy {
|
|
46
3
|
config;
|
|
47
4
|
db;
|
|
@@ -60,15 +17,11 @@ export class ReactiveRunPolicy {
|
|
|
60
17
|
if (!issue.prNumber || issue.prState !== "open" || !issue.lastGitHubFailureHeadSha) {
|
|
61
18
|
return undefined;
|
|
62
19
|
}
|
|
63
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
64
|
-
if (!project?.github?.repoFullName) {
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
20
|
try {
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
21
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
22
|
+
if (!snapshot || snapshot.prState !== "open")
|
|
70
23
|
return undefined;
|
|
71
|
-
if (!
|
|
24
|
+
if (!snapshot.headSha || snapshot.headSha !== issue.lastGitHubFailureHeadSha)
|
|
72
25
|
return undefined;
|
|
73
26
|
return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
|
|
74
27
|
}
|
|
@@ -91,18 +44,14 @@ export class ReactiveRunPolicy {
|
|
|
91
44
|
if (!run.sourceHeadSha) {
|
|
92
45
|
return `Requested-changes run finished for PR #${issue.prNumber} without a recorded starting head SHA. PatchRelay cannot verify that a new head was published.`;
|
|
93
46
|
}
|
|
94
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
95
|
-
if (!project?.github?.repoFullName) {
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
47
|
try {
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
48
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
49
|
+
if (!snapshot || snapshot.prState !== "open")
|
|
101
50
|
return undefined;
|
|
102
|
-
if (!
|
|
51
|
+
if (!snapshot.headSha) {
|
|
103
52
|
return `Requested-changes run finished for PR #${issue.prNumber} but GitHub did not report a current head SHA.`;
|
|
104
53
|
}
|
|
105
|
-
if (
|
|
54
|
+
if (snapshot.headSha === run.sourceHeadSha) {
|
|
106
55
|
return `Requested-changes run finished for PR #${issue.prNumber} without pushing a new head; PatchRelay must not hand the same SHA back to review.`;
|
|
107
56
|
}
|
|
108
57
|
return undefined;
|
|
@@ -123,28 +72,20 @@ export class ReactiveRunPolicy {
|
|
|
123
72
|
if (!issue.prNumber) {
|
|
124
73
|
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
125
74
|
}
|
|
126
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
127
|
-
const repoFullName = project?.github?.repoFullName;
|
|
128
|
-
if (!repoFullName) {
|
|
129
|
-
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
130
|
-
}
|
|
131
75
|
try {
|
|
132
|
-
const
|
|
133
|
-
if (!
|
|
76
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
77
|
+
if (!snapshot) {
|
|
134
78
|
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
135
79
|
}
|
|
136
|
-
const
|
|
137
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
138
|
-
const gateCheckName = project?.gateChecks?.find((entry) => entry.trim())?.trim() ?? "verify";
|
|
139
|
-
const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== issue.lastGitHubFailureHeadSha);
|
|
80
|
+
const headAdvanced = Boolean(snapshot.headSha && snapshot.headSha !== issue.lastGitHubFailureHeadSha);
|
|
140
81
|
const reviewFixHeadAdvanced = isRequestedChangesRunType(run.runType)
|
|
141
|
-
&& Boolean(
|
|
82
|
+
&& Boolean(snapshot.headSha && run.sourceHeadSha && snapshot.headSha !== run.sourceHeadSha);
|
|
142
83
|
this.upsertIssueIfLeaseHeld(run.projectId, run.linearIssueId, {
|
|
143
84
|
projectId: run.projectId,
|
|
144
85
|
linearIssueId: run.linearIssueId,
|
|
145
|
-
...(
|
|
146
|
-
...(
|
|
147
|
-
...(
|
|
86
|
+
...(snapshot.prState ? { prState: snapshot.prState } : {}),
|
|
87
|
+
...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
|
|
88
|
+
...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
|
|
148
89
|
...((headAdvanced || reviewFixHeadAdvanced)
|
|
149
90
|
? {
|
|
150
91
|
prCheckStatus: "pending",
|
|
@@ -158,8 +99,8 @@ export class ReactiveRunPolicy {
|
|
|
158
99
|
lastQueueIncidentJson: null,
|
|
159
100
|
lastAttemptedFailureHeadSha: null,
|
|
160
101
|
lastAttemptedFailureSignature: null,
|
|
161
|
-
lastGitHubCiSnapshotHeadSha:
|
|
162
|
-
lastGitHubCiSnapshotGateCheckName: gateCheckName,
|
|
102
|
+
lastGitHubCiSnapshotHeadSha: snapshot.headSha ?? null,
|
|
103
|
+
lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName,
|
|
163
104
|
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
164
105
|
lastGitHubCiSnapshotJson: null,
|
|
165
106
|
lastGitHubCiSnapshotSettledAt: null,
|
|
@@ -183,31 +124,24 @@ export class ReactiveRunPolicy {
|
|
|
183
124
|
if (!issue.prNumber || issue.prState !== "open" || issue.prReviewState !== "changes_requested") {
|
|
184
125
|
return context;
|
|
185
126
|
}
|
|
186
|
-
const project = this.config.projects.find((entry) => entry.id === issue.projectId);
|
|
187
|
-
const repoFullName = project?.github?.repoFullName;
|
|
188
|
-
if (!repoFullName) {
|
|
189
|
-
return context;
|
|
190
|
-
}
|
|
191
127
|
try {
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
128
|
+
const snapshot = await readReactivePrSnapshot(this.config, issue.projectId, issue.prNumber);
|
|
129
|
+
if (!snapshot)
|
|
194
130
|
return context;
|
|
195
|
-
const nextPrState = normalizeRemotePrState(pr.state);
|
|
196
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
197
131
|
this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
|
|
198
132
|
projectId: issue.projectId,
|
|
199
133
|
linearIssueId: issue.linearIssueId,
|
|
200
|
-
...(
|
|
201
|
-
...(
|
|
202
|
-
...(
|
|
134
|
+
...(snapshot.prState ? { prState: snapshot.prState } : {}),
|
|
135
|
+
...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
|
|
136
|
+
...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
|
|
203
137
|
}, "review-fix wake refresh");
|
|
204
|
-
if (
|
|
138
|
+
if (snapshot.prState !== "open")
|
|
205
139
|
return context;
|
|
206
|
-
if (
|
|
140
|
+
if (snapshot.reviewState && snapshot.reviewState !== "changes_requested")
|
|
207
141
|
return context;
|
|
208
|
-
if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
|
|
142
|
+
if (!isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus))
|
|
209
143
|
return context;
|
|
210
|
-
return buildReviewFixBranchUpkeepContext(issue.prNumber,
|
|
144
|
+
return buildReviewFixBranchUpkeepContext(issue.prNumber, snapshot.baseBranch, snapshot.pr, context);
|
|
211
145
|
}
|
|
212
146
|
catch (error) {
|
|
213
147
|
this.logger.debug({
|
|
@@ -228,34 +162,27 @@ export class ReactiveRunPolicy {
|
|
|
228
162
|
if (issue.prReviewState !== "changes_requested") {
|
|
229
163
|
return undefined;
|
|
230
164
|
}
|
|
231
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
232
|
-
const repoFullName = project?.github?.repoFullName;
|
|
233
|
-
if (!repoFullName) {
|
|
234
|
-
return undefined;
|
|
235
|
-
}
|
|
236
165
|
try {
|
|
237
|
-
const
|
|
238
|
-
if (!
|
|
166
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
167
|
+
if (!snapshot)
|
|
239
168
|
return undefined;
|
|
240
|
-
const nextPrState = normalizeRemotePrState(pr.state);
|
|
241
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
242
169
|
this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
|
|
243
170
|
projectId: issue.projectId,
|
|
244
171
|
linearIssueId: issue.linearIssueId,
|
|
245
|
-
...(
|
|
246
|
-
...(
|
|
247
|
-
...(
|
|
172
|
+
...(snapshot.prState ? { prState: snapshot.prState } : {}),
|
|
173
|
+
...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
|
|
174
|
+
...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
|
|
248
175
|
}, "post-run follow-up refresh");
|
|
249
|
-
if (
|
|
176
|
+
if (snapshot.prState !== "open")
|
|
250
177
|
return undefined;
|
|
251
|
-
if (
|
|
178
|
+
if (snapshot.reviewState && snapshot.reviewState !== "changes_requested")
|
|
252
179
|
return undefined;
|
|
253
|
-
if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
|
|
180
|
+
if (!isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus))
|
|
254
181
|
return undefined;
|
|
255
182
|
return {
|
|
256
183
|
pendingRunType: "branch_upkeep",
|
|
257
184
|
factoryState: "changes_requested",
|
|
258
|
-
context: buildReviewFixBranchUpkeepContext(issue.prNumber,
|
|
185
|
+
context: buildReviewFixBranchUpkeepContext(issue.prNumber, snapshot.baseBranch, snapshot.pr),
|
|
259
186
|
summary: `PR #${issue.prNumber} is still dirty after review fix; queued branch upkeep`,
|
|
260
187
|
};
|
|
261
188
|
}
|
|
@@ -268,16 +195,6 @@ export class ReactiveRunPolicy {
|
|
|
268
195
|
return undefined;
|
|
269
196
|
}
|
|
270
197
|
}
|
|
271
|
-
async loadRemotePrState(repoFullName, prNumber) {
|
|
272
|
-
const { stdout, exitCode } = await execCommand("gh", [
|
|
273
|
-
"pr", "view", String(prNumber),
|
|
274
|
-
"--repo", repoFullName,
|
|
275
|
-
"--json", "headRefOid,state,reviewDecision,mergeStateStatus",
|
|
276
|
-
], { timeoutMs: 10_000 });
|
|
277
|
-
if (exitCode !== 0)
|
|
278
|
-
return undefined;
|
|
279
|
-
return JSON.parse(stdout);
|
|
280
|
-
}
|
|
281
198
|
upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
|
|
282
199
|
const updated = this.withHeldLease(projectId, linearIssueId, (lease) => this.db.issueSessions.upsertIssueWithLease(lease, params));
|
|
283
200
|
if (updated === undefined) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { execCommand } from "./utils.js";
|
|
2
|
+
export async function readRemotePrState(repoFullName, prNumber) {
|
|
3
|
+
const { stdout, exitCode } = await execCommand("gh", [
|
|
4
|
+
"pr", "view", String(prNumber),
|
|
5
|
+
"--repo", repoFullName,
|
|
6
|
+
"--json", "headRefOid,state,reviewDecision,mergeStateStatus",
|
|
7
|
+
], { timeoutMs: 10_000 });
|
|
8
|
+
if (exitCode !== 0)
|
|
9
|
+
return undefined;
|
|
10
|
+
return JSON.parse(stdout);
|
|
11
|
+
}
|
package/dist/run-launcher.js
CHANGED
|
@@ -106,7 +106,6 @@ export class RunLauncher {
|
|
|
106
106
|
});
|
|
107
107
|
this.db.issueSessions.consumeIssueSessionEvents(params.item.projectId, params.item.issueId, freshWake.eventIds, created.id);
|
|
108
108
|
this.db.issueSessions.setIssueSessionLastWakeReason(params.item.projectId, params.item.issueId, freshWake.wakeReason ?? null);
|
|
109
|
-
this.db.issueSessions.setBranchOwnerWithLease({ projectId: params.item.projectId, linearIssueId: params.item.issueId, leaseId: params.leaseId }, "patchrelay");
|
|
110
109
|
return created;
|
|
111
110
|
});
|
|
112
111
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -4,7 +4,7 @@ import { CompletionCheckService } from "./completion-check.js";
|
|
|
4
4
|
import { WorktreeManager } from "./worktree-manager.js";
|
|
5
5
|
import { MergedLinearCompletionReconciler } from "./merged-linear-completion-reconciler.js";
|
|
6
6
|
import { QueueHealthMonitor } from "./queue-health-monitor.js";
|
|
7
|
-
import { IdleIssueReconciler
|
|
7
|
+
import { IdleIssueReconciler } from "./idle-reconciliation.js";
|
|
8
8
|
import { LinearSessionSync } from "./linear-session-sync.js";
|
|
9
9
|
import { IssueSessionLeaseService } from "./issue-session-lease-service.js";
|
|
10
10
|
import { InterruptedRunRecovery } from "./interrupted-run-recovery.js";
|
|
@@ -54,6 +54,20 @@ export class RunOrchestrator {
|
|
|
54
54
|
runNotificationHandler;
|
|
55
55
|
runReconciler;
|
|
56
56
|
mergedLinearCompletionReconciler;
|
|
57
|
+
threadPorts = {
|
|
58
|
+
readThreadWithRetry: (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries),
|
|
59
|
+
};
|
|
60
|
+
leasePorts = {
|
|
61
|
+
withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
|
|
62
|
+
releaseLease: (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId),
|
|
63
|
+
heartbeatLease: (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId),
|
|
64
|
+
getHeldLease: (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId),
|
|
65
|
+
};
|
|
66
|
+
recoveryPorts = {
|
|
67
|
+
failRunAndClear: (run, message, nextState) => this.failRunAndClear(run, message, nextState),
|
|
68
|
+
restoreIdleWorktree: (issue) => this.restoreIdleWorktree(issue),
|
|
69
|
+
recoverOrEscalate: (issue, runType, reason) => this.recoverOrEscalate(issue, runType, reason),
|
|
70
|
+
};
|
|
57
71
|
activeSessionLeases;
|
|
58
72
|
botIdentity;
|
|
59
73
|
constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
|
|
@@ -66,16 +80,16 @@ export class RunOrchestrator {
|
|
|
66
80
|
this.feed = feed;
|
|
67
81
|
this.worktreeManager = new WorktreeManager(config);
|
|
68
82
|
this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
|
|
69
|
-
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId,
|
|
83
|
+
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry);
|
|
70
84
|
this.activeSessionLeases = this.leaseService.activeSessionLeases;
|
|
71
|
-
this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger,
|
|
85
|
+
this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
|
|
72
86
|
this.completionCheck = new CompletionCheckService(codex, logger);
|
|
73
|
-
this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue,
|
|
87
|
+
this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, feed);
|
|
74
88
|
this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
|
|
75
|
-
this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer,
|
|
76
|
-
this.runRecovery = new RunRecoveryService(db, logger, this.linearSync,
|
|
77
|
-
this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync,
|
|
78
|
-
this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer,
|
|
89
|
+
this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
|
|
90
|
+
this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
|
|
91
|
+
this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
|
|
92
|
+
this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, feed);
|
|
79
93
|
this.runWakePlanner = new RunWakePlanner(db);
|
|
80
94
|
this.idleReconciler = new IdleIssueReconciler(db, config, {
|
|
81
95
|
enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
|
|
@@ -302,9 +316,6 @@ export class RunOrchestrator {
|
|
|
302
316
|
nextState,
|
|
303
317
|
});
|
|
304
318
|
}
|
|
305
|
-
resolveBranchOwnerForStateTransition(newState, pendingRunType) {
|
|
306
|
-
return resolveBranchOwnerForStateTransition(newState, pendingRunType);
|
|
307
|
-
}
|
|
308
319
|
async resolveRequestedChangesWakeContext(issue, runType, context) {
|
|
309
320
|
return await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context);
|
|
310
321
|
}
|
package/dist/run-reconciler.js
CHANGED
|
@@ -31,6 +31,16 @@ export class RunReconciler {
|
|
|
31
31
|
async reconcile(params) {
|
|
32
32
|
const { run, issue, recoveryLease } = params;
|
|
33
33
|
const acquiredRecoveryLease = recoveryLease === true;
|
|
34
|
+
if (!issue.delegatedToPatchRelay) {
|
|
35
|
+
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
36
|
+
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
|
|
37
|
+
this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null, factoryState: issue.factoryState });
|
|
38
|
+
});
|
|
39
|
+
const pausedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
40
|
+
void this.linearSync.syncSession(pausedIssue, { activeRunType: run.runType });
|
|
41
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
34
44
|
if (TERMINAL_STATES.has(issue.factoryState)) {
|
|
35
45
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
36
46
|
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
|
|
@@ -10,9 +10,8 @@ export class RunRecoveryService {
|
|
|
10
10
|
appendWakeEventWithLease;
|
|
11
11
|
releaseLease;
|
|
12
12
|
enqueueIssue;
|
|
13
|
-
resolveBranchOwnerForStateTransition;
|
|
14
13
|
feed;
|
|
15
|
-
constructor(db, logger, linearSync, withHeldLease, getHeldLease, appendWakeEventWithLease, releaseLease, enqueueIssue,
|
|
14
|
+
constructor(db, logger, linearSync, withHeldLease, getHeldLease, appendWakeEventWithLease, releaseLease, enqueueIssue, feed) {
|
|
16
15
|
this.db = db;
|
|
17
16
|
this.logger = logger;
|
|
18
17
|
this.linearSync = linearSync;
|
|
@@ -21,7 +20,6 @@ export class RunRecoveryService {
|
|
|
21
20
|
this.appendWakeEventWithLease = appendWakeEventWithLease;
|
|
22
21
|
this.releaseLease = releaseLease;
|
|
23
22
|
this.enqueueIssue = enqueueIssue;
|
|
24
|
-
this.resolveBranchOwnerForStateTransition = resolveBranchOwnerForStateTransition;
|
|
25
23
|
this.feed = feed;
|
|
26
24
|
}
|
|
27
25
|
recoverOrEscalate(params) {
|
|
@@ -189,13 +187,6 @@ export class RunRecoveryService {
|
|
|
189
187
|
activeRunId: null,
|
|
190
188
|
factoryState: nextState,
|
|
191
189
|
});
|
|
192
|
-
const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
|
|
193
|
-
if (branchOwner) {
|
|
194
|
-
const heldLease = this.getHeldLease(run.projectId, run.linearIssueId);
|
|
195
|
-
if (heldLease) {
|
|
196
|
-
this.db.issueSessions.setBranchOwnerWithLease(heldLease, branchOwner);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
190
|
return true;
|
|
200
191
|
});
|
|
201
192
|
if (!updated) {
|
|
@@ -17,6 +17,9 @@ export class ServiceIssueActions {
|
|
|
17
17
|
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
18
18
|
if (!issue)
|
|
19
19
|
return undefined;
|
|
20
|
+
if (!issue.delegatedToPatchRelay && !issue.activeRunId) {
|
|
21
|
+
return { error: "Issue is undelegated from PatchRelay; delegate it again before prompting work" };
|
|
22
|
+
}
|
|
20
23
|
this.feed.publish({
|
|
21
24
|
level: "info",
|
|
22
25
|
kind: "comment",
|
|
@@ -95,6 +98,8 @@ export class ServiceIssueActions {
|
|
|
95
98
|
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
96
99
|
if (!issue)
|
|
97
100
|
return undefined;
|
|
101
|
+
if (!issue.delegatedToPatchRelay)
|
|
102
|
+
return { error: "Issue is undelegated from PatchRelay; delegate it again before retrying" };
|
|
98
103
|
if (issue.activeRunId)
|
|
99
104
|
return { error: "Issue already has an active run" };
|
|
100
105
|
const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isResumablePausedLocalWork } from "./paused-issue-state.js";
|
|
1
2
|
export class ServiceStartupRecovery {
|
|
2
3
|
db;
|
|
3
4
|
linearProvider;
|
|
@@ -65,12 +66,14 @@ export class ServiceStartupRecovery {
|
|
|
65
66
|
});
|
|
66
67
|
const delegated = liveIssue.delegateId === installation.actorId;
|
|
67
68
|
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
68
|
-
const
|
|
69
|
-
|
|
69
|
+
const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
70
|
+
const shouldRecoverPausedLocalWork = delegated
|
|
71
|
+
&& isResumablePausedLocalWork({ issue, latestRun })
|
|
70
72
|
&& this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
|
|
71
73
|
const updated = this.db.issues.upsertIssue({
|
|
72
74
|
projectId: issue.projectId,
|
|
73
75
|
linearIssueId: issue.linearIssueId,
|
|
76
|
+
delegatedToPatchRelay: delegated,
|
|
74
77
|
...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
|
|
75
78
|
...(liveIssue.title ? { title: liveIssue.title } : {}),
|
|
76
79
|
...(liveIssue.description ? { description: liveIssue.description } : {}),
|
|
@@ -79,9 +82,9 @@ export class ServiceStartupRecovery {
|
|
|
79
82
|
...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
|
|
80
83
|
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
81
84
|
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
82
|
-
...(
|
|
85
|
+
...(shouldRecoverPausedLocalWork ? { factoryState: "delegated" } : {}),
|
|
83
86
|
});
|
|
84
|
-
if (!
|
|
87
|
+
if (!shouldRecoverPausedLocalWork) {
|
|
85
88
|
continue;
|
|
86
89
|
}
|
|
87
90
|
if (unresolvedBlockers === 0) {
|
|
@@ -94,10 +97,10 @@ export class ServiceStartupRecovery {
|
|
|
94
97
|
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
95
98
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
96
99
|
}
|
|
97
|
-
this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from
|
|
100
|
+
this.logger.info({ issueKey: updated.issueKey }, "Recovered delegated issue from paused local-work state and re-queued implementation");
|
|
98
101
|
}
|
|
99
102
|
else {
|
|
100
|
-
this.logger.info({ issueKey: updated.issueKey, unresolvedBlockers }, "Recovered delegated blocked issue from
|
|
103
|
+
this.logger.info({ issueKey: updated.issueKey, unresolvedBlockers }, "Recovered delegated blocked issue from paused local-work state");
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
106
|
}
|
package/dist/service.js
CHANGED
|
@@ -103,7 +103,6 @@ export class PatchRelayService {
|
|
|
103
103
|
const identity = this.githubAppTokenManager.botIdentity();
|
|
104
104
|
if (identity) {
|
|
105
105
|
this.orchestrator.botIdentity = identity;
|
|
106
|
-
this.githubWebhookHandler.setPatchRelayAuthorLogins([identity.name]);
|
|
107
106
|
}
|
|
108
107
|
}
|
|
109
108
|
await this.runtime.start();
|
|
@@ -80,7 +80,7 @@ export class TrackedIssueListQuery {
|
|
|
80
80
|
const rows = this.db.connection
|
|
81
81
|
.prepare(`SELECT
|
|
82
82
|
s.project_id, s.linear_issue_id, s.issue_key, i.title,
|
|
83
|
-
i.current_linear_state, i.factory_state, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
|
|
83
|
+
i.current_linear_state, i.factory_state, i.delegated_to_patchrelay, s.session_state, s.waiting_reason, s.summary_text, s.updated_at,
|
|
84
84
|
i.pending_run_type,
|
|
85
85
|
i.pr_number, i.pr_state, i.pr_head_sha, i.pr_review_state, i.pr_check_status, i.last_blocking_review_head_sha,
|
|
86
86
|
i.last_github_ci_snapshot_json,
|
|
@@ -157,6 +157,7 @@ export class TrackedIssueListQuery {
|
|
|
157
157
|
const readyForExecution = isIssueSessionReadyForExecution({
|
|
158
158
|
...(typeof row.session_state === "string" ? { sessionState: String(row.session_state) } : {}),
|
|
159
159
|
factoryState: String(row.factory_state ?? "delegated"),
|
|
160
|
+
...(row.delegated_to_patchrelay !== null ? { delegatedToPatchRelay: Number(row.delegated_to_patchrelay) !== 0 } : {}),
|
|
160
161
|
...(row.active_run_type !== null ? { activeRunId: 1 } : {}),
|
|
161
162
|
blockedByCount,
|
|
162
163
|
hasPendingWake,
|
|
@@ -175,6 +176,7 @@ export class TrackedIssueListQuery {
|
|
|
175
176
|
? row.summary_text
|
|
176
177
|
: undefined;
|
|
177
178
|
const waitingReason = sessionWaitingReason ?? derivePatchRelayWaitingReason({
|
|
179
|
+
...(row.delegated_to_patchrelay !== null ? { delegatedToPatchRelay: Number(row.delegated_to_patchrelay) !== 0 } : {}),
|
|
178
180
|
...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
|
|
179
181
|
blockedByKeys,
|
|
180
182
|
factoryState: String(row.factory_state ?? "delegated"),
|