patchrelay 0.38.1 → 0.39.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/cli/args.js +4 -0
- package/dist/cli/commands/issues.js +20 -1
- package/dist/cli/data.js +54 -7
- package/dist/cli/formatters/text.js +10 -0
- package/dist/cli/help.js +4 -0
- package/dist/cli/index.js +3 -0
- package/dist/config.js +26 -0
- package/dist/db/issue-store.js +10 -2
- package/dist/db/migrations.js +5 -0
- package/dist/factory-state.js +1 -0
- package/dist/github-webhook-handler.js +12 -0
- package/dist/github-webhook-late-publication-guard.js +94 -0
- package/dist/github-webhook-state-projector.js +15 -1
- package/dist/github-webhooks.js +39 -4
- package/dist/github-worktree-auth.js +18 -0
- package/dist/http.js +17 -0
- package/dist/idle-reconciliation.js +4 -2
- package/dist/issue-session-events.js +1 -0
- package/dist/linear-activity-key.js +11 -0
- package/dist/linear-agent-session-client.js +14 -1
- package/dist/linear-progress-facts.js +170 -0
- package/dist/linear-progress-reporter.js +21 -168
- package/dist/linear-status-comment-sync.js +3 -19
- package/dist/linear-workflow-state-sync.js +37 -18
- package/dist/manual-issue-actions.js +37 -0
- package/dist/merged-linear-completion-reconciler.js +102 -22
- package/dist/no-pr-completion-check.js +52 -0
- package/dist/presentation-text.js +11 -1
- package/dist/prompting/patchrelay.js +8 -6
- package/dist/run-budgets.js +12 -0
- package/dist/run-launcher.js +6 -6
- package/dist/run-notification-handler.js +4 -0
- package/dist/run-orchestrator.js +7 -1
- package/dist/run-wake-planner.js +11 -10
- package/dist/service-issue-actions.js +80 -27
- package/dist/service.js +3 -0
- package/dist/trusted-no-pr-completion.js +7 -0
- package/dist/webhooks/desired-stage-recorder.js +34 -10
- package/package.json +1 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
1
2
|
import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
3
|
+
import { isCompletedLinearState } from "./pr-state.js";
|
|
4
|
+
import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
|
|
2
5
|
export class MergedLinearCompletionReconciler {
|
|
3
6
|
db;
|
|
4
7
|
linearProvider;
|
|
@@ -10,39 +13,116 @@ export class MergedLinearCompletionReconciler {
|
|
|
10
13
|
}
|
|
11
14
|
async reconcile() {
|
|
12
15
|
for (const issue of this.db.issues.listIssues()) {
|
|
13
|
-
if (issue.prState !== "merged")
|
|
14
|
-
continue;
|
|
15
|
-
if (issue.currentLinearStateType?.trim().toLowerCase() === "completed")
|
|
16
|
+
if (issue.factoryState !== "done" && issue.prState !== "merged") {
|
|
16
17
|
continue;
|
|
18
|
+
}
|
|
17
19
|
const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
|
|
18
|
-
if (!linear)
|
|
20
|
+
if (!linear) {
|
|
19
21
|
continue;
|
|
22
|
+
}
|
|
20
23
|
try {
|
|
21
24
|
const liveIssue = await linear.getIssue(issue.linearIssueId);
|
|
22
|
-
|
|
23
|
-
if (!targetState)
|
|
24
|
-
continue;
|
|
25
|
-
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
26
|
-
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
27
|
-
this.db.issues.upsertIssue({
|
|
28
|
-
projectId: issue.projectId,
|
|
29
|
-
linearIssueId: issue.linearIssueId,
|
|
30
|
-
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
31
|
-
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
32
|
-
});
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
36
|
-
this.db.issues.upsertIssue({
|
|
25
|
+
this.db.issues.replaceIssueDependencies({
|
|
37
26
|
projectId: issue.projectId,
|
|
38
27
|
linearIssueId: issue.linearIssueId,
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
blockers: liveIssue.blockedBy.map((blocker) => ({
|
|
29
|
+
blockerLinearIssueId: blocker.id,
|
|
30
|
+
...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
|
|
31
|
+
...(blocker.title ? { blockerTitle: blocker.title } : {}),
|
|
32
|
+
...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
|
|
33
|
+
...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
|
|
34
|
+
})),
|
|
41
35
|
});
|
|
36
|
+
const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
37
|
+
const trustedNoPrDone = hasTrustedNoPrCompletion(issue, latestRun);
|
|
38
|
+
if (issue.prState === "merged" || trustedNoPrDone) {
|
|
39
|
+
await this.reconcileCompletedLinearState(issue, liveIssue, linear);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (issue.factoryState === "done" && !isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
|
|
43
|
+
this.reopenStaleLocalDoneIssue(issue, liveIssue);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
this.refreshCachedLinearState(issue, liveIssue);
|
|
47
|
+
}
|
|
42
48
|
}
|
|
43
49
|
catch (error) {
|
|
44
|
-
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged
|
|
50
|
+
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged or stale completed issue state");
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
53
|
}
|
|
54
|
+
async reconcileCompletedLinearState(issue, liveIssue, linear) {
|
|
55
|
+
if (isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
|
|
56
|
+
this.refreshCachedLinearState(issue, liveIssue);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const targetState = resolvePreferredCompletedLinearState(liveIssue);
|
|
60
|
+
if (!targetState) {
|
|
61
|
+
this.refreshCachedLinearState(issue, liveIssue);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
|
|
65
|
+
if (normalizedCurrent === targetState.trim().toLowerCase()) {
|
|
66
|
+
this.refreshCachedLinearState(issue, liveIssue);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
|
|
70
|
+
this.db.issues.upsertIssue({
|
|
71
|
+
projectId: issue.projectId,
|
|
72
|
+
linearIssueId: issue.linearIssueId,
|
|
73
|
+
...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
|
|
74
|
+
...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
reopenStaleLocalDoneIssue(issue, liveIssue) {
|
|
78
|
+
const restored = resolveOpenWorkflowState(issue);
|
|
79
|
+
this.db.issues.upsertIssue({
|
|
80
|
+
projectId: issue.projectId,
|
|
81
|
+
linearIssueId: issue.linearIssueId,
|
|
82
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
83
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
84
|
+
...(restored ? { factoryState: restored.factoryState } : {}),
|
|
85
|
+
...(restored ? { pendingRunType: restored.pendingRunType } : {}),
|
|
86
|
+
});
|
|
87
|
+
this.logger.info({
|
|
88
|
+
issueKey: issue.issueKey,
|
|
89
|
+
previousFactoryState: issue.factoryState,
|
|
90
|
+
restoredFactoryState: restored?.factoryState,
|
|
91
|
+
liveLinearState: liveIssue.stateName,
|
|
92
|
+
}, "Reopened stale local done state from live Linear workflow");
|
|
93
|
+
}
|
|
94
|
+
refreshCachedLinearState(issue, liveIssue) {
|
|
95
|
+
this.db.issues.upsertIssue({
|
|
96
|
+
projectId: issue.projectId,
|
|
97
|
+
linearIssueId: issue.linearIssueId,
|
|
98
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
99
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function resolveOpenWorkflowState(issue) {
|
|
104
|
+
const reactiveIntent = deriveIssueSessionReactiveIntent({
|
|
105
|
+
delegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
106
|
+
prNumber: issue.prNumber,
|
|
107
|
+
prState: issue.prState,
|
|
108
|
+
prReviewState: issue.prReviewState,
|
|
109
|
+
prCheckStatus: issue.prCheckStatus,
|
|
110
|
+
latestFailureSource: issue.lastGitHubFailureSource,
|
|
111
|
+
});
|
|
112
|
+
if (reactiveIntent) {
|
|
113
|
+
return {
|
|
114
|
+
factoryState: reactiveIntent.compatibilityFactoryState,
|
|
115
|
+
pendingRunType: reactiveIntent.runType,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (issue.prNumber !== undefined && (issue.prState === undefined || issue.prState === "open")) {
|
|
119
|
+
if (issue.prReviewState === "approved" && (issue.prCheckStatus === "success" || issue.prCheckStatus === "passed")) {
|
|
120
|
+
return { factoryState: "awaiting_queue", pendingRunType: null };
|
|
121
|
+
}
|
|
122
|
+
return { factoryState: "pr_open", pendingRunType: null };
|
|
123
|
+
}
|
|
124
|
+
if (issue.delegatedToPatchRelay) {
|
|
125
|
+
return { factoryState: "delegated", pendingRunType: null };
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
48
128
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { buildCompletionCheckActivity } from "./linear-session-reporting.js";
|
|
2
|
+
function shouldContinueForUnpublishedLocalChanges(message) {
|
|
3
|
+
const normalized = message.trim().toLowerCase();
|
|
4
|
+
if (!normalized)
|
|
5
|
+
return false;
|
|
6
|
+
return normalized.includes("worktree still has")
|
|
7
|
+
|| (normalized.includes("local commit") && normalized.includes("ahead of origin/"));
|
|
8
|
+
}
|
|
2
9
|
export async function handleNoPrCompletionCheck(params) {
|
|
3
10
|
const completedRunUpdate = buildCompletedRunUpdate({
|
|
4
11
|
threadId: params.threadId,
|
|
@@ -115,6 +122,51 @@ export async function handleNoPrCompletionCheck(params) {
|
|
|
115
122
|
return;
|
|
116
123
|
}
|
|
117
124
|
if (completionCheck.outcome === "done") {
|
|
125
|
+
if (shouldContinueForUnpublishedLocalChanges(params.publishedOutcomeError)) {
|
|
126
|
+
const continued = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
|
|
127
|
+
params.db.runs.finishRun(params.run.id, completedRunUpdate);
|
|
128
|
+
params.db.runs.saveCompletionCheck(params.run.id, {
|
|
129
|
+
...completionCheck,
|
|
130
|
+
outcome: "continue",
|
|
131
|
+
summary: "PatchRelay changed files locally but has not published them yet; continuing automatically to finish publication.",
|
|
132
|
+
why: params.publishedOutcomeError,
|
|
133
|
+
});
|
|
134
|
+
params.db.issues.upsertIssue({
|
|
135
|
+
projectId: params.run.projectId,
|
|
136
|
+
linearIssueId: params.run.linearIssueId,
|
|
137
|
+
activeRunId: null,
|
|
138
|
+
factoryState: "delegated",
|
|
139
|
+
pendingRunType: null,
|
|
140
|
+
pendingRunContextJson: null,
|
|
141
|
+
});
|
|
142
|
+
return Boolean(params.db.issueSessions.appendIssueSessionEventWithLease(lease, {
|
|
143
|
+
projectId: params.run.projectId,
|
|
144
|
+
linearIssueId: params.run.linearIssueId,
|
|
145
|
+
eventType: "completion_check_continue",
|
|
146
|
+
eventJson: JSON.stringify({
|
|
147
|
+
runType: params.run.runType,
|
|
148
|
+
summary: params.publishedOutcomeError,
|
|
149
|
+
}),
|
|
150
|
+
dedupeKey: `completion_check_continue:${params.run.id}`,
|
|
151
|
+
}));
|
|
152
|
+
});
|
|
153
|
+
if (!continued) {
|
|
154
|
+
params.logger.warn({ runId: params.run.id, issueId: params.run.linearIssueId }, "Skipping completion-check continue writes after losing issue-session lease");
|
|
155
|
+
params.clearProgressAndRelease(params.run);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
params.syncCompletionCheckOutcome({
|
|
159
|
+
run: params.run,
|
|
160
|
+
fallbackIssue: params.issue,
|
|
161
|
+
level: "info",
|
|
162
|
+
status: "completion_check_continue",
|
|
163
|
+
summary: "No PR found; continuing automatically to finish publication",
|
|
164
|
+
detail: params.publishedOutcomeError,
|
|
165
|
+
activity: buildCompletionCheckActivity("continue"),
|
|
166
|
+
enqueue: true,
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
118
170
|
const completed = params.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
|
|
119
171
|
params.db.runs.finishRun(params.run.id, completedRunUpdate);
|
|
120
172
|
params.db.runs.saveCompletionCheck(params.run.id, completionCheck);
|
|
@@ -5,6 +5,16 @@ function unwrapShellWrappedCommand(text) {
|
|
|
5
5
|
.replace(/^(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+'([^`\n]+)'$/g, "$1")
|
|
6
6
|
.replace(/^(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+"([^`\n]+)"$/g, "$1");
|
|
7
7
|
}
|
|
8
|
+
function stripLocalMarkdownLinks(text) {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/\[([^\]]+)\]\(<\/[^)>]+>\)/g, "`$1`")
|
|
11
|
+
.replace(/\[([^\]]+)\]\((\/[^)\s]+)\)/g, "`$1`");
|
|
12
|
+
}
|
|
13
|
+
function stripLocalAbsolutePaths(text) {
|
|
14
|
+
return text
|
|
15
|
+
.replace(/`\/(?:home|Users|private|tmp|var\/folders)\/[^`\n]+`/g, "`local path omitted`")
|
|
16
|
+
.replace(/(^|[\s(])\/(?:home|Users|private|tmp|var\/folders)\/[^\s)]+/g, "$1`local path omitted`");
|
|
17
|
+
}
|
|
8
18
|
export function sanitizeOperatorFacingCommand(command) {
|
|
9
19
|
const trimmed = command?.trim();
|
|
10
20
|
if (!trimmed) {
|
|
@@ -17,5 +27,5 @@ export function sanitizeOperatorFacingText(text) {
|
|
|
17
27
|
if (!trimmed) {
|
|
18
28
|
return undefined;
|
|
19
29
|
}
|
|
20
|
-
return unwrapShellWrappedCommand(trimmed);
|
|
30
|
+
return stripLocalAbsolutePaths(stripLocalMarkdownLinks(unwrapShellWrappedCommand(trimmed)));
|
|
21
31
|
}
|
|
@@ -184,12 +184,12 @@ function buildRequestedChangesContext(runType, context) {
|
|
|
184
184
|
const mode = resolveRequestedChangesMode(runType, context);
|
|
185
185
|
const lines = [];
|
|
186
186
|
if (mode === "branch_upkeep") {
|
|
187
|
-
lines.push("## Branch Upkeep After Requested Changes", "", "The requested review changes may already be addressed, but GitHub still shows the PR branch as behind or dirty against the base branch.", "Update the existing PR branch onto the latest base branch, resolve conflicts carefully, rerun the narrowest relevant verification, and push a newer head.", "Do not open a new PR.", "");
|
|
187
|
+
lines.push("## Branch Upkeep After Requested Changes", "", "Goal: restore merge readiness on the existing PR branch without regressing review or CI readiness.", "The requested review changes may already be addressed, but GitHub still shows the PR branch as behind or dirty against the base branch.", "Update the existing PR branch onto the latest base branch, resolve conflicts carefully, rerun the narrowest relevant verification, and push a newer head.", "Do not open a new PR.", "", "1. Refresh the latest remote branch and base branch state first.", "2. Rebase or merge onto the latest base branch and resolve conflicts in a way that preserves the branch's current intent and prior fixes.", "3. Audit the conflicted areas for semantic regressions, not just textual conflicts.", "4. Run focused verification for the touched areas and enough surrounding checks to regain confidence that the branch is still review-ready.", "5. Commit and push a newer head on the existing PR branch.", "6. Do not stop at 'conflicts resolved' if the resulting branch is no longer likely to pass review or CI.", "");
|
|
188
188
|
}
|
|
189
189
|
else {
|
|
190
190
|
const reviewer = typeof context?.reviewerName === "string" ? context.reviewerName : undefined;
|
|
191
191
|
const reviewBody = typeof context?.reviewBody === "string" ? context.reviewBody.trim() : "";
|
|
192
|
-
lines.push("## Review Changes Requested", "", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "", "", "1. Start with the structured review context below
|
|
192
|
+
lines.push("## Review Changes Requested", "", "Goal: restore review readiness on the existing PR branch, not merely patch the latest cited line.", "Treat the reviewer comments as evidence of what still makes the branch unready. Your job is to return the branch to a state that is likely to pass the next full review.", "", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "", "", "1. Start with the structured review context below, then inspect the current diff (`git diff origin/main`) and current code before deciding what still needs work.", "2. Infer the underlying concern or invariant behind the review feedback. Do not assume each comment is an isolated chore.", "3. For each review point: if already resolved on the current head, note why. If not, fix it. Then inspect adjacent code paths and flows that could fail for the same reason.", "4. If the structured review context looks incomplete, inspect the latest GitHub review threads directly before deciding you are done.", "5. Verify the branch as a whole for the relevant concern class: current review issue, nearby regressions, relevant tests, and compatibility with the latest base branch.", "6. Only finish when you believe the branch is review-ready again. If you cannot get it there, stop and surface the blocker clearly.", "7. Commit and push a newer head on the existing PR branch. Do not try to hand the same head back to review.", "8. GitHub review happens after the new head is pushed and CI is green. Do not use `gh pr edit --add-reviewer` as part of this workflow.", "");
|
|
193
193
|
appendStructuredReviewContext(lines, context);
|
|
194
194
|
}
|
|
195
195
|
return lines.join("\n").trim();
|
|
@@ -201,7 +201,8 @@ function buildCiRepairContext(context) {
|
|
|
201
201
|
return [
|
|
202
202
|
"## CI Repair",
|
|
203
203
|
"",
|
|
204
|
-
"
|
|
204
|
+
"Goal: restore CI readiness on the existing PR branch so the next full CI run is likely to pass.",
|
|
205
|
+
"A full CI iteration has settled failed on your PR. Start from the specific failing check/job/step below on the latest remote PR branch tip, but do not stop at a narrow patch if the same root cause is likely to fail other checks in the suite.",
|
|
205
206
|
snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "",
|
|
206
207
|
snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "",
|
|
207
208
|
snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "",
|
|
@@ -219,10 +220,11 @@ function buildCiRepairContext(context) {
|
|
|
219
220
|
: "",
|
|
220
221
|
"",
|
|
221
222
|
"Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.",
|
|
222
|
-
"Read the latest logs for the named failing check,
|
|
223
|
+
"Read the latest logs for the named failing check, identify the root cause, and check whether that same cause is likely to affect other jobs or checks.",
|
|
224
|
+
"Fix the root cause, not just the first visible symptom.",
|
|
223
225
|
"Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.",
|
|
224
|
-
"Run
|
|
225
|
-
"Do not open a new PR. Keep working on the existing branch until CI
|
|
226
|
+
"Run the narrowest local verification that gives real confidence for the suite, then commit and push.",
|
|
227
|
+
"Do not open a new PR. Keep working on the existing branch until the branch is likely to pass CI again or the situation is clearly stuck.",
|
|
226
228
|
"Do not change test expectations unless the test is genuinely wrong.",
|
|
227
229
|
].filter(Boolean).join("\n");
|
|
228
230
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_CI_REPAIR_BUDGET = 3;
|
|
2
|
+
export const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
|
|
3
|
+
export const DEFAULT_REVIEW_FIX_BUDGET = 3;
|
|
4
|
+
export function getCiRepairBudget(project) {
|
|
5
|
+
return project?.repairBudgets?.ciRepair ?? DEFAULT_CI_REPAIR_BUDGET;
|
|
6
|
+
}
|
|
7
|
+
export function getQueueRepairBudget(project) {
|
|
8
|
+
return project?.repairBudgets?.queueRepair ?? DEFAULT_QUEUE_REPAIR_BUDGET;
|
|
9
|
+
}
|
|
10
|
+
export function getReviewFixBudget(project) {
|
|
11
|
+
return project?.repairBudgets?.reviewFix ?? DEFAULT_REVIEW_FIX_BUDGET;
|
|
12
|
+
}
|
package/dist/run-launcher.js
CHANGED
|
@@ -2,7 +2,7 @@ import { buildHookEnv, runProjectHook } from "./hook-runner.js";
|
|
|
2
2
|
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
3
3
|
import { loadPatchRelayRepoPrompting } from "./patchrelay-customization.js";
|
|
4
4
|
import { buildRunPrompt as buildPatchRelayRunPrompt, findDisallowedPatchRelayPromptSectionIds, findUnknownPatchRelayPromptSectionIds, mergePromptCustomizationLayers, resolvePromptLayers, } from "./prompting/patchrelay.js";
|
|
5
|
-
import {
|
|
5
|
+
import { configureGitHubBotAuthForWorktree } from "./github-worktree-auth.js";
|
|
6
6
|
function slugify(value) {
|
|
7
7
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
8
8
|
}
|
|
@@ -116,11 +116,11 @@ export class RunLauncher {
|
|
|
116
116
|
try {
|
|
117
117
|
await this.worktreeManager.ensureIssueWorktree(params.project.repoPath, params.project.worktreeRoot, params.worktreePath, params.branchName, { allowExistingOutsideRoot: params.issue.branchName !== undefined });
|
|
118
118
|
if (params.botIdentity) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
await configureGitHubBotAuthForWorktree({
|
|
120
|
+
gitBin: this.config.runner.gitBin,
|
|
121
|
+
worktreePath: params.worktreePath,
|
|
122
|
+
botIdentity: params.botIdentity,
|
|
123
|
+
});
|
|
124
124
|
}
|
|
125
125
|
await this.worktreeManager.resetWorktreeToTrackedBranch(params.worktreePath, params.branchName, params.issue, this.logger);
|
|
126
126
|
if (params.runType !== "queue_repair") {
|
|
@@ -41,6 +41,10 @@ export class RunNotificationHandler {
|
|
|
41
41
|
const run = this.db.runs.getRunByThreadId(threadId);
|
|
42
42
|
if (!run)
|
|
43
43
|
return;
|
|
44
|
+
if (run.status !== "running") {
|
|
45
|
+
this.logger.info({ runId: run.id, status: run.status, issueId: run.linearIssueId }, "Ignoring Codex notification for inactive run");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
44
48
|
if (!this.heartbeatIssueSessionLease(run.projectId, run.linearIssueId)) {
|
|
45
49
|
this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Ignoring Codex notification after losing issue-session lease");
|
|
46
50
|
return;
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -138,6 +138,12 @@ export class RunOrchestrator {
|
|
|
138
138
|
return;
|
|
139
139
|
}
|
|
140
140
|
const { runType, context, resumeThread } = wake;
|
|
141
|
+
if (runType === "implementation" && this.db.issues.countUnresolvedBlockers(item.projectId, item.issueId) > 0) {
|
|
142
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(item.projectId, item.issueId);
|
|
143
|
+
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
144
|
+
this.logger.info({ issueKey: issue.issueKey }, "Skipped implementation launch because the issue is blocked");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
141
147
|
const remainingZombieDelayMs = shouldDelayZombieRecoveryLaunch(issue, issueSession, runType);
|
|
142
148
|
if (remainingZombieDelayMs > 0) {
|
|
143
149
|
this.logger.debug({ issueKey: issue.issueKey, runType, remainingZombieDelayMs }, "Deferring recovered run launch until zombie backoff elapses");
|
|
@@ -152,7 +158,7 @@ export class RunOrchestrator {
|
|
|
152
158
|
: typeof effectiveContext?.headSha === "string"
|
|
153
159
|
? effectiveContext.headSha
|
|
154
160
|
: issue.prHeadSha;
|
|
155
|
-
const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, runType, isRequestedChangesRunType);
|
|
161
|
+
const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, project, runType, isRequestedChangesRunType);
|
|
156
162
|
if (budgetExceeded) {
|
|
157
163
|
this.escalate(issue, runType, budgetExceeded);
|
|
158
164
|
return;
|
package/dist/run-wake-planner.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
|
|
3
|
-
const DEFAULT_REVIEW_FIX_BUDGET = 12;
|
|
1
|
+
import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
|
|
4
2
|
export class RunWakePlanner {
|
|
5
3
|
db;
|
|
6
4
|
constructor(db) {
|
|
@@ -62,15 +60,18 @@ export class RunWakePlanner {
|
|
|
62
60
|
return issue;
|
|
63
61
|
return this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
|
|
64
62
|
}
|
|
65
|
-
budgetExceeded(issue, runType, isRequestedChangesRunType) {
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
budgetExceeded(issue, project, runType, isRequestedChangesRunType) {
|
|
64
|
+
const ciRepairBudget = getCiRepairBudget(project);
|
|
65
|
+
if (runType === "ci_repair" && issue.ciRepairAttempts >= ciRepairBudget) {
|
|
66
|
+
return `CI repair budget exhausted (${ciRepairBudget} attempts)`;
|
|
68
67
|
}
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
const queueRepairBudget = getQueueRepairBudget(project);
|
|
69
|
+
if (runType === "queue_repair" && issue.queueRepairAttempts >= queueRepairBudget) {
|
|
70
|
+
return `Queue repair budget exhausted (${queueRepairBudget} attempts)`;
|
|
71
71
|
}
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const reviewFixBudget = getReviewFixBudget(project);
|
|
73
|
+
if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= reviewFixBudget) {
|
|
74
|
+
return `Requested-changes budget exhausted (${reviewFixBudget} attempts)`;
|
|
74
75
|
}
|
|
75
76
|
return undefined;
|
|
76
77
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildOperatorRetryEvent } from "./operator-retry-event.js";
|
|
2
|
-
import {
|
|
2
|
+
import { buildManualRetryAttemptReset, resolveRetryTarget } from "./manual-issue-actions.js";
|
|
3
3
|
export class ServiceIssueActions {
|
|
4
4
|
db;
|
|
5
5
|
codex;
|
|
@@ -103,7 +103,16 @@ export class ServiceIssueActions {
|
|
|
103
103
|
if (issue.activeRunId)
|
|
104
104
|
return { error: "Issue already has an active run" };
|
|
105
105
|
const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
|
|
106
|
-
|
|
106
|
+
const retryTarget = resolveRetryTarget({
|
|
107
|
+
prNumber: issue.prNumber,
|
|
108
|
+
prState: issue.prState,
|
|
109
|
+
prReviewState: issue.prReviewState,
|
|
110
|
+
prCheckStatus: issue.prCheckStatus,
|
|
111
|
+
pendingRunType: issue.pendingRunType,
|
|
112
|
+
lastRunType: issueSession?.lastRunType,
|
|
113
|
+
lastGitHubFailureSource: issue.lastGitHubFailureSource,
|
|
114
|
+
});
|
|
115
|
+
if (retryTarget.runType === "none") {
|
|
107
116
|
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
108
117
|
projectId: issue.projectId,
|
|
109
118
|
linearIssueId: issue.linearIssueId,
|
|
@@ -111,45 +120,89 @@ export class ServiceIssueActions {
|
|
|
111
120
|
});
|
|
112
121
|
return { issueKey, runType: "none" };
|
|
113
122
|
}
|
|
114
|
-
|
|
115
|
-
let factoryState = "delegated";
|
|
116
|
-
if (hasOpenPr(issue.prNumber, issue.prState) && issue.lastGitHubFailureSource === "queue_eviction") {
|
|
117
|
-
runType = "queue_repair";
|
|
118
|
-
factoryState = "repairing_queue";
|
|
119
|
-
}
|
|
120
|
-
else if (hasOpenPr(issue.prNumber, issue.prState) && (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure" || issue.lastGitHubFailureSource === "branch_ci")) {
|
|
121
|
-
runType = "ci_repair";
|
|
122
|
-
factoryState = "repairing_ci";
|
|
123
|
-
}
|
|
124
|
-
else if (hasOpenPr(issue.prNumber, issue.prState) && issue.prReviewState === "changes_requested") {
|
|
125
|
-
runType = issue.pendingRunType === "branch_upkeep" || issueSession?.lastRunType === "branch_upkeep"
|
|
126
|
-
? "branch_upkeep"
|
|
127
|
-
: "review_fix";
|
|
128
|
-
factoryState = "changes_requested";
|
|
129
|
-
}
|
|
130
|
-
else if (hasOpenPr(issue.prNumber, issue.prState)) {
|
|
131
|
-
runType = "implementation";
|
|
132
|
-
factoryState = "implementing";
|
|
133
|
-
}
|
|
134
|
-
this.appendOperatorRetryEvent(issue, runType);
|
|
123
|
+
this.appendOperatorRetryEvent(issue, retryTarget.runType);
|
|
135
124
|
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
136
125
|
projectId: issue.projectId,
|
|
137
126
|
linearIssueId: issue.linearIssueId,
|
|
138
|
-
factoryState: factoryState,
|
|
127
|
+
factoryState: retryTarget.factoryState,
|
|
128
|
+
...buildManualRetryAttemptReset(retryTarget.runType),
|
|
139
129
|
});
|
|
140
130
|
this.feed.publish({
|
|
141
131
|
level: "info",
|
|
142
132
|
kind: "stage",
|
|
143
133
|
issueKey: issue.issueKey,
|
|
144
134
|
projectId: issue.projectId,
|
|
145
|
-
stage: factoryState,
|
|
135
|
+
stage: retryTarget.factoryState,
|
|
146
136
|
status: "retry",
|
|
147
|
-
summary: `Retry queued: ${runType}`,
|
|
137
|
+
summary: `Retry queued: ${retryTarget.runType}`,
|
|
148
138
|
});
|
|
149
139
|
if (this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
|
|
150
140
|
this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
151
141
|
}
|
|
152
|
-
return { issueKey, runType };
|
|
142
|
+
return { issueKey, runType: retryTarget.runType };
|
|
143
|
+
}
|
|
144
|
+
async closeIssue(issueKey, options) {
|
|
145
|
+
const issue = this.db.issues.getIssueByKey(issueKey);
|
|
146
|
+
if (!issue)
|
|
147
|
+
return undefined;
|
|
148
|
+
const terminalState = options?.failed ? "failed" : "done";
|
|
149
|
+
const run = issue.activeRunId ? this.db.runs.getRunById(issue.activeRunId) : undefined;
|
|
150
|
+
if (run?.threadId && run.turnId) {
|
|
151
|
+
try {
|
|
152
|
+
await this.codex.steerTurn({
|
|
153
|
+
threadId: run.threadId,
|
|
154
|
+
turnId: run.turnId,
|
|
155
|
+
input: `STOP: The operator manually closed this issue in PatchRelay as ${terminalState}. Stop working immediately and exit without making further changes.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// The turn may already be settled.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
163
|
+
projectId: issue.projectId,
|
|
164
|
+
linearIssueId: issue.linearIssueId,
|
|
165
|
+
eventType: "operator_closed",
|
|
166
|
+
eventJson: JSON.stringify({
|
|
167
|
+
terminalState,
|
|
168
|
+
...(options?.reason ? { reason: options.reason } : {}),
|
|
169
|
+
}),
|
|
170
|
+
dedupeKey: `operator_closed:${issue.linearIssueId}:${terminalState}:${issue.activeRunId ?? "no-run"}`,
|
|
171
|
+
});
|
|
172
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
173
|
+
if (run) {
|
|
174
|
+
this.db.issueSessions.finishRunRespectingActiveLease(issue.projectId, issue.linearIssueId, run.id, {
|
|
175
|
+
status: "released",
|
|
176
|
+
failureReason: options?.reason
|
|
177
|
+
? `Operator closed issue as ${terminalState}: ${options.reason}`
|
|
178
|
+
: `Operator closed issue as ${terminalState}`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
182
|
+
projectId: issue.projectId,
|
|
183
|
+
linearIssueId: issue.linearIssueId,
|
|
184
|
+
factoryState: terminalState,
|
|
185
|
+
activeRunId: null,
|
|
186
|
+
pendingRunType: null,
|
|
187
|
+
pendingRunContextJson: null,
|
|
188
|
+
});
|
|
189
|
+
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
190
|
+
this.feed.publish({
|
|
191
|
+
level: terminalState === "failed" ? "warn" : "info",
|
|
192
|
+
kind: "workflow",
|
|
193
|
+
issueKey: issue.issueKey,
|
|
194
|
+
projectId: issue.projectId,
|
|
195
|
+
stage: terminalState,
|
|
196
|
+
status: "operator_closed",
|
|
197
|
+
summary: options?.reason
|
|
198
|
+
? `Operator closed issue as ${terminalState}: ${options.reason}`
|
|
199
|
+
: `Operator closed issue as ${terminalState}`,
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
issueKey,
|
|
203
|
+
factoryState: terminalState,
|
|
204
|
+
...(run ? { releasedRunId: run.id } : {}),
|
|
205
|
+
};
|
|
153
206
|
}
|
|
154
207
|
queueOperatorPrompt(issue, text, source) {
|
|
155
208
|
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
package/dist/service.js
CHANGED
|
@@ -215,6 +215,9 @@ export class PatchRelayService {
|
|
|
215
215
|
retryIssue(issueKey) {
|
|
216
216
|
return this.issueActions.retryIssue(issueKey);
|
|
217
217
|
}
|
|
218
|
+
async closeIssue(issueKey, options) {
|
|
219
|
+
return await this.issueActions.closeIssue(issueKey, options);
|
|
220
|
+
}
|
|
218
221
|
async acceptWebhook(params) {
|
|
219
222
|
const result = await acceptIncomingWebhook({
|
|
220
223
|
config: this.config,
|