patchrelay 0.38.1 → 0.38.2
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/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-reporter.js +7 -181
- package/dist/linear-status-comment-sync.js +3 -19
- package/dist/manual-issue-actions.js +37 -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-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/webhooks/desired-stage-recorder.js +34 -10
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { sanitizeOperatorFacingText } from "./presentation-text.js";
|
|
2
|
+
export function computeLinearActivityKey(content) {
|
|
3
|
+
if (content.type === "action") {
|
|
4
|
+
const action = sanitizeOperatorFacingText(content.action) ?? content.action;
|
|
5
|
+
const parameter = sanitizeOperatorFacingText(content.parameter) ?? content.parameter;
|
|
6
|
+
const result = sanitizeOperatorFacingText(content.result);
|
|
7
|
+
return `action:${action}:${parameter}:${result ?? ""}`;
|
|
8
|
+
}
|
|
9
|
+
const body = sanitizeOperatorFacingText(content.body) ?? content.body;
|
|
10
|
+
return `${content.type}:${body}`;
|
|
11
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
|
|
2
2
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
3
|
+
import { computeLinearActivityKey } from "./linear-activity-key.js";
|
|
3
4
|
export class LinearAgentSessionClient {
|
|
4
5
|
config;
|
|
5
6
|
db;
|
|
@@ -36,11 +37,23 @@ export class LinearAgentSessionClient {
|
|
|
36
37
|
if (!linear)
|
|
37
38
|
return;
|
|
38
39
|
const allowEphemeral = content.type === "thought" || content.type === "action";
|
|
40
|
+
const ephemeral = options?.ephemeral && allowEphemeral;
|
|
41
|
+
const activityKey = ephemeral ? undefined : computeLinearActivityKey(content);
|
|
42
|
+
if (activityKey && syncedIssue.lastLinearActivityKey === activityKey) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
39
45
|
await linear.createAgentActivity({
|
|
40
46
|
agentSessionId: syncedIssue.agentSessionId,
|
|
41
47
|
content,
|
|
42
|
-
...(
|
|
48
|
+
...(ephemeral ? { ephemeral: true } : {}),
|
|
43
49
|
});
|
|
50
|
+
if (activityKey) {
|
|
51
|
+
this.db.issues.upsertIssue({
|
|
52
|
+
projectId: syncedIssue.projectId,
|
|
53
|
+
linearIssueId: syncedIssue.linearIssueId,
|
|
54
|
+
lastLinearActivityKey: activityKey,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
44
57
|
}
|
|
45
58
|
catch (error) {
|
|
46
59
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -1,185 +1,11 @@
|
|
|
1
|
-
import { sanitizeOperatorFacingCommand, sanitizeOperatorFacingText } from "./presentation-text.js";
|
|
2
|
-
const PROGRESS_THROTTLE_MS = 5_000;
|
|
3
|
-
const MAX_PROGRESS_TEXT_LENGTH = 220;
|
|
4
1
|
export class LinearProgressReporter {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
agentMessageProgressPublished = new Set();
|
|
11
|
-
constructor(db, emitActivity) {
|
|
12
|
-
this.db = db;
|
|
13
|
-
this.emitActivity = emitActivity;
|
|
2
|
+
constructor(_db, _emitActivity) { }
|
|
3
|
+
maybeEmitProgress(_notification, _run) {
|
|
4
|
+
// Keep routine Codex progress in local/operator surfaces rather than
|
|
5
|
+
// turning every planning or reasoning update into Linear thread chatter.
|
|
6
|
+
return;
|
|
14
7
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (!issue)
|
|
18
|
-
return;
|
|
19
|
-
const agentSentence = this.consumeAgentMessageSentence(notification, run);
|
|
20
|
-
const workingOn = resolveWorkingOnActivity(notification, agentSentence?.sentence);
|
|
21
|
-
if (workingOn && !this.workingOnPublishedRuns.has(run.id)) {
|
|
22
|
-
this.workingOnPublishedRuns.add(run.id);
|
|
23
|
-
void this.emitActivity(issue, workingOn);
|
|
24
|
-
}
|
|
25
|
-
const progress = resolveEphemeralProgressActivity(notification, agentSentence?.sentence);
|
|
26
|
-
if (!progress)
|
|
27
|
-
return;
|
|
28
|
-
if (!progress.bypassThrottle) {
|
|
29
|
-
const now = Date.now();
|
|
30
|
-
const lastEmit = this.progressThrottle.get(run.id) ?? 0;
|
|
31
|
-
if (now - lastEmit < PROGRESS_THROTTLE_MS)
|
|
32
|
-
return;
|
|
33
|
-
this.progressThrottle.set(run.id, now);
|
|
34
|
-
}
|
|
35
|
-
void this.emitActivity(issue, progress.activity, { ephemeral: true });
|
|
8
|
+
clearProgress(_runId) {
|
|
9
|
+
return;
|
|
36
10
|
}
|
|
37
|
-
clearProgress(runId) {
|
|
38
|
-
this.progressThrottle.delete(runId);
|
|
39
|
-
this.workingOnPublishedRuns.delete(runId);
|
|
40
|
-
for (const key of this.agentMessageBuffers.keys()) {
|
|
41
|
-
if (key.startsWith(`${runId}:`)) {
|
|
42
|
-
this.agentMessageBuffers.delete(key);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
for (const key of this.agentMessageProgressPublished) {
|
|
46
|
-
if (key.startsWith(`${runId}:`)) {
|
|
47
|
-
this.agentMessageProgressPublished.delete(key);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
consumeAgentMessageSentence(notification, run) {
|
|
52
|
-
const messageKey = resolveAgentMessageKey(notification, run);
|
|
53
|
-
if (!messageKey)
|
|
54
|
-
return undefined;
|
|
55
|
-
if (this.agentMessageProgressPublished.has(messageKey))
|
|
56
|
-
return undefined;
|
|
57
|
-
const delta = resolveAgentMessageDelta(notification);
|
|
58
|
-
if (delta) {
|
|
59
|
-
const previous = this.agentMessageBuffers.get(messageKey) ?? "";
|
|
60
|
-
const next = `${previous}${delta}`;
|
|
61
|
-
this.agentMessageBuffers.set(messageKey, next);
|
|
62
|
-
const sentence = extractFirstCompletedSentence(next);
|
|
63
|
-
if (!sentence)
|
|
64
|
-
return undefined;
|
|
65
|
-
this.agentMessageProgressPublished.add(messageKey);
|
|
66
|
-
return { sentence };
|
|
67
|
-
}
|
|
68
|
-
const completedText = resolveCompletedAgentMessageText(notification);
|
|
69
|
-
if (!completedText)
|
|
70
|
-
return undefined;
|
|
71
|
-
const sentence = extractFirstSentence(completedText);
|
|
72
|
-
if (!sentence)
|
|
73
|
-
return undefined;
|
|
74
|
-
this.agentMessageProgressPublished.add(messageKey);
|
|
75
|
-
return { sentence };
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
function resolveWorkingOnActivity(notification, agentSentence) {
|
|
79
|
-
const summary = resolveWorkingOnSummary(notification) ?? agentSentence;
|
|
80
|
-
if (!summary)
|
|
81
|
-
return undefined;
|
|
82
|
-
return { type: "response", body: `Working on: ${summary}` };
|
|
83
|
-
}
|
|
84
|
-
function resolveEphemeralProgressActivity(notification, agentSentence) {
|
|
85
|
-
if (notification.method === "item/started") {
|
|
86
|
-
const item = notification.params.item;
|
|
87
|
-
if (!item)
|
|
88
|
-
return undefined;
|
|
89
|
-
const type = typeof item.type === "string" ? item.type : undefined;
|
|
90
|
-
if (type === "commandExecution") {
|
|
91
|
-
const cmd = item.command;
|
|
92
|
-
const cmdStr = Array.isArray(cmd)
|
|
93
|
-
? sanitizeOperatorFacingCommand(cmd.map((part) => String(part)).join(" "))
|
|
94
|
-
: sanitizeOperatorFacingCommand(typeof cmd === "string" ? cmd : undefined);
|
|
95
|
-
return { activity: { type: "action", action: "Running", parameter: truncateProgressText(cmdStr ?? "command", 120) } };
|
|
96
|
-
}
|
|
97
|
-
if (type === "mcpToolCall") {
|
|
98
|
-
const server = typeof item.server === "string" ? item.server : "";
|
|
99
|
-
const tool = typeof item.tool === "string" ? item.tool : "";
|
|
100
|
-
return { activity: { type: "action", action: "Using", parameter: `${server}/${tool}` } };
|
|
101
|
-
}
|
|
102
|
-
if (type === "dynamicToolCall") {
|
|
103
|
-
const tool = typeof item.tool === "string" ? item.tool : "tool";
|
|
104
|
-
return { activity: { type: "action", action: "Using", parameter: tool } };
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (agentSentence) {
|
|
108
|
-
return {
|
|
109
|
-
activity: { type: "thought", body: agentSentence },
|
|
110
|
-
bypassThrottle: true,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
return undefined;
|
|
114
|
-
}
|
|
115
|
-
function resolveWorkingOnSummary(notification) {
|
|
116
|
-
if (notification.method !== "turn/plan/updated") {
|
|
117
|
-
return undefined;
|
|
118
|
-
}
|
|
119
|
-
const plan = notification.params.plan;
|
|
120
|
-
if (!Array.isArray(plan))
|
|
121
|
-
return undefined;
|
|
122
|
-
const ranked = plan
|
|
123
|
-
.map((entry) => entry)
|
|
124
|
-
.filter((entry) => typeof entry.step === "string" && entry.step.trim().length > 0)
|
|
125
|
-
.sort((a, b) => rankPlanStatus(a.status) - rankPlanStatus(b.status));
|
|
126
|
-
const first = ranked[0];
|
|
127
|
-
return summarizeProgressSentence(typeof first?.step === "string" ? first.step : undefined);
|
|
128
|
-
}
|
|
129
|
-
function rankPlanStatus(status) {
|
|
130
|
-
return status === "inProgress" ? 0
|
|
131
|
-
: status === "pending" ? 1
|
|
132
|
-
: status === "completed" ? 2
|
|
133
|
-
: 3;
|
|
134
|
-
}
|
|
135
|
-
function resolveAgentMessageKey(notification, run) {
|
|
136
|
-
if (notification.method === "item/agentMessage/delta") {
|
|
137
|
-
const itemId = typeof notification.params.itemId === "string" ? notification.params.itemId : undefined;
|
|
138
|
-
return itemId ? `${run.id}:${itemId}` : undefined;
|
|
139
|
-
}
|
|
140
|
-
if (notification.method === "item/completed") {
|
|
141
|
-
const item = notification.params.item;
|
|
142
|
-
const itemId = typeof item?.id === "string" ? item.id : undefined;
|
|
143
|
-
const itemType = typeof item?.type === "string" ? item.type : undefined;
|
|
144
|
-
return itemId && itemType === "agentMessage" ? `${run.id}:${itemId}` : undefined;
|
|
145
|
-
}
|
|
146
|
-
return undefined;
|
|
147
|
-
}
|
|
148
|
-
function resolveAgentMessageDelta(notification) {
|
|
149
|
-
if (notification.method !== "item/agentMessage/delta") {
|
|
150
|
-
return undefined;
|
|
151
|
-
}
|
|
152
|
-
return typeof notification.params.delta === "string" ? notification.params.delta : undefined;
|
|
153
|
-
}
|
|
154
|
-
function resolveCompletedAgentMessageText(notification) {
|
|
155
|
-
if (notification.method !== "item/completed") {
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
const item = notification.params.item;
|
|
159
|
-
if (!item || item.type !== "agentMessage")
|
|
160
|
-
return undefined;
|
|
161
|
-
return typeof item.text === "string" ? item.text : undefined;
|
|
162
|
-
}
|
|
163
|
-
function extractFirstSentence(text) {
|
|
164
|
-
const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
|
|
165
|
-
if (!sanitized)
|
|
166
|
-
return undefined;
|
|
167
|
-
const match = sanitized.match(/^(.+?[.!?])(?:\s|$)/);
|
|
168
|
-
return truncateProgressText((match?.[1] ?? sanitized).trim(), MAX_PROGRESS_TEXT_LENGTH);
|
|
169
|
-
}
|
|
170
|
-
function extractFirstCompletedSentence(text) {
|
|
171
|
-
const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
|
|
172
|
-
if (!sanitized)
|
|
173
|
-
return undefined;
|
|
174
|
-
const match = sanitized.match(/^(.+?[.!?])(?:\s|$)/);
|
|
175
|
-
return match?.[1] ? truncateProgressText(match[1].trim(), MAX_PROGRESS_TEXT_LENGTH) : undefined;
|
|
176
|
-
}
|
|
177
|
-
function summarizeProgressSentence(text) {
|
|
178
|
-
const summary = extractFirstSentence(text);
|
|
179
|
-
if (!summary)
|
|
180
|
-
return undefined;
|
|
181
|
-
return summary.endsWith(".") || summary.endsWith("!") || summary.endsWith("?") ? summary : `${summary}.`;
|
|
182
|
-
}
|
|
183
|
-
function truncateProgressText(text, maxLength) {
|
|
184
|
-
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3).trimEnd()}...`;
|
|
185
11
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { extractCompletionCheck } from "./completion-check.js";
|
|
2
|
+
import { isClosedPrState } from "./pr-state.js";
|
|
2
3
|
import { deriveIssueStatusNote } from "./status-note.js";
|
|
3
4
|
import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
|
|
4
|
-
import { isClosedPrState } from "./pr-state.js";
|
|
5
|
-
import { isUndelegatedPausedIssue } from "./paused-issue-state.js";
|
|
6
5
|
export async function syncVisibleStatusComment(params) {
|
|
7
6
|
const { db, issue, linear, logger, trackedIssue, options } = params;
|
|
8
7
|
try {
|
|
@@ -25,23 +24,8 @@ export async function syncVisibleStatusComment(params) {
|
|
|
25
24
|
logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear status comment");
|
|
26
25
|
}
|
|
27
26
|
}
|
|
28
|
-
export function shouldSyncVisibleIssueComment(
|
|
29
|
-
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
if (issue.sessionState === "waiting_input" || issue.sessionState === "failed"
|
|
33
|
-
|| issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
if (isUndelegatedPausedIssue(issue)) {
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
|
-
if ((issue.sessionState === "done" || issue.factoryState === "done")
|
|
40
|
-
&& ((issue.prNumber === undefined && !issue.prUrl)
|
|
41
|
-
|| isClosedPrState(issue.prState))) {
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
return false;
|
|
27
|
+
export function shouldSyncVisibleIssueComment(_issue, _hasAgentSession) {
|
|
28
|
+
return true;
|
|
45
29
|
}
|
|
46
30
|
function renderStatusComment(db, issue, trackedIssue, options) {
|
|
47
31
|
const activeRun = issue.activeRunId ? db.runs.getRunById(issue.activeRunId) : undefined;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { hasOpenPr } from "./pr-state.js";
|
|
2
|
+
export function resolveRetryTarget(params) {
|
|
3
|
+
if (params.prState === "merged") {
|
|
4
|
+
return { runType: "none", factoryState: "done" };
|
|
5
|
+
}
|
|
6
|
+
if (hasOpenPr(params.prNumber, params.prState) && params.lastGitHubFailureSource === "queue_eviction") {
|
|
7
|
+
return { runType: "queue_repair", factoryState: "repairing_queue" };
|
|
8
|
+
}
|
|
9
|
+
if (hasOpenPr(params.prNumber, params.prState)
|
|
10
|
+
&& (params.prCheckStatus === "failed" || params.prCheckStatus === "failure" || params.lastGitHubFailureSource === "branch_ci")) {
|
|
11
|
+
return { runType: "ci_repair", factoryState: "repairing_ci" };
|
|
12
|
+
}
|
|
13
|
+
if (hasOpenPr(params.prNumber, params.prState) && params.prReviewState === "changes_requested") {
|
|
14
|
+
return {
|
|
15
|
+
runType: params.pendingRunType === "branch_upkeep" || params.lastRunType === "branch_upkeep"
|
|
16
|
+
? "branch_upkeep"
|
|
17
|
+
: "review_fix",
|
|
18
|
+
factoryState: "changes_requested",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (hasOpenPr(params.prNumber, params.prState)) {
|
|
22
|
+
return { runType: "implementation", factoryState: "implementing" };
|
|
23
|
+
}
|
|
24
|
+
return { runType: "implementation", factoryState: "delegated" };
|
|
25
|
+
}
|
|
26
|
+
export function buildManualRetryAttemptReset(runType) {
|
|
27
|
+
if (runType === "ci_repair") {
|
|
28
|
+
return { ciRepairAttempts: 0 };
|
|
29
|
+
}
|
|
30
|
+
if (runType === "queue_repair") {
|
|
31
|
+
return { queueRepairAttempts: 0 };
|
|
32
|
+
}
|
|
33
|
+
if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
34
|
+
return { reviewFixAttempts: 0 };
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,
|