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.
@@ -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
- ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
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
- db;
6
- emitActivity;
7
- progressThrottle = new Map();
8
- workingOnPublishedRuns = new Set();
9
- agentMessageBuffers = new Map();
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
- maybeEmitProgress(notification, run) {
16
- const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
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(issue, hasAgentSession) {
29
- if (!hasAgentSession) {
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. Treat the inline review comments as the primary repair checklist for this turn.", "2. Check the current diff (`git diff origin/main`) a prior rebase may have already resolved some concerns.", "3. For each review point: if already resolved on the current head, note why. If not, fix it.", "4. If the structured review context looks incomplete, inspect the latest GitHub review threads directly before deciding you are done.", "5. Run verification, commit, and push a newer head on the existing PR branch.", "6. Do not try to hand the same head back to review. If you cannot produce a new pushed head, stop and surface the blocker clearly.", "7. 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.", "");
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
- "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, fix that concrete failure first, then push to the same PR branch.",
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, fix that root cause, and only broaden scope when the logs show direct fallout from the same issue.",
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 focused verification for the named failure, then commit and push.",
225
- "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.",
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;
@@ -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;
@@ -1,6 +1,4 @@
1
- const DEFAULT_CI_REPAIR_BUDGET = 3;
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
- if (runType === "ci_repair" && issue.ciRepairAttempts >= DEFAULT_CI_REPAIR_BUDGET) {
67
- return `CI repair budget exhausted (${DEFAULT_CI_REPAIR_BUDGET} attempts)`;
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
- if (runType === "queue_repair" && issue.queueRepairAttempts >= DEFAULT_QUEUE_REPAIR_BUDGET) {
70
- return `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`;
68
+ const queueRepairBudget = getQueueRepairBudget(project);
69
+ if (runType === "queue_repair" && issue.queueRepairAttempts >= queueRepairBudget) {
70
+ return `Queue repair budget exhausted (${queueRepairBudget} attempts)`;
71
71
  }
72
- if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
73
- return `Requested-changes budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`;
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 { hasOpenPr } from "./pr-state.js";
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
- if (issue.prState === "merged") {
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
- let runType = "implementation";
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,