patchrelay 0.36.6 → 0.36.8

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.
@@ -1,190 +1,23 @@
1
- import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
3
- import path from "node:path";
4
1
  import { ACTIVE_RUN_STATES, TERMINAL_STATES } from "./factory-state.js";
5
- import { buildHookEnv, runProjectHook } from "./hook-runner.js";
6
- import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
7
- import { buildRunCompletedActivity, buildRunFailureActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
2
+ import { extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread } from "./run-reporting.js";
3
+ import { buildRunFailureActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
8
4
  import { WorktreeManager } from "./worktree-manager.js";
9
5
  import { resolveAuthoritativeLinearStopState, resolvePreferredCompletedLinearState } from "./linear-workflow.js";
10
6
  import { execCommand } from "./utils.js";
11
7
  import { getThreadTurns } from "./codex-thread-utils.js";
12
8
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
13
- const DEFAULT_CI_REPAIR_BUDGET = 3;
14
- const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
15
- // Requested-changes loops can legitimately take more iterations than CI/queue
16
- // repair when the reviewer is catching nuanced product or timing bugs across
17
- // successive heads. Keep a hard ceiling to prevent infinite ping-pong, but make
18
- // it wide enough that real review cycles can continue after multiple successful
19
- // head advances.
20
- const DEFAULT_REVIEW_FIX_BUDGET = 12;
21
- const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
22
- const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000; // 15s, 30s, 60s, 120s, 240s
23
- const ISSUE_SESSION_LEASE_MS = 10 * 60_000;
24
- const MAX_THREAD_GENERATION_BEFORE_COMPACTION = 4;
25
- const MAX_FOLLOW_UPS_BEFORE_COMPACTION = 4;
26
9
  import { QueueHealthMonitor } from "./queue-health-monitor.js";
10
+ import { resolveImplementationDeliveryMode, } from "./prompting/patchrelay.js";
27
11
  import { IdleIssueReconciler, resolveBranchOwnerForStateTransition } from "./idle-reconciliation.js";
28
12
  import { LinearSessionSync } from "./linear-session-sync.js";
29
- function slugify(value) {
30
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
31
- }
32
- function sanitizePathSegment(value) {
33
- return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
34
- }
13
+ import { IssueSessionLeaseService } from "./issue-session-lease-service.js";
14
+ import { RunFinalizer } from "./run-finalizer.js";
15
+ import { RunLauncher } from "./run-launcher.js";
16
+ import { RunRecoveryService } from "./run-recovery-service.js";
17
+ import { RunWakePlanner } from "./run-wake-planner.js";
35
18
  function lowerCaseFirst(value) {
36
19
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
37
20
  }
38
- const WORKFLOW_FILES = {
39
- implementation: "IMPLEMENTATION_WORKFLOW.md",
40
- review_fix: "REVIEW_WORKFLOW.md",
41
- branch_upkeep: "REVIEW_WORKFLOW.md",
42
- ci_repair: "IMPLEMENTATION_WORKFLOW.md",
43
- queue_repair: "IMPLEMENTATION_WORKFLOW.md",
44
- };
45
- function readWorkflowFile(repoPath, runType) {
46
- const filename = WORKFLOW_FILES[runType];
47
- const filePath = path.join(repoPath, filename);
48
- if (!existsSync(filePath))
49
- return undefined;
50
- return readFileSync(filePath, "utf8").trim();
51
- }
52
- function collectImplementationInstructionText(issue, context, promptText) {
53
- const parts = [];
54
- if (issue.title)
55
- parts.push(issue.title);
56
- if (issue.description)
57
- parts.push(issue.description);
58
- if (promptText)
59
- parts.push(promptText);
60
- const stringFields = ["promptContext", "promptBody", "operatorPrompt", "userComment"];
61
- for (const field of stringFields) {
62
- const value = context?.[field];
63
- if (typeof value === "string" && value.trim()) {
64
- parts.push(value);
65
- }
66
- }
67
- if (Array.isArray(context?.followUps)) {
68
- for (const entry of context.followUps) {
69
- if (!entry || typeof entry !== "object")
70
- continue;
71
- const text = entry.text;
72
- if (typeof text === "string" && text.trim()) {
73
- parts.push(text);
74
- }
75
- }
76
- }
77
- return parts.join("\n").toLowerCase();
78
- }
79
- export function resolveImplementationDeliveryMode(issue, context, promptText) {
80
- const instructionText = collectImplementationInstructionText(issue, context, promptText);
81
- if (!instructionText)
82
- return "publish_pr";
83
- const hasExplicitNoPr = [
84
- /\bdo not open (?:a |any )?pr\b/,
85
- /\bdo not open (?:a |any )?pull request\b/,
86
- /\bno pr is opened\b/,
87
- /\bpatchrelay should not open a pr\b/,
88
- /\bwithout opening a pr\b/,
89
- ].some((pattern) => pattern.test(instructionText));
90
- const forbidsRepoChanges = [
91
- /\bdo not make repository changes\b/,
92
- /\bdo not make repo changes\b/,
93
- /\bno repository changes\b/,
94
- /\bno repo changes\b/,
95
- /\bdo not modify repo files\b/,
96
- ].some((pattern) => pattern.test(instructionText));
97
- const planningOnly = [
98
- /\bplanning\/specification issue only\b/,
99
- /\bplanning[- ]only\b/,
100
- /\bspecification[- ]only\b/,
101
- /\bplanning issue only\b/,
102
- ].some((pattern) => pattern.test(instructionText));
103
- if (hasExplicitNoPr || (planningOnly && forbidsRepoChanges)) {
104
- return "linear_only";
105
- }
106
- return "publish_pr";
107
- }
108
- function appendPublicationContract(lines, runType, issue, context) {
109
- const deliveryMode = runType === "implementation" && issue
110
- ? resolveImplementationDeliveryMode(issue, context)
111
- : "publish_pr";
112
- if (runType === "implementation" && deliveryMode === "linear_only") {
113
- lines.push("## Delivery Requirements", "");
114
- lines.push("This issue is planning/specification only.", "Do not modify repo files or open a PR for this issue.", "Deliver the result through Linear artifacts such as follow-up issues, documents, and a concise summary.", "Leave the worktree clean before stopping.", "");
115
- return;
116
- }
117
- lines.push("## Publication Requirements", "");
118
- if (runType === "implementation") {
119
- lines.push("Before finishing, publish the result instead of leaving it only in the worktree.", "If the worktree already contains relevant changes for this issue, verify them and publish them.", "If you changed files for this issue, commit them, push the issue branch, and open or update the PR before stopping.", "Do not stop with only local commits or uncommitted changes.", "");
120
- return;
121
- }
122
- lines.push("Before finishing, publish the result to the existing PR branch.", "If you changed files for this repair, commit them and push the same branch before stopping.", "Do not open a new PR.", "Do not stop with only local commits or uncommitted changes.", "");
123
- }
124
- function buildPromptHeader(issue) {
125
- return [
126
- `Issue: ${issue.issueKey ?? issue.linearIssueId}`,
127
- issue.title ? `Title: ${issue.title}` : undefined,
128
- issue.branchName ? `Branch: ${issue.branchName}` : undefined,
129
- issue.prNumber ? `PR: #${issue.prNumber}` : undefined,
130
- "",
131
- ].filter(Boolean);
132
- }
133
- function appendTaskObjective(lines, issue) {
134
- const description = issue.description?.trim();
135
- lines.push("## Task Objective", "");
136
- lines.push(issue.title || `Complete ${issue.issueKey ?? issue.linearIssueId}.`);
137
- if (description) {
138
- lines.push("", description);
139
- }
140
- lines.push("");
141
- }
142
- function extractIssueSection(description, heading) {
143
- if (!description)
144
- return undefined;
145
- const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
146
- const pattern = new RegExp(`^## ${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|$)`, "im");
147
- const match = description.match(pattern);
148
- const body = match?.[1]?.trim();
149
- return body && body.length > 0 ? body : undefined;
150
- }
151
- function appendScopeDiscipline(lines, issue) {
152
- const description = issue.description?.trim();
153
- const scope = extractIssueSection(description, "Scope");
154
- const acceptance = extractIssueSection(description, "Acceptance criteria")
155
- ?? extractIssueSection(description, "Success criteria");
156
- const relevantCode = extractIssueSection(description, "Relevant code");
157
- lines.push("## Scope Discipline", "");
158
- lines.push("Stay inside the delegated task.", "Finish the issue completely enough to satisfy its stated scope and acceptance criteria, but do not widen it into unrelated product polish or follow-up cleanup.", "Only broaden to adjacent routes, copy, or supporting surfaces when the issue text or repository guidance explicitly says they are the same user flow.", "If you notice a worthwhile broader inconsistency that is not required to make this task correct, mention it in your summary as follow-up context instead of expanding the implementation.", "");
159
- if (scope) {
160
- lines.push("### In Scope", "", scope, "");
161
- }
162
- if (acceptance) {
163
- lines.push("### Acceptance / Done", "", acceptance, "");
164
- }
165
- if (relevantCode) {
166
- lines.push("### Relevant Code", "", relevantCode, "");
167
- }
168
- lines.push("### Likely Review Invariants", "", "- Check the surfaces explicitly named in the task before stopping.", "- If repository guidance says certain changed surfaces are one flow, verify that shared flow, but do not treat unrelated surrounding cleanup as part of this task.", "- A review repair should fix the concrete concern on the current head, not silently expand the Linear issue into a broader rewrite.", "");
169
- }
170
- function appendLinearContext(lines, context) {
171
- const promptContext = typeof context?.promptContext === "string" ? context.promptContext.trim() : "";
172
- const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
173
- const operatorPrompt = typeof context?.operatorPrompt === "string" ? context.operatorPrompt.trim() : "";
174
- const userComment = typeof context?.userComment === "string" ? context.userComment.trim() : "";
175
- if (promptContext) {
176
- lines.push("## Linear Session Context", "", promptContext, "");
177
- }
178
- if (latestPrompt) {
179
- lines.push("## Latest Human Instruction", "", latestPrompt, "");
180
- }
181
- if (operatorPrompt) {
182
- lines.push("## Operator Prompt", "", operatorPrompt, "");
183
- }
184
- if (userComment) {
185
- lines.push("## Human Follow-up Comment", "", userComment, "");
186
- }
187
- }
188
21
  function isRequestedChangesRunType(runType) {
189
22
  return runType === "review_fix" || runType === "branch_upkeep";
190
23
  }
@@ -196,341 +29,6 @@ function resolveRequestedChangesMode(runType, context) {
196
29
  ? "branch_upkeep"
197
30
  : "address_review_feedback";
198
31
  }
199
- function readReviewFixComments(context) {
200
- const raw = context?.reviewComments;
201
- if (!Array.isArray(raw)) {
202
- return [];
203
- }
204
- const comments = [];
205
- for (const entry of raw) {
206
- if (!entry || typeof entry !== "object")
207
- continue;
208
- const record = entry;
209
- const body = typeof record.body === "string" ? record.body.trim() : "";
210
- if (!body)
211
- continue;
212
- comments.push({
213
- body,
214
- ...(typeof record.path === "string" ? { path: record.path } : {}),
215
- ...(typeof record.line === "number" ? { line: record.line } : {}),
216
- ...(typeof record.side === "string" ? { side: record.side } : {}),
217
- ...(typeof record.startLine === "number" ? { startLine: record.startLine } : {}),
218
- ...(typeof record.startSide === "string" ? { startSide: record.startSide } : {}),
219
- ...(typeof record.url === "string" ? { url: record.url } : {}),
220
- ...(typeof record.authorLogin === "string" ? { authorLogin: record.authorLogin } : {}),
221
- });
222
- }
223
- return comments;
224
- }
225
- function appendStructuredReviewContext(lines, context) {
226
- const reviewId = typeof context?.reviewId === "number" ? context.reviewId : undefined;
227
- const reviewCommitId = typeof context?.reviewCommitId === "string" ? context.reviewCommitId : undefined;
228
- const reviewUrl = typeof context?.reviewUrl === "string" ? context.reviewUrl : undefined;
229
- const reviewComments = readReviewFixComments(context);
230
- if (!reviewId && !reviewCommitId && !reviewUrl && reviewComments.length === 0) {
231
- return;
232
- }
233
- lines.push("## Structured Review Context", "");
234
- if (reviewId !== undefined) {
235
- lines.push(`Review ID: ${reviewId}`);
236
- }
237
- if (reviewCommitId) {
238
- lines.push(`Reviewed commit: ${reviewCommitId}`);
239
- }
240
- if (reviewUrl) {
241
- lines.push(`Review URL: ${reviewUrl}`);
242
- }
243
- if (reviewComments.length === 0) {
244
- lines.push("No inline review comments were captured for this review.", "");
245
- return;
246
- }
247
- lines.push(`Inline review comments captured: ${reviewComments.length}`, "Resolve each comment below or verify it is already fixed on the current head before you stop.", "A requested-changes turn is only complete if you push a newer PR head or deliberately escalate because you are blocked.", "");
248
- for (const comment of reviewComments) {
249
- const location = comment.path
250
- ? `${comment.path}${comment.line !== undefined ? `:${comment.line}` : ""}${comment.side ? ` (${comment.side})` : ""}`
251
- : "general";
252
- lines.push(`- ${location}`);
253
- lines.push(comment.body);
254
- if (comment.url) {
255
- lines.push(` URL: ${comment.url}`);
256
- }
257
- }
258
- lines.push("");
259
- }
260
- function collectFollowUpInputs(context) {
261
- const followUps = Array.isArray(context?.followUps) ? context.followUps : [];
262
- const inputs = [];
263
- for (const entry of followUps) {
264
- const followUp = entry && typeof entry === "object" ? entry : undefined;
265
- const type = typeof followUp?.type === "string" ? followUp.type : "followup";
266
- const author = typeof followUp?.author === "string" ? followUp.author : undefined;
267
- const text = typeof followUp?.text === "string" ? followUp.text.trim() : "";
268
- if (!text)
269
- continue;
270
- inputs.push({ type, text, ...(author ? { author } : {}) });
271
- }
272
- return inputs;
273
- }
274
- function resolveFollowUpWhy(runType, context) {
275
- const wakeReason = typeof context?.wakeReason === "string" ? context.wakeReason : undefined;
276
- switch (wakeReason) {
277
- case "direct_reply":
278
- return "A human reply arrived for the outstanding question from the previous turn.";
279
- case "followup_prompt":
280
- return "A new Linear agent prompt arrived after the previous turn.";
281
- case "followup_comment":
282
- return "A human follow-up comment arrived after the previous turn.";
283
- case "operator_prompt":
284
- return "An operator supplied new guidance for this issue.";
285
- case "review_changes_requested":
286
- return "GitHub review requested changes on the current PR head.";
287
- case "branch_upkeep":
288
- return "GitHub still shows the PR branch as needing upkeep after the requested code change was addressed.";
289
- case "settled_red_ci":
290
- return "Required CI settled red for the current PR head.";
291
- case "merge_steward_incident":
292
- return "Merge Steward reported an incident on the current PR head.";
293
- case "delegated":
294
- return runType === "implementation"
295
- ? "This is the first implementation turn for the delegated issue."
296
- : `This turn continues ${runType.replaceAll("_", " ")} work for the delegated issue.`;
297
- default:
298
- if (isRequestedChangesRunType(runType)) {
299
- return resolveRequestedChangesMode(runType, context) === "branch_upkeep"
300
- ? "This turn continues branch upkeep on the existing PR after requested changes."
301
- : "This turn continues requested-changes work on the existing PR.";
302
- }
303
- if (runType === "ci_repair")
304
- return "This turn continues CI repair work on the existing PR.";
305
- if (runType === "queue_repair")
306
- return "This turn continues merge-queue repair work on the existing PR.";
307
- return "This turn continues implementation on the existing issue session.";
308
- }
309
- }
310
- function resolveFollowUpAction(runType, context) {
311
- if (context?.directReplyMode === true) {
312
- return "Apply the latest human answer, continue from the current branch/session context, and only ask another question if you are still blocked.";
313
- }
314
- if (isRequestedChangesRunType(runType) && resolveRequestedChangesMode(runType, context) === "branch_upkeep") {
315
- const baseBranch = typeof context?.baseBranch === "string" ? context.baseBranch : "main";
316
- return `Update the existing PR branch onto latest ${baseBranch}, resolve conflicts if needed, rerun narrow verification, and push a newer head on the same branch.`;
317
- }
318
- switch (runType) {
319
- case "review_fix":
320
- return "Address the review feedback on the current PR branch, verify the fix, and push a newer head on the same branch.";
321
- case "branch_upkeep":
322
- return "Repair the existing PR branch after requested changes, rerun narrow verification, and push a newer head on the same branch.";
323
- case "ci_repair":
324
- return "Fix the failing CI root cause on the current PR branch, verify it locally, and push the same branch.";
325
- case "queue_repair":
326
- return "Repair the merge-queue incident on the current PR branch, verify the fix, and push the same branch.";
327
- case "implementation":
328
- default:
329
- return "Continue from the latest branch state, incorporate the new input, and publish updates to the existing issue branch if you make changes.";
330
- }
331
- }
332
- function hasAuthoritativeGitHubFacts(issue, runType, context) {
333
- return issue.prNumber !== undefined
334
- || issue.prHeadSha !== undefined
335
- || runType !== "implementation"
336
- || typeof context?.failureHeadSha === "string"
337
- || typeof context?.failingHeadSha === "string"
338
- || typeof context?.mergeStateStatus === "string"
339
- || typeof context?.checkName === "string"
340
- || typeof context?.reviewerName === "string";
341
- }
342
- function appendAuthoritativeGitHubFacts(lines, issue, runType, context) {
343
- if (!hasAuthoritativeGitHubFacts(issue, runType, context)) {
344
- return;
345
- }
346
- const prNumber = issue.prNumber !== undefined ? `#${issue.prNumber}` : undefined;
347
- const headSha = typeof context?.failureHeadSha === "string"
348
- ? context.failureHeadSha
349
- : typeof context?.failingHeadSha === "string"
350
- ? context.failingHeadSha
351
- : issue.prHeadSha;
352
- const mergeStateStatus = typeof context?.mergeStateStatus === "string" ? context.mergeStateStatus : undefined;
353
- const baseBranch = typeof context?.baseBranch === "string" ? context.baseBranch : undefined;
354
- const checkName = typeof context?.checkName === "string" ? context.checkName : undefined;
355
- const jobName = typeof context?.jobName === "string" ? context.jobName : undefined;
356
- const stepName = typeof context?.stepName === "string" ? context.stepName : undefined;
357
- const reviewerName = typeof context?.reviewerName === "string" ? context.reviewerName : undefined;
358
- const reviewBody = typeof context?.reviewBody === "string" ? context.reviewBody.trim() : "";
359
- const summary = typeof context?.summary === "string" ? context.summary : undefined;
360
- lines.push("## Authoritative GitHub Facts", "");
361
- if (prNumber) {
362
- lines.push(`- Current PR: ${prNumber}`);
363
- }
364
- if (headSha) {
365
- lines.push(`- Current relevant head SHA: ${headSha}`);
366
- }
367
- if (issue.prReviewState) {
368
- lines.push(`- Current review state: ${issue.prReviewState}`);
369
- }
370
- if (issue.prCheckStatus) {
371
- lines.push(`- Current check status: ${issue.prCheckStatus}`);
372
- }
373
- if (mergeStateStatus) {
374
- lines.push(`- Merge state against ${baseBranch ?? "base"}: ${mergeStateStatus}`);
375
- }
376
- if (checkName) {
377
- lines.push(`- Relevant check: ${checkName}`);
378
- }
379
- if (jobName && jobName !== checkName) {
380
- lines.push(`- Relevant job: ${jobName}`);
381
- }
382
- if (stepName) {
383
- lines.push(`- Relevant step: ${stepName}`);
384
- }
385
- if (reviewerName) {
386
- lines.push(`- Reviewer: ${reviewerName}`);
387
- }
388
- if (summary) {
389
- lines.push(`- Summary: ${summary}`);
390
- }
391
- if (reviewBody) {
392
- lines.push(`- Review body: ${reviewBody}`);
393
- }
394
- lines.push("");
395
- }
396
- function appendFactFreshness(lines, issue, runType, context) {
397
- if (!hasAuthoritativeGitHubFacts(issue, runType, context)) {
398
- return;
399
- }
400
- const hasFreshFacts = context?.githubFactsFresh === true || context?.branchUpkeepRequired === true;
401
- lines.push("## Fact Freshness", "");
402
- if (hasFreshFacts) {
403
- lines.push("GitHub facts below were refreshed immediately before this turn was created.");
404
- }
405
- else {
406
- lines.push("GitHub facts below came from the triggering event or last known reconciliation state and may now be stale.");
407
- lines.push("Verify the current PR head, review state, and check state in GitHub before making branch-mutating decisions.");
408
- }
409
- lines.push("");
410
- }
411
- function appendFollowUpPromptPrelude(lines, issue, runType, context) {
412
- lines.push("## Follow-up Turn", "");
413
- lines.push(`Why this turn exists: ${resolveFollowUpWhy(runType, context)}`);
414
- lines.push(`Required action now: ${resolveFollowUpAction(runType, context)}`);
415
- lines.push("");
416
- appendLinearContext(lines, context);
417
- const followUps = collectFollowUpInputs(context);
418
- if (followUps.length > 0) {
419
- lines.push("## What Changed Since The Last Turn", "");
420
- for (const followUp of followUps) {
421
- lines.push(`- ${followUp.type}${followUp.author ? ` from ${followUp.author}` : ""}: ${followUp.text}`);
422
- }
423
- lines.push("");
424
- }
425
- appendFactFreshness(lines, issue, runType, context);
426
- appendAuthoritativeGitHubFacts(lines, issue, runType, context);
427
- }
428
- function appendRequestedChangesInstructions(lines, runType, context) {
429
- if (resolveRequestedChangesMode(runType, context) === "branch_upkeep") {
430
- const baseBranch = typeof context?.baseBranch === "string" ? context.baseBranch : "main";
431
- lines.push("## Branch Upkeep After Requested Changes", "", "The requested code change may already be present, but the PR branch still needs upkeep before review can continue.", typeof context?.mergeStateStatus === "string" ? `Current merge state: ${String(context.mergeStateStatus)}` : "", "", "Steps:", `1. Update the existing PR branch onto latest ${baseBranch}.`, "2. Resolve conflicts or branch drift without reopening the review-feedback debate unless the merge introduces a new issue.", "3. Run the narrowest verification that proves the branch is healthy again.", "4. Commit and push a newer head on the existing PR branch.", "5. If you cannot produce a new pushed head, stop and surface the exact blocker.", "");
432
- return;
433
- }
434
- lines.push("## Review Changes Requested", "", "A reviewer has requested changes on your PR. Address the feedback and push.", context?.reviewerName ? `Reviewer: ${String(context.reviewerName)}` : "", context?.reviewBody ? `\n## Review comment\n\n${String(context.reviewBody)}` : "", "", "Steps:", "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 (e.g., scope-bundling from stale commits).", "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.", "");
435
- appendStructuredReviewContext(lines, context);
436
- }
437
- export function buildInitialRunPrompt(issue, runType, repoPath, context) {
438
- const lines = buildPromptHeader(issue);
439
- appendTaskObjective(lines, issue);
440
- appendScopeDiscipline(lines, issue);
441
- appendLinearContext(lines, context);
442
- // Add run-type-specific context for reactive runs
443
- switch (runType) {
444
- case "ci_repair": {
445
- const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
446
- ? context.ciSnapshot
447
- : undefined;
448
- lines.push("## CI Repair", "", "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.", snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "", snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "", snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
449
- ? `Other failed checks in the settled snapshot (context only; ignore unless the logs show the same root cause):\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
450
- : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", Array.isArray(context?.annotations) && context.annotations.length > 0
451
- ? `Annotations:\n${context.annotations.map((entry) => `- ${String(entry)}`).join("\n")}`
452
- : "", "", "Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.", "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.", "Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.", "Run focused verification for the named failure, then commit and push.", "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.", "Do not change test expectations unless the test is genuinely wrong.", "");
453
- break;
454
- }
455
- case "review_fix":
456
- case "branch_upkeep":
457
- appendRequestedChangesInstructions(lines, runType, context);
458
- break;
459
- case "queue_repair":
460
- appendQueueRepairContext(lines, context);
461
- lines.push("## Merge Queue Failure", "", "The merge queue rejected this PR. Rebase onto latest main and fix conflicts.", context?.failureReason ? `Failure reason: ${String(context.failureReason)}` : "", "", "Fetch and rebase onto latest main, resolve conflicts, run verification, push.", "If the conflict is a semantic contradiction, explain and stop.", "");
462
- break;
463
- }
464
- const workflowBody = readWorkflowFile(repoPath, runType);
465
- if (workflowBody) {
466
- lines.push(workflowBody);
467
- }
468
- else if (runType === "implementation") {
469
- lines.push("Implement the Linear issue. Read the issue via MCP for details.");
470
- }
471
- appendPublicationContract(lines, runType, issue, context);
472
- return lines.join("\n");
473
- }
474
- export function buildFollowUpRunPrompt(issue, runType, repoPath, context) {
475
- const lines = buildPromptHeader(issue);
476
- appendFollowUpPromptPrelude(lines, issue, runType, context);
477
- appendTaskObjective(lines, issue);
478
- appendScopeDiscipline(lines, issue);
479
- // Add run-type-specific context for reactive runs
480
- switch (runType) {
481
- case "ci_repair": {
482
- const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
483
- ? context.ciSnapshot
484
- : undefined;
485
- lines.push("## CI Repair", "", "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.", snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "", snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "", snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
486
- ? `Other failed checks in the settled snapshot (context only; ignore unless the logs show the same root cause):\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
487
- : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", Array.isArray(context?.annotations) && context.annotations.length > 0
488
- ? `Annotations:\n${context.annotations.map((entry) => `- ${String(entry)}`).join("\n")}`
489
- : "", "", "Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.", "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.", "Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.", "Run focused verification for the named failure, then commit and push.", "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.", "Do not change test expectations unless the test is genuinely wrong.", "");
490
- break;
491
- }
492
- case "review_fix":
493
- case "branch_upkeep":
494
- appendRequestedChangesInstructions(lines, runType, context);
495
- break;
496
- case "queue_repair":
497
- appendQueueRepairContext(lines, context);
498
- lines.push("## Merge Queue Failure", "", "The merge queue rejected this PR. Rebase onto latest main and fix conflicts.", context?.failureReason ? `Failure reason: ${String(context.failureReason)}` : "", "", "Fetch and rebase onto latest main, resolve conflicts, run verification, push.", "If the conflict is a semantic contradiction, explain and stop.", "");
499
- break;
500
- }
501
- const workflowBody = readWorkflowFile(repoPath, runType);
502
- if (workflowBody) {
503
- lines.push(workflowBody);
504
- }
505
- else if (runType === "implementation") {
506
- lines.push("Implement the Linear issue. Read the issue via MCP for details.");
507
- }
508
- appendPublicationContract(lines, runType, issue, context);
509
- return lines.join("\n");
510
- }
511
- function shouldBuildFollowUpPrompt(runType, context) {
512
- if (context?.followUpMode)
513
- return true;
514
- if (runType !== "implementation")
515
- return true;
516
- const wakeReason = typeof context?.wakeReason === "string" ? context.wakeReason : undefined;
517
- return Boolean(wakeReason && wakeReason !== "delegated");
518
- }
519
- export function buildRunPrompt(issue, runType, repoPath, context) {
520
- if (shouldBuildFollowUpPrompt(runType, context)) {
521
- return buildFollowUpRunPrompt(issue, runType, repoPath, context);
522
- }
523
- return buildInitialRunPrompt(issue, runType, repoPath, context);
524
- }
525
- function shouldCompactThread(issue, threadGeneration, context) {
526
- const followUpCount = typeof context?.followUpCount === "number" ? context.followUpCount : 0;
527
- return issue.threadId !== undefined
528
- && (threadGeneration ?? 0) >= MAX_THREAD_GENERATION_BEFORE_COMPACTION
529
- && followUpCount >= MAX_FOLLOW_UPS_BEFORE_COMPACTION;
530
- }
531
- export function shouldReuseIssueThread(params) {
532
- return Boolean(params.existingThreadId) && !params.compactThread && params.resumeThread;
533
- }
534
32
  function isBranchUpkeepRequired(context) {
535
33
  return context?.branchUpkeepRequired === true;
536
34
  }
@@ -549,7 +47,12 @@ export class RunOrchestrator {
549
47
  linearSync;
550
48
  activeThreadId;
551
49
  workerId = `patchrelay:${process.pid}`;
552
- activeSessionLeases = new Map();
50
+ leaseService;
51
+ runFinalizer;
52
+ runLauncher;
53
+ runRecovery;
54
+ runWakePlanner;
55
+ activeSessionLeases;
553
56
  botIdentity;
554
57
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
555
58
  this.config = config;
@@ -561,6 +64,12 @@ export class RunOrchestrator {
561
64
  this.feed = feed;
562
65
  this.worktreeManager = new WorktreeManager(config);
563
66
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
67
+ this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries));
68
+ this.activeSessionLeases = this.leaseService.activeSessionLeases;
69
+ this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, feed);
70
+ this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
71
+ this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (projectId, issueId) => this.enqueueIssue(projectId, issueId), (newState, pendingRunType) => this.resolveBranchOwnerForStateTransition(newState, pendingRunType), feed);
72
+ this.runWakePlanner = new RunWakePlanner(db);
564
73
  this.idleReconciler = new IdleIssueReconciler(db, config, {
565
74
  enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
566
75
  }, logger, feed);
@@ -570,88 +79,40 @@ export class RunOrchestrator {
570
79
  }, logger, feed);
571
80
  }
572
81
  resolveRunWake(issue) {
573
- const sessionWake = this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
574
- if (sessionWake) {
575
- return {
576
- runType: sessionWake.runType,
577
- context: sessionWake.context,
578
- wakeReason: sessionWake.wakeReason,
579
- resumeThread: sessionWake.resumeThread,
580
- eventIds: sessionWake.eventIds,
581
- };
582
- }
583
- return undefined;
82
+ return this.runWakePlanner.resolveRunWake(issue);
584
83
  }
585
84
  appendWakeEventWithLease(lease, issue, runType, context, dedupeScope) {
586
- let eventType;
587
- let dedupeKey;
588
- if (runType === "queue_repair") {
589
- eventType = "merge_steward_incident";
590
- dedupeKey = `${dedupeScope ?? "wake"}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`;
591
- }
592
- else if (runType === "ci_repair") {
593
- eventType = "settled_red_ci";
594
- dedupeKey = `${dedupeScope ?? "wake"}:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`;
595
- }
596
- else if (runType === "review_fix" || runType === "branch_upkeep") {
597
- eventType = "review_changes_requested";
598
- dedupeKey = `${dedupeScope ?? "wake"}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`;
599
- }
600
- else {
601
- eventType = "delegated";
602
- dedupeKey = `${dedupeScope ?? "wake"}:implementation:${issue.linearIssueId}`;
603
- }
604
- return Boolean(this.db.appendIssueSessionEventWithLease(lease, {
605
- projectId: issue.projectId,
606
- linearIssueId: issue.linearIssueId,
607
- eventType,
608
- ...(context ? { eventJson: JSON.stringify(context) } : {}),
609
- dedupeKey,
610
- }));
85
+ return this.runWakePlanner.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope);
611
86
  }
612
87
  materializeLegacyPendingWake(issue, lease) {
613
- if (!issue.pendingRunType)
614
- return issue;
615
- const context = issue.pendingRunContextJson
616
- ? JSON.parse(issue.pendingRunContextJson)
617
- : undefined;
618
- this.appendWakeEventWithLease(lease, issue, issue.pendingRunType, context, "legacy_pending");
619
- const updated = this.db.upsertIssueWithLease(lease, {
620
- projectId: issue.projectId,
621
- linearIssueId: issue.linearIssueId,
622
- pendingRunType: null,
623
- pendingRunContextJson: null,
624
- });
625
- if (!updated)
626
- return issue;
627
- return this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
88
+ return this.runWakePlanner.materializeLegacyPendingWake(issue, lease);
628
89
  }
629
90
  // ─── Run ────────────────────────────────────────────────────────
630
91
  async run(item) {
631
92
  const project = this.config.projects.find((p) => p.id === item.projectId);
632
93
  if (!project)
633
94
  return;
634
- if (this.activeSessionLeases.has(this.issueSessionLeaseKey(item.projectId, item.issueId))) {
95
+ if (this.leaseService.hasLocalLease(item.projectId, item.issueId)) {
635
96
  return;
636
97
  }
637
98
  const issue = this.db.getIssue(item.projectId, item.issueId);
638
99
  if (!issue || issue.activeRunId !== undefined)
639
100
  return;
640
- const issueSession = this.db.getIssueSession(item.projectId, item.issueId);
641
- const leaseId = this.acquireIssueSessionLease(item.projectId, item.issueId);
101
+ const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
102
+ const leaseId = this.leaseService.acquire(item.projectId, item.issueId);
642
103
  if (!leaseId) {
643
104
  this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId }, "Skipped run because another worker holds the session lease");
644
105
  return;
645
106
  }
646
107
  if (issue.prState === "merged") {
647
- this.db.upsertIssueWithLease({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingRunType: null, factoryState: "done" });
648
- this.releaseIssueSessionLease(item.projectId, item.issueId);
108
+ this.db.issueSessions.upsertIssueWithLease({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingRunType: null, factoryState: "done" });
109
+ this.leaseService.release(item.projectId, item.issueId);
649
110
  return;
650
111
  }
651
112
  const wakeIssue = this.materializeLegacyPendingWake(issue, { projectId: item.projectId, linearIssueId: item.issueId, leaseId });
652
113
  const wake = this.resolveRunWake(wakeIssue);
653
114
  if (!wake) {
654
- this.releaseIssueSessionLease(item.projectId, item.issueId);
115
+ this.leaseService.release(item.projectId, item.issueId);
655
116
  return;
656
117
  }
657
118
  const { runType, context, resumeThread } = wake;
@@ -663,94 +124,33 @@ export class RunOrchestrator {
663
124
  : typeof effectiveContext?.headSha === "string"
664
125
  ? effectiveContext.headSha
665
126
  : issue.prHeadSha;
666
- // Check repair budgets
667
- if (runType === "ci_repair" && issue.ciRepairAttempts >= DEFAULT_CI_REPAIR_BUDGET) {
668
- this.escalate(issue, runType, `CI repair budget exhausted (${DEFAULT_CI_REPAIR_BUDGET} attempts)`);
127
+ const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, runType, isRequestedChangesRunType);
128
+ if (budgetExceeded) {
129
+ this.escalate(issue, runType, budgetExceeded);
669
130
  return;
670
131
  }
671
- if (runType === "queue_repair" && issue.queueRepairAttempts >= DEFAULT_QUEUE_REPAIR_BUDGET) {
672
- this.escalate(issue, runType, `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`);
673
- return;
674
- }
675
- if (isRequestedChangesRunType(runType) && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
676
- this.escalate(issue, runType, `Requested-changes budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`);
132
+ if (!this.runWakePlanner.incrementAttemptCounters(issue, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, runType, isRequestedChangesRunType)) {
133
+ this.releaseIssueSessionLease(item.projectId, item.issueId);
677
134
  return;
678
135
  }
679
- // Increment repair counters
680
- if (runType === "ci_repair") {
681
- const updated = this.db.upsertIssueWithLease({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, ciRepairAttempts: issue.ciRepairAttempts + 1 });
682
- if (!updated) {
683
- this.releaseIssueSessionLease(item.projectId, item.issueId);
684
- return;
685
- }
686
- }
687
- if (runType === "queue_repair") {
688
- const updated = this.db.upsertIssueWithLease({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueRepairAttempts: issue.queueRepairAttempts + 1 });
689
- if (!updated) {
690
- this.releaseIssueSessionLease(item.projectId, item.issueId);
691
- return;
692
- }
693
- }
694
- if (isRequestedChangesRunType(runType)) {
695
- const updated = this.db.upsertIssueWithLease({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: issue.reviewFixAttempts + 1 });
696
- if (!updated) {
697
- this.releaseIssueSessionLease(item.projectId, item.issueId);
698
- return;
699
- }
700
- }
701
- // Build prompt
702
- const prompt = buildRunPrompt(issue, runType, project.repoPath, effectiveContext);
703
- // Resolve workspace
704
- const issueRef = sanitizePathSegment(issue.issueKey ?? issue.linearIssueId);
705
- const slug = issue.title ? slugify(issue.title) : "";
706
- const branchSuffix = slug ? `${issueRef}-${slug}` : issueRef;
707
- const branchName = issue.branchName ?? `${project.branchPrefix}/${branchSuffix}`;
708
- const worktreePath = issue.worktreePath ?? `${project.worktreeRoot}/${issueRef}`;
709
- // Claim the run atomically
710
- const run = this.db.withIssueSessionLease(item.projectId, item.issueId, leaseId, () => {
711
- const fresh = this.db.getIssue(item.projectId, item.issueId);
712
- if (!fresh || fresh.activeRunId !== undefined)
713
- return undefined;
714
- const wakeIssue = this.materializeLegacyPendingWake(fresh, { projectId: item.projectId, linearIssueId: item.issueId, leaseId });
715
- const freshWake = this.resolveRunWake(wakeIssue);
716
- if (!freshWake || freshWake.runType !== runType)
717
- return undefined;
718
- const created = this.db.createRun({
719
- issueId: fresh.id,
720
- projectId: item.projectId,
721
- linearIssueId: item.issueId,
722
- runType,
723
- ...(sourceHeadSha ? { sourceHeadSha } : {}),
724
- promptText: prompt,
725
- });
726
- const failureHeadSha = typeof effectiveContext?.failureHeadSha === "string"
727
- ? effectiveContext.failureHeadSha
728
- : typeof effectiveContext?.headSha === "string" ? effectiveContext.headSha : undefined;
729
- const failureSignature = typeof effectiveContext?.failureSignature === "string" ? effectiveContext.failureSignature : undefined;
730
- this.db.upsertIssue({
731
- projectId: item.projectId,
732
- linearIssueId: item.issueId,
733
- pendingRunType: null,
734
- pendingRunContextJson: null,
735
- activeRunId: created.id,
736
- branchName,
737
- worktreePath,
738
- factoryState: runType === "implementation" ? "implementing"
739
- : runType === "ci_repair" ? "repairing_ci"
740
- : runType === "review_fix" || runType === "branch_upkeep" ? "changes_requested"
741
- : runType === "queue_repair" ? "repairing_queue"
742
- : "implementing",
743
- ...((runType === "ci_repair" || runType === "queue_repair") && failureSignature
744
- ? {
745
- lastAttemptedFailureSignature: failureSignature,
746
- lastAttemptedFailureHeadSha: failureHeadSha ?? null,
747
- }
748
- : {}),
749
- });
750
- this.db.consumeIssueSessionEvents(item.projectId, item.issueId, freshWake.eventIds, created.id);
751
- this.db.setIssueSessionLastWakeReason(item.projectId, item.issueId, freshWake.wakeReason ?? null);
752
- this.db.setBranchOwnerWithLease({ projectId: item.projectId, linearIssueId: item.issueId, leaseId }, "patchrelay");
753
- return created;
136
+ const { prompt, branchName, worktreePath } = this.runLauncher.prepareLaunchPlan({
137
+ project,
138
+ issue,
139
+ runType,
140
+ ...(effectiveContext ? { effectiveContext } : {}),
141
+ });
142
+ const run = this.runLauncher.claimRun({
143
+ item,
144
+ issue,
145
+ leaseId,
146
+ runType,
147
+ prompt,
148
+ ...(sourceHeadSha ? { sourceHeadSha } : {}),
149
+ ...(effectiveContext ? { effectiveContext } : {}),
150
+ materializeLegacyPendingWake: (targetIssue, lease) => this.materializeLegacyPendingWake(targetIssue, lease),
151
+ resolveRunWake: (targetIssue) => this.resolveRunWake(targetIssue),
152
+ branchName,
153
+ worktreePath,
754
154
  });
755
155
  if (!run) {
756
156
  this.releaseIssueSessionLease(item.projectId, item.issueId);
@@ -765,110 +165,36 @@ export class RunOrchestrator {
765
165
  status: "starting",
766
166
  summary: `Starting ${runType} run`,
767
167
  });
768
- let threadId;
769
- let turnId;
770
- let parentThreadId;
771
- try {
772
- // Ensure worktree
773
- await this.worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktreePath, branchName, { allowExistingOutsideRoot: issue.branchName !== undefined });
774
- // Set bot git identity and push credentials when GitHub App is configured.
775
- // This ensures commits are authored by and pushes are authenticated as
776
- // patchrelay[bot], not the system user.
777
- if (this.botIdentity) {
778
- const gitBin = this.config.runner.gitBin;
779
- await execCommand(gitBin, ["-C", worktreePath, "config", "user.name", this.botIdentity.name], { timeoutMs: 5_000 });
780
- await execCommand(gitBin, ["-C", worktreePath, "config", "user.email", this.botIdentity.email], { timeoutMs: 5_000 });
781
- // Override credential helper to use the App installation token for git push.
782
- // The helper script reads the token file and returns it as the password.
783
- const credentialHelper = `!f() { echo "username=x-access-token"; echo "password=$(cat ${this.botIdentity.tokenFile})"; }; f`;
784
- await execCommand(gitBin, ["-C", worktreePath, "config", "credential.helper", credentialHelper], { timeoutMs: 5_000 });
785
- }
786
- await this.resetWorktreeToTrackedBranch(worktreePath, branchName, issue);
787
- // Freshen the worktree: fetch + rebase onto latest base branch.
788
- // This prevents branch contamination when local main has drifted
789
- // and avoids scope-bundling review rejections from stale commits.
790
- // Skip for queue_repair — its entire purpose is to resolve rebase conflicts.
791
- if (runType !== "queue_repair") {
792
- await this.freshenWorktree(worktreePath, project, issue);
793
- }
794
- // Run prepare-worktree hook
795
- const hookEnv = buildHookEnv(issue.issueKey ?? issue.linearIssueId, branchName, runType, worktreePath);
796
- const prepareResult = await runProjectHook(project.repoPath, "prepare-worktree", { cwd: worktreePath, env: hookEnv });
797
- if (prepareResult.ran && prepareResult.exitCode !== 0) {
798
- throw new Error(`prepare-worktree hook failed (exit ${prepareResult.exitCode}): ${prepareResult.stderr?.slice(0, 500) ?? ""}`);
799
- }
800
- this.assertLaunchLease(run, "before starting the Codex turn");
801
- // Reuse the existing thread only for additive follow-ups that explicitly
802
- // request continuity. Fresh review-fix runs now start a new thread so the
803
- // model is not anchored to the implementation conversation that produced
804
- // the rejected patch. If the thread has accumulated many resumptions and
805
- // batched follow-ups, compact by starting a fresh main thread while
806
- // keeping a parent link.
807
- const compactThread = shouldCompactThread(issue, issueSession?.threadGeneration, effectiveContext);
808
- if (compactThread && issue.threadId) {
809
- parentThreadId = issue.threadId;
810
- }
811
- if (shouldReuseIssueThread({ existingThreadId: issue.threadId, compactThread, resumeThread })) {
812
- threadId = issue.threadId;
813
- }
814
- else {
815
- const thread = await this.codex.startThread({ cwd: worktreePath });
816
- threadId = thread.id;
817
- this.db.upsertIssueWithLease({ projectId: item.projectId, linearIssueId: item.issueId, leaseId }, { projectId: item.projectId, linearIssueId: item.issueId, threadId });
818
- }
819
- try {
820
- const turn = await this.codex.startTurn({ threadId, cwd: worktreePath, input: prompt });
821
- turnId = turn.turnId;
822
- }
823
- catch (turnError) {
824
- // If the thread is stale (e.g. after app-server restart), start fresh and retry once.
825
- const msg = turnError instanceof Error ? turnError.message : String(turnError);
826
- if (msg.includes("thread not found") || msg.includes("not materialized")) {
827
- this.logger.info({ issueKey: issue.issueKey, staleThreadId: threadId }, "Thread is stale, retrying with fresh thread");
828
- const thread = await this.codex.startThread({ cwd: worktreePath });
829
- threadId = thread.id;
830
- this.db.upsertIssueWithLease({ projectId: item.projectId, linearIssueId: item.issueId, leaseId }, { projectId: item.projectId, linearIssueId: item.issueId, threadId });
831
- const turn = await this.codex.startTurn({ threadId, cwd: worktreePath, input: prompt });
832
- turnId = turn.turnId;
833
- }
834
- else {
835
- throw turnError;
836
- }
837
- }
838
- this.assertLaunchLease(run, "after starting the Codex turn");
839
- }
840
- catch (error) {
841
- const message = error instanceof Error ? error.message : String(error);
842
- const lostLease = error instanceof Error && error.name === "IssueSessionLeaseLostError";
843
- if (!lostLease) {
844
- const nextState = isRequestedChangesRunType(runType) ? "escalated" : "failed";
845
- this.db.finishRunWithLease({ projectId: item.projectId, linearIssueId: item.issueId, leaseId }, run.id, {
846
- status: "failed",
847
- failureReason: message,
848
- });
849
- this.db.upsertIssueWithLease({ projectId: item.projectId, linearIssueId: item.issueId, leaseId }, {
850
- projectId: item.projectId,
851
- linearIssueId: item.issueId,
852
- activeRunId: null,
853
- factoryState: nextState,
854
- });
855
- }
856
- this.logger.error({ issueKey: issue.issueKey, runType, error: message }, `Failed to launch ${runType} run`);
857
- const failedIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
858
- void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(runType, `Failed to start ${lowerCaseFirst(message)}`));
859
- void this.linearSync.syncSession(failedIssue, { activeRunType: runType });
860
- this.releaseIssueSessionLease(item.projectId, item.issueId);
861
- throw error;
862
- }
168
+ const { threadId, turnId, parentThreadId, } = await this.runLauncher.launchTurn({
169
+ project,
170
+ issue,
171
+ ...(issueSession ? { issueSession } : {}),
172
+ run,
173
+ runType,
174
+ prompt,
175
+ branchName,
176
+ worktreePath,
177
+ resumeThread,
178
+ ...(effectiveContext ? { effectiveContext } : {}),
179
+ leaseId,
180
+ ...(this.botIdentity ? { botIdentity: this.botIdentity } : {}),
181
+ assertLaunchLease: (targetRun, phase) => this.assertLaunchLease(targetRun, phase),
182
+ resetWorktreeToTrackedBranch: (targetWorktreePath, targetBranchName, targetIssue) => this.resetWorktreeToTrackedBranch(targetWorktreePath, targetBranchName, targetIssue),
183
+ freshenWorktree: (targetWorktreePath, targetProject, targetIssue) => this.freshenWorktree(targetWorktreePath, targetProject, targetIssue),
184
+ linearSync: this.linearSync,
185
+ releaseLease: (projectId, issueId) => this.releaseIssueSessionLease(projectId, issueId),
186
+ isRequestedChangesRunType,
187
+ lowerCaseFirst,
188
+ });
863
189
  this.assertLaunchLease(run, "before recording the active thread");
864
- if (!this.db.updateRunThreadWithLease({ projectId: run.projectId, linearIssueId: run.linearIssueId, leaseId }, run.id, { threadId, turnId, ...(parentThreadId ? { parentThreadId } : {}) })) {
190
+ if (!this.db.issueSessions.updateRunThreadWithLease({ projectId: run.projectId, linearIssueId: run.linearIssueId, leaseId }, run.id, { threadId, turnId, ...(parentThreadId ? { parentThreadId } : {}) })) {
865
191
  this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping run thread update after losing issue-session lease");
866
192
  this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
867
193
  return;
868
194
  }
869
195
  // Reset zombie recovery counter — this run started successfully
870
196
  if (issue.zombieRecoveryAttempts > 0) {
871
- this.db.upsertIssueWithLease({ projectId: item.projectId, linearIssueId: item.issueId, leaseId }, {
197
+ this.db.issueSessions.upsertIssueWithLease({ projectId: item.projectId, linearIssueId: item.issueId, leaseId }, {
872
198
  projectId: item.projectId,
873
199
  linearIssueId: item.issueId,
874
200
  zombieRecoveryAttempts: 0,
@@ -984,7 +310,7 @@ export class RunOrchestrator {
984
310
  if (notification.method === "turn/started" && threadId) {
985
311
  this.activeThreadId = threadId;
986
312
  }
987
- const run = this.db.getRunByThreadId(threadId);
313
+ const run = this.db.runs.getRunByThreadId(threadId);
988
314
  if (!run)
989
315
  return;
990
316
  if (!this.heartbeatIssueSessionLease(run.projectId, run.linearIssueId)) {
@@ -993,7 +319,7 @@ export class RunOrchestrator {
993
319
  }
994
320
  const turnId = typeof notification.params.turnId === "string" ? notification.params.turnId : undefined;
995
321
  if (this.config.runner.codex.persistExtendedHistory) {
996
- this.db.saveThreadEvent({
322
+ this.db.runs.saveThreadEvent({
997
323
  runId: run.id,
998
324
  threadId,
999
325
  ...(turnId ? { turnId } : {}),
@@ -1021,13 +347,13 @@ export class RunOrchestrator {
1021
347
  if (status === "failed") {
1022
348
  const nextState = isRequestedChangesRunType(run.runType) ? "escalated" : "failed";
1023
349
  const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
1024
- this.db.finishRunWithLease(lease, run.id, {
350
+ this.db.issueSessions.finishRunWithLease(lease, run.id, {
1025
351
  status: "failed",
1026
352
  threadId,
1027
353
  ...(completedTurnId ? { turnId: completedTurnId } : {}),
1028
354
  failureReason: "Codex reported the turn completed in a failed state",
1029
355
  });
1030
- this.db.upsertIssueWithLease(lease, {
356
+ this.db.issueSessions.upsertIssueWithLease(lease, {
1031
357
  projectId: run.projectId,
1032
358
  linearIssueId: run.linearIssueId,
1033
359
  activeRunId: null,
@@ -1057,158 +383,33 @@ export class RunOrchestrator {
1057
383
  this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1058
384
  return;
1059
385
  }
1060
- // Complete the run
1061
- const trackedIssue = this.db.issueToTrackedIssue(issue);
1062
- const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
1063
- // Determine post-run state based on current PR metadata.
1064
- const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
1065
- const verifiedRepairError = await this.verifyReactiveRunAdvancedBranch(run, freshIssue);
1066
- if (verifiedRepairError) {
1067
- const holdState = resolveRecoverablePostRunState(freshIssue) ?? "failed";
1068
- this.failRunAndClear(run, verifiedRepairError, holdState);
1069
- const heldIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
1070
- this.feed?.publish({
1071
- level: "warn",
1072
- kind: "turn",
1073
- issueKey: freshIssue.issueKey,
1074
- projectId: run.projectId,
1075
- stage: run.runType,
1076
- status: "branch_not_advanced",
1077
- summary: verifiedRepairError,
1078
- });
1079
- void this.linearSync.emitActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
1080
- void this.linearSync.syncSession(heldIssue, { activeRunType: run.runType });
1081
- this.linearSync.clearProgress(run.id);
1082
- this.activeThreadId = undefined;
1083
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1084
- return;
1085
- }
1086
- const missingReviewFixHeadError = await this.verifyReviewFixAdvancedHead(run, freshIssue);
1087
- if (missingReviewFixHeadError) {
1088
- this.failRunAndClear(run, missingReviewFixHeadError, "escalated");
1089
- const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
1090
- this.feed?.publish({
1091
- level: "error",
1092
- kind: "turn",
1093
- issueKey: freshIssue.issueKey,
1094
- projectId: run.projectId,
1095
- stage: run.runType,
1096
- status: "same_head_review_handoff_blocked",
1097
- summary: missingReviewFixHeadError,
1098
- });
1099
- void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, missingReviewFixHeadError));
1100
- void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1101
- this.linearSync.clearProgress(run.id);
1102
- this.activeThreadId = undefined;
1103
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1104
- return;
1105
- }
1106
- const publishedOutcomeError = await this.verifyPublishedRunOutcome(run, freshIssue);
1107
- if (publishedOutcomeError) {
1108
- this.failRunAndClear(run, publishedOutcomeError, "failed");
1109
- const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
1110
- this.feed?.publish({
1111
- level: "warn",
1112
- kind: "turn",
1113
- issueKey: freshIssue.issueKey,
1114
- projectId: run.projectId,
1115
- stage: run.runType,
1116
- status: "publish_incomplete",
1117
- summary: publishedOutcomeError,
1118
- });
1119
- void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, publishedOutcomeError));
1120
- void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1121
- this.linearSync.clearProgress(run.id);
1122
- this.activeThreadId = undefined;
1123
- return;
1124
- }
1125
- const refreshedIssue = await this.refreshIssueAfterReactivePublish(run, freshIssue);
1126
- const postRunFollowUp = await this.resolvePostRunFollowUp(run, refreshedIssue);
1127
- const postRunState = postRunFollowUp?.factoryState ?? resolveCompletedRunState(refreshedIssue, run);
1128
- const completed = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
1129
- this.db.finishRun(run.id, {
1130
- status: "completed",
1131
- threadId,
1132
- ...(completedTurnId ? { turnId: completedTurnId } : {}),
1133
- summaryJson: JSON.stringify({ latestAssistantMessage: report.assistantMessages.at(-1) ?? null }),
1134
- reportJson: JSON.stringify(report),
1135
- });
1136
- this.db.upsertIssue({
1137
- projectId: run.projectId,
1138
- linearIssueId: run.linearIssueId,
1139
- activeRunId: null,
1140
- ...(postRunState ? { factoryState: postRunState } : {}),
1141
- pendingRunType: null,
1142
- pendingRunContextJson: null,
1143
- ...(postRunFollowUp ? {} : (postRunState === "awaiting_queue" || postRunState === "done"
1144
- ? {
1145
- lastGitHubFailureSource: null,
1146
- lastGitHubFailureHeadSha: null,
1147
- lastGitHubFailureSignature: null,
1148
- lastGitHubFailureCheckName: null,
1149
- lastGitHubFailureCheckUrl: null,
1150
- lastGitHubFailureContextJson: null,
1151
- lastGitHubFailureAt: null,
1152
- lastQueueIncidentJson: null,
1153
- lastAttemptedFailureHeadSha: null,
1154
- lastAttemptedFailureSignature: null,
1155
- }
1156
- : {})),
1157
- });
1158
- if (postRunFollowUp) {
1159
- return this.appendWakeEventWithLease(lease, issue, postRunFollowUp.pendingRunType, postRunFollowUp.context, "post_run");
1160
- }
1161
- return true;
1162
- });
1163
- if (!completed) {
1164
- this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion writes after losing issue-session lease");
1165
- this.linearSync.clearProgress(run.id);
1166
- this.activeThreadId = undefined;
1167
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1168
- return;
1169
- }
1170
- if (postRunFollowUp) {
1171
- this.feed?.publish({
1172
- level: "info",
1173
- kind: "stage",
1174
- issueKey: issue.issueKey,
1175
- projectId: run.projectId,
1176
- stage: postRunFollowUp.factoryState,
1177
- status: "follow_up_queued",
1178
- summary: postRunFollowUp.summary,
1179
- });
1180
- this.enqueueIssue(run.projectId, run.linearIssueId);
1181
- }
1182
- this.feed?.publish({
1183
- level: "info",
1184
- kind: "turn",
1185
- issueKey: issue.issueKey,
1186
- projectId: run.projectId,
1187
- stage: run.runType,
1188
- status: "completed",
1189
- summary: `Turn completed for ${run.runType}`,
1190
- detail: summarizeCurrentThread(thread).latestAgentMessage,
386
+ await this.runFinalizer.finalizeCompletedRun({
387
+ source: "notification",
388
+ run,
389
+ issue,
390
+ thread,
391
+ threadId,
392
+ ...(completedTurnId ? { completedTurnId } : {}),
393
+ withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
394
+ releaseLease: (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId),
395
+ failRunAndClear: (targetRun, message, nextState) => this.failRunAndClear(targetRun, message, nextState),
396
+ verifyReactiveRunAdvancedBranch: (targetRun, targetIssue) => this.verifyReactiveRunAdvancedBranch(targetRun, targetIssue),
397
+ verifyReviewFixAdvancedHead: (targetRun, targetIssue) => this.verifyReviewFixAdvancedHead(targetRun, targetIssue),
398
+ verifyPublishedRunOutcome: (targetRun, targetIssue) => this.verifyPublishedRunOutcome(targetRun, targetIssue),
399
+ refreshIssueAfterReactivePublish: (targetRun, targetIssue) => this.refreshIssueAfterReactivePublish(targetRun, targetIssue),
400
+ resolvePostRunFollowUp: (targetRun, targetIssue) => this.resolvePostRunFollowUp(targetRun, targetIssue),
401
+ resolveCompletedRunState,
402
+ resolveRecoverableRunState: resolveRecoverablePostRunState,
403
+ appendWakeEventWithLease: (lease, targetIssue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, targetIssue, runType, context, dedupeScope),
1191
404
  });
1192
- // Emit Linear completion activity + plan
1193
- const updatedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
1194
- const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
1195
- void this.linearSync.emitActivity(updatedIssue, buildRunCompletedActivity({
1196
- runType: run.runType,
1197
- completionSummary,
1198
- postRunState: updatedIssue.factoryState,
1199
- ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
1200
- }));
1201
- void this.linearSync.syncSession(updatedIssue);
1202
- this.linearSync.clearProgress(run.id);
1203
405
  this.activeThreadId = undefined;
1204
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1205
406
  }
1206
407
  // ─── Active status for query ──────────────────────────────────────
1207
408
  async getActiveRunStatus(issueKey) {
1208
409
  const issue = this.db.getIssueByKey(issueKey);
1209
410
  if (!issue?.activeRunId)
1210
411
  return undefined;
1211
- const run = this.db.getRun(issue.activeRunId);
412
+ const run = this.db.runs.getRunById(issue.activeRunId);
1212
413
  if (!run?.threadId)
1213
414
  return undefined;
1214
415
  const trackedIssue = this.db.issueToTrackedIssue(issue);
@@ -1221,7 +422,7 @@ export class RunOrchestrator {
1221
422
  }
1222
423
  // ─── Reconciliation ───────────────────────────────────────────────
1223
424
  async reconcileActiveRuns() {
1224
- for (const run of this.db.listRunningRuns()) {
425
+ for (const run of this.db.runs.listRunningRuns()) {
1225
426
  await this.reconcileRun(run);
1226
427
  }
1227
428
  // Preemptively detect stuck merge-queue PRs (conflicts visible on
@@ -1279,118 +480,14 @@ export class RunOrchestrator {
1279
480
  * escalate; backoff delay not elapsed → skip.
1280
481
  */
1281
482
  recoverOrEscalate(issue, runType, reason) {
1282
- // Re-read issue after the run was cleared (activeRunId is now null)
1283
- const fresh = this.db.getIssue(issue.projectId, issue.linearIssueId);
1284
- if (!fresh)
1285
- return;
1286
- if (isRequestedChangesRunType(runType)) {
1287
- const updated = this.withHeldIssueSessionLease(fresh.projectId, fresh.linearIssueId, (lease) => {
1288
- this.db.clearPendingIssueSessionEventsWithLease(lease);
1289
- this.db.upsertIssueWithLease(lease, {
1290
- projectId: fresh.projectId,
1291
- linearIssueId: fresh.linearIssueId,
1292
- pendingRunType: null,
1293
- pendingRunContextJson: null,
1294
- factoryState: "escalated",
1295
- });
1296
- return true;
1297
- });
1298
- if (!updated) {
1299
- this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping review-fix recovery escalation after losing issue-session lease");
1300
- this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1301
- return;
1302
- }
1303
- this.logger.warn({ issueKey: fresh.issueKey, reason }, "Requested-changes run failed before a new head was published — escalating");
1304
- this.feed?.publish({
1305
- level: "error",
1306
- kind: "workflow",
1307
- issueKey: fresh.issueKey,
1308
- projectId: fresh.projectId,
1309
- stage: runType,
1310
- status: "escalated",
1311
- summary: `Requested-changes run failed before publishing a new head (${reason})`,
1312
- });
1313
- this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1314
- return;
1315
- }
1316
- // If PR already merged, transition to done — no retry needed
1317
- if (fresh.prState === "merged") {
1318
- const updated = this.withHeldIssueSessionLease(fresh.projectId, fresh.linearIssueId, (lease) => {
1319
- this.db.upsertIssueWithLease(lease, {
1320
- projectId: fresh.projectId,
1321
- linearIssueId: fresh.linearIssueId,
1322
- factoryState: "done",
1323
- zombieRecoveryAttempts: 0,
1324
- lastZombieRecoveryAt: null,
1325
- });
1326
- return true;
1327
- });
1328
- if (!updated) {
1329
- this.logger.warn({ issueKey: fresh.issueKey, reason }, "Skipping merged recovery completion after losing issue-session lease");
1330
- this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1331
- return;
1332
- }
1333
- this.logger.info({ issueKey: fresh.issueKey, reason }, "Recovery: PR already merged — transitioning to done");
1334
- this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1335
- return;
1336
- }
1337
- // Budget check
1338
- const attempts = fresh.zombieRecoveryAttempts + 1;
1339
- if (attempts > DEFAULT_ZOMBIE_RECOVERY_BUDGET) {
1340
- const updated = this.withHeldIssueSessionLease(fresh.projectId, fresh.linearIssueId, (lease) => {
1341
- this.db.upsertIssueWithLease(lease, {
1342
- projectId: fresh.projectId,
1343
- linearIssueId: fresh.linearIssueId,
1344
- factoryState: "escalated",
1345
- });
1346
- return true;
1347
- });
1348
- if (!updated) {
1349
- this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery escalation after losing issue-session lease");
1350
- this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1351
- return;
1352
- }
1353
- this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: budget exhausted — escalating");
1354
- this.feed?.publish({
1355
- level: "error",
1356
- kind: "workflow",
1357
- issueKey: fresh.issueKey,
1358
- projectId: fresh.projectId,
1359
- stage: "escalated",
1360
- status: "budget_exhausted",
1361
- summary: `${reason} recovery failed after ${DEFAULT_ZOMBIE_RECOVERY_BUDGET} attempts`,
1362
- });
1363
- this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1364
- return;
1365
- }
1366
- // Exponential backoff — skip if delay hasn't elapsed
1367
- if (fresh.lastZombieRecoveryAt) {
1368
- const elapsed = Date.now() - new Date(fresh.lastZombieRecoveryAt).getTime();
1369
- const delay = ZOMBIE_RECOVERY_BASE_DELAY_MS * Math.pow(2, fresh.zombieRecoveryAttempts);
1370
- if (elapsed < delay) {
1371
- this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, delay, elapsed }, "Recovery: backoff not elapsed, skipping");
1372
- return;
1373
- }
1374
- }
1375
- // Re-enqueue with backoff tracking
1376
- const requeued = this.withHeldIssueSessionLease(fresh.projectId, fresh.linearIssueId, (lease) => {
1377
- this.db.upsertIssueWithLease(lease, {
1378
- projectId: fresh.projectId,
1379
- linearIssueId: fresh.linearIssueId,
1380
- pendingRunType: null,
1381
- pendingRunContextJson: null,
1382
- zombieRecoveryAttempts: attempts,
1383
- lastZombieRecoveryAt: new Date().toISOString(),
1384
- });
1385
- return this.appendWakeEventWithLease(lease, fresh, runType, undefined, `recovery:${attempts}`);
483
+ this.runRecovery.recoverOrEscalate({
484
+ issue,
485
+ runType,
486
+ reason,
487
+ isRequestedChangesRunType,
488
+ withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
489
+ appendWakeEventWithLease: (lease, targetIssue, pendingRunType, context, dedupeScope) => this.appendWakeEventWithLease(lease, targetIssue, pendingRunType, context, dedupeScope),
1386
490
  });
1387
- if (!requeued) {
1388
- this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Skipping recovery re-enqueue after losing issue-session lease");
1389
- this.releaseIssueSessionLease(fresh.projectId, fresh.linearIssueId);
1390
- return;
1391
- }
1392
- this.enqueueIssue(fresh.projectId, fresh.linearIssueId);
1393
- this.logger.info({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: re-enqueued with backoff");
1394
491
  }
1395
492
  async reconcileRun(run) {
1396
493
  const issue = this.db.getIssue(run.projectId, run.linearIssueId);
@@ -1407,7 +504,7 @@ export class RunOrchestrator {
1407
504
  // (e.g. pr_merged processed, DB manually edited), just release the run.
1408
505
  if (TERMINAL_STATES.has(issue.factoryState)) {
1409
506
  this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
1410
- this.db.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
507
+ this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
1411
508
  this.db.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
1412
509
  });
1413
510
  this.logger.info({ issueKey: issue.issueKey, runId: run.id, factoryState: issue.factoryState }, "Reconciliation: released run on terminal issue");
@@ -1420,7 +517,7 @@ export class RunOrchestrator {
1420
517
  if (!run.threadId) {
1421
518
  this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
1422
519
  this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
1423
- this.db.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
520
+ this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
1424
521
  this.db.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
1425
522
  });
1426
523
  this.recoverOrEscalate(issue, run.runType, "zombie");
@@ -1438,7 +535,7 @@ export class RunOrchestrator {
1438
535
  catch {
1439
536
  this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
1440
537
  this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
1441
- this.db.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
538
+ this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
1442
539
  this.db.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
1443
540
  });
1444
541
  this.recoverOrEscalate(issue, run.runType, "stale_thread");
@@ -1456,7 +553,7 @@ export class RunOrchestrator {
1456
553
  const stopState = resolveAuthoritativeLinearStopState(linearIssue);
1457
554
  if (stopState?.isFinal) {
1458
555
  this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, () => {
1459
- this.db.finishRun(run.id, { status: "released" });
556
+ this.db.runs.finishRun(run.id, { status: "released" });
1460
557
  this.db.upsertIssue({
1461
558
  projectId: run.projectId,
1462
559
  linearIssueId: run.linearIssueId,
@@ -1490,21 +587,21 @@ export class RunOrchestrator {
1490
587
  // Interrupted runs are not real failures — undo the budget increment.
1491
588
  const repairedCounters = this.withHeldIssueSessionLease(issue.projectId, issue.linearIssueId, (lease) => {
1492
589
  if (run.runType === "ci_repair" && issue.ciRepairAttempts > 0) {
1493
- this.db.upsertIssueWithLease(lease, {
590
+ this.db.issueSessions.upsertIssueWithLease(lease, {
1494
591
  projectId: issue.projectId,
1495
592
  linearIssueId: issue.linearIssueId,
1496
593
  ciRepairAttempts: issue.ciRepairAttempts - 1,
1497
594
  });
1498
595
  }
1499
596
  else if (run.runType === "queue_repair" && issue.queueRepairAttempts > 0) {
1500
- this.db.upsertIssueWithLease(lease, {
597
+ this.db.issueSessions.upsertIssueWithLease(lease, {
1501
598
  projectId: issue.projectId,
1502
599
  linearIssueId: issue.linearIssueId,
1503
600
  queueRepairAttempts: issue.queueRepairAttempts - 1,
1504
601
  });
1505
602
  }
1506
603
  if (run.runType === "ci_repair" || run.runType === "queue_repair") {
1507
- this.db.upsertIssueWithLease(lease, {
604
+ this.db.issueSessions.upsertIssueWithLease(lease, {
1508
605
  projectId: issue.projectId,
1509
606
  linearIssueId: issue.linearIssueId,
1510
607
  lastAttemptedFailureHeadSha: null,
@@ -1596,140 +693,25 @@ export class RunOrchestrator {
1596
693
  }
1597
694
  // Handle completed turn discovered during reconciliation
1598
695
  if (latestTurn?.status === "completed") {
1599
- const trackedIssue = this.db.issueToTrackedIssue(issue);
1600
- const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
1601
- const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
1602
- const verifiedRepairError = await this.verifyReactiveRunAdvancedBranch(run, freshIssue);
1603
- if (verifiedRepairError) {
1604
- const holdState = resolveRecoverablePostRunState(freshIssue) ?? "failed";
1605
- this.failRunAndClear(run, verifiedRepairError, holdState);
1606
- this.feed?.publish({
1607
- level: "warn",
1608
- kind: "turn",
1609
- issueKey: issue.issueKey,
1610
- projectId: run.projectId,
1611
- stage: run.runType,
1612
- status: "branch_not_advanced",
1613
- summary: verifiedRepairError,
1614
- });
1615
- const heldIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
1616
- void this.linearSync.emitActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
1617
- void this.linearSync.syncSession(heldIssue, { activeRunType: run.runType });
1618
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1619
- return;
1620
- }
1621
- const missingReviewFixHeadError = await this.verifyReviewFixAdvancedHead(run, freshIssue);
1622
- if (missingReviewFixHeadError) {
1623
- this.failRunAndClear(run, missingReviewFixHeadError, "escalated");
1624
- const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
1625
- this.feed?.publish({
1626
- level: "error",
1627
- kind: "turn",
1628
- issueKey: freshIssue.issueKey,
1629
- projectId: run.projectId,
1630
- stage: run.runType,
1631
- status: "same_head_review_handoff_blocked",
1632
- summary: missingReviewFixHeadError,
1633
- });
1634
- void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, missingReviewFixHeadError));
1635
- void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1636
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1637
- return;
1638
- }
1639
- const publishedOutcomeError = await this.verifyPublishedRunOutcome(run, freshIssue);
1640
- if (publishedOutcomeError) {
1641
- this.failRunAndClear(run, publishedOutcomeError, "failed");
1642
- this.feed?.publish({
1643
- level: "warn",
1644
- kind: "turn",
1645
- issueKey: issue.issueKey,
1646
- projectId: run.projectId,
1647
- stage: run.runType,
1648
- status: "publish_incomplete",
1649
- summary: publishedOutcomeError,
1650
- });
1651
- const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
1652
- void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, publishedOutcomeError));
1653
- void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1654
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1655
- return;
1656
- }
1657
- const refreshedIssue = await this.refreshIssueAfterReactivePublish(run, freshIssue);
1658
- const postRunFollowUp = await this.resolvePostRunFollowUp(run, refreshedIssue);
1659
- const postRunState = postRunFollowUp?.factoryState ?? resolveCompletedRunState(refreshedIssue, run);
1660
- const reconciled = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
1661
- this.db.finishRun(run.id, {
1662
- status: "completed",
1663
- ...(run.threadId ? { threadId: run.threadId } : {}),
1664
- ...(latestTurn.id ? { turnId: latestTurn.id } : {}),
1665
- summaryJson: JSON.stringify({ latestAssistantMessage: report.assistantMessages.at(-1) ?? null }),
1666
- reportJson: JSON.stringify(report),
1667
- });
1668
- this.db.upsertIssue({
1669
- projectId: run.projectId,
1670
- linearIssueId: run.linearIssueId,
1671
- activeRunId: null,
1672
- ...(postRunState ? { factoryState: postRunState } : {}),
1673
- pendingRunType: null,
1674
- pendingRunContextJson: null,
1675
- ...(postRunFollowUp ? {} : (postRunState === "awaiting_queue" || postRunState === "done"
1676
- ? {
1677
- lastGitHubFailureSource: null,
1678
- lastGitHubFailureHeadSha: null,
1679
- lastGitHubFailureSignature: null,
1680
- lastGitHubFailureCheckName: null,
1681
- lastGitHubFailureCheckUrl: null,
1682
- lastGitHubFailureContextJson: null,
1683
- lastGitHubFailureAt: null,
1684
- lastQueueIncidentJson: null,
1685
- lastAttemptedFailureHeadSha: null,
1686
- lastAttemptedFailureSignature: null,
1687
- }
1688
- : {})),
1689
- });
1690
- if (postRunFollowUp) {
1691
- return this.appendWakeEventWithLease(lease, issue, postRunFollowUp.pendingRunType, postRunFollowUp.context, "post_run");
1692
- }
1693
- return true;
696
+ await this.runFinalizer.finalizeCompletedRun({
697
+ source: "reconciliation",
698
+ run,
699
+ issue,
700
+ thread,
701
+ threadId: run.threadId,
702
+ ...(latestTurn.id ? { completedTurnId: latestTurn.id } : {}),
703
+ withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
704
+ releaseLease: (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId),
705
+ failRunAndClear: (targetRun, message, nextState) => this.failRunAndClear(targetRun, message, nextState),
706
+ verifyReactiveRunAdvancedBranch: (targetRun, targetIssue) => this.verifyReactiveRunAdvancedBranch(targetRun, targetIssue),
707
+ verifyReviewFixAdvancedHead: (targetRun, targetIssue) => this.verifyReviewFixAdvancedHead(targetRun, targetIssue),
708
+ verifyPublishedRunOutcome: (targetRun, targetIssue) => this.verifyPublishedRunOutcome(targetRun, targetIssue),
709
+ refreshIssueAfterReactivePublish: (targetRun, targetIssue) => this.refreshIssueAfterReactivePublish(targetRun, targetIssue),
710
+ resolvePostRunFollowUp: (targetRun, targetIssue) => this.resolvePostRunFollowUp(targetRun, targetIssue),
711
+ resolveCompletedRunState,
712
+ resolveRecoverableRunState: resolveRecoverablePostRunState,
713
+ appendWakeEventWithLease: (lease, targetIssue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, targetIssue, runType, context, dedupeScope),
1694
714
  });
1695
- if (!reconciled) {
1696
- this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping reconciled completion writes after losing issue-session lease");
1697
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1698
- return;
1699
- }
1700
- if (postRunFollowUp) {
1701
- this.feed?.publish({
1702
- level: "info",
1703
- kind: "stage",
1704
- issueKey: issue.issueKey,
1705
- projectId: run.projectId,
1706
- stage: postRunFollowUp.factoryState,
1707
- status: "follow_up_queued",
1708
- summary: postRunFollowUp.summary,
1709
- });
1710
- this.enqueueIssue(run.projectId, run.linearIssueId);
1711
- }
1712
- if (postRunState) {
1713
- this.feed?.publish({
1714
- level: "info",
1715
- kind: "turn",
1716
- issueKey: issue.issueKey,
1717
- projectId: run.projectId,
1718
- stage: run.runType,
1719
- status: "completed",
1720
- summary: `Reconciliation: ${run.runType} completed \u2192 ${postRunState}`,
1721
- });
1722
- }
1723
- const updatedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
1724
- const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
1725
- void this.linearSync.emitActivity(updatedIssue, buildRunCompletedActivity({
1726
- runType: run.runType,
1727
- completionSummary,
1728
- postRunState: updatedIssue.factoryState,
1729
- ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
1730
- }));
1731
- void this.linearSync.syncSession(updatedIssue);
1732
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1733
715
  return;
1734
716
  }
1735
717
  if (acquiredRecoveryLease)
@@ -1737,69 +719,21 @@ export class RunOrchestrator {
1737
719
  }
1738
720
  // ─── Internal helpers ─────────────────────────────────────────────
1739
721
  escalate(issue, runType, reason) {
1740
- this.logger.warn({ issueKey: issue.issueKey, runType, reason }, "Escalating to human");
1741
- const escalated = this.withHeldIssueSessionLease(issue.projectId, issue.linearIssueId, (lease) => {
1742
- if (issue.activeRunId) {
1743
- this.db.finishRunWithLease(lease, issue.activeRunId, { status: "released" });
1744
- }
1745
- this.db.clearPendingIssueSessionEventsWithLease(lease);
1746
- this.db.upsertIssueWithLease(lease, {
1747
- projectId: issue.projectId,
1748
- linearIssueId: issue.linearIssueId,
1749
- pendingRunType: null,
1750
- pendingRunContextJson: null,
1751
- activeRunId: null,
1752
- factoryState: "escalated",
1753
- });
1754
- return true;
722
+ this.runRecovery.escalate({
723
+ issue,
724
+ runType,
725
+ reason,
726
+ withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
1755
727
  });
1756
- if (!escalated) {
1757
- this.logger.warn({ issueKey: issue.issueKey, runType }, "Skipping escalation write after losing issue-session lease");
1758
- this.releaseIssueSessionLease(issue.projectId, issue.linearIssueId);
1759
- return;
1760
- }
1761
- this.feed?.publish({
1762
- level: "error",
1763
- kind: "workflow",
1764
- issueKey: issue.issueKey,
1765
- projectId: issue.projectId,
1766
- stage: runType,
1767
- status: "escalated",
1768
- summary: `Escalated: ${reason}`,
1769
- });
1770
- const escalatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
1771
- void this.linearSync.emitActivity(escalatedIssue, {
1772
- type: "error",
1773
- body: `PatchRelay needs human help to continue.\n\n${reason}`,
1774
- });
1775
- void this.linearSync.syncSession(escalatedIssue);
1776
- this.releaseIssueSessionLease(issue.projectId, issue.linearIssueId);
1777
728
  }
1778
729
  failRunAndClear(run, message, nextState = "failed") {
1779
- const updated = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (lease) => {
1780
- this.db.finishRun(run.id, { status: "failed", failureReason: message });
1781
- if (nextState === "failed" || nextState === "escalated" || nextState === "awaiting_input" || nextState === "done") {
1782
- this.db.clearPendingIssueSessionEventsWithLease(lease);
1783
- }
1784
- this.db.upsertIssue({
1785
- projectId: run.projectId,
1786
- linearIssueId: run.linearIssueId,
1787
- activeRunId: null,
1788
- factoryState: nextState,
1789
- });
1790
- const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
1791
- if (branchOwner) {
1792
- const lease = this.getHeldIssueSessionLease(run.projectId, run.linearIssueId);
1793
- if (lease) {
1794
- this.db.setBranchOwnerWithLease(lease, branchOwner);
1795
- }
1796
- }
1797
- return true;
730
+ this.runRecovery.failRunAndClear({
731
+ run,
732
+ message,
733
+ nextState,
734
+ withHeldLease: (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn),
735
+ getHeldLease: (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId),
1798
736
  });
1799
- if (!updated) {
1800
- this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping failure cleanup after losing issue-session lease");
1801
- }
1802
- this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
1803
737
  }
1804
738
  resolveBranchOwnerForStateTransition(newState, pendingRunType) {
1805
739
  return resolveBranchOwnerForStateTransition(newState, pendingRunType);
@@ -2147,20 +1081,11 @@ export class RunOrchestrator {
2147
1081
  }
2148
1082
  throw new Error(`Failed to read thread ${threadId}`);
2149
1083
  }
2150
- issueSessionLeaseKey(projectId, linearIssueId) {
2151
- return `${projectId}:${linearIssueId}`;
2152
- }
2153
1084
  getHeldIssueSessionLease(projectId, linearIssueId) {
2154
- const leaseId = this.activeSessionLeases.get(this.issueSessionLeaseKey(projectId, linearIssueId));
2155
- if (!leaseId)
2156
- return undefined;
2157
- return { projectId, linearIssueId, leaseId };
1085
+ return this.leaseService.getHeldLease(projectId, linearIssueId);
2158
1086
  }
2159
1087
  withHeldIssueSessionLease(projectId, linearIssueId, fn) {
2160
- const lease = this.getHeldIssueSessionLease(projectId, linearIssueId);
2161
- if (!lease)
2162
- return undefined;
2163
- return this.db.withIssueSessionLease(projectId, linearIssueId, lease.leaseId, () => fn(lease));
1088
+ return this.leaseService.withHeldLease(projectId, linearIssueId, fn);
2164
1089
  }
2165
1090
  upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
2166
1091
  const lease = this.getHeldIssueSessionLease(projectId, linearIssueId);
@@ -2168,7 +1093,7 @@ export class RunOrchestrator {
2168
1093
  this.logger.warn({ projectId, linearIssueId, context }, "Skipping issue write without a held issue-session lease");
2169
1094
  return undefined;
2170
1095
  }
2171
- const updated = this.db.upsertIssueWithLease(lease, params);
1096
+ const updated = this.db.issueSessions.upsertIssueWithLease(lease, params);
2172
1097
  if (!updated) {
2173
1098
  this.logger.warn({ projectId, linearIssueId, context }, "Skipping issue write after losing issue-session lease");
2174
1099
  }
@@ -2184,113 +1109,22 @@ export class RunOrchestrator {
2184
1109
  throw error;
2185
1110
  }
2186
1111
  acquireIssueSessionLease(projectId, linearIssueId) {
2187
- const leaseId = randomUUID();
2188
- const leasedUntil = new Date(Date.now() + ISSUE_SESSION_LEASE_MS).toISOString();
2189
- const acquired = this.db.acquireIssueSessionLease({
2190
- projectId,
2191
- linearIssueId,
2192
- leaseId,
2193
- workerId: this.workerId,
2194
- leasedUntil,
2195
- });
2196
- if (!acquired)
2197
- return undefined;
2198
- this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
2199
- return leaseId;
1112
+ return this.leaseService.acquire(projectId, linearIssueId);
2200
1113
  }
2201
1114
  forceAcquireIssueSessionLease(projectId, linearIssueId) {
2202
- const leaseId = randomUUID();
2203
- const leasedUntil = new Date(Date.now() + ISSUE_SESSION_LEASE_MS).toISOString();
2204
- const acquired = this.db.forceAcquireIssueSessionLease({
2205
- projectId,
2206
- linearIssueId,
2207
- leaseId,
2208
- workerId: this.workerId,
2209
- leasedUntil,
2210
- });
2211
- if (!acquired)
2212
- return undefined;
2213
- this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
2214
- return leaseId;
1115
+ return this.leaseService.forceAcquire(projectId, linearIssueId);
2215
1116
  }
2216
1117
  claimLeaseForReconciliation(projectId, linearIssueId) {
2217
- const key = this.issueSessionLeaseKey(projectId, linearIssueId);
2218
- if (this.activeSessionLeases.has(key)) {
2219
- return "owned";
2220
- }
2221
- const session = this.db.getIssueSession(projectId, linearIssueId);
2222
- if (!session)
2223
- return "skip";
2224
- const leasedUntilMs = session.leasedUntil ? Date.parse(session.leasedUntil) : undefined;
2225
- if (leasedUntilMs !== undefined && Number.isFinite(leasedUntilMs) && leasedUntilMs > Date.now()) {
2226
- return "skip";
2227
- }
2228
- return this.acquireIssueSessionLease(projectId, linearIssueId) ? true : "skip";
1118
+ return this.leaseService.claimForReconciliation(projectId, linearIssueId);
2229
1119
  }
2230
1120
  async reclaimForeignRecoveryLeaseIfSafe(run, issue) {
2231
- const key = this.issueSessionLeaseKey(run.projectId, run.linearIssueId);
2232
- if (this.activeSessionLeases.has(key)) {
2233
- return false;
2234
- }
2235
- const session = this.db.getIssueSession(run.projectId, run.linearIssueId);
2236
- if (!session?.leaseId || !session.workerId || session.workerId === this.workerId) {
2237
- return false;
2238
- }
2239
- if (issue.activeRunId !== run.id) {
2240
- return false;
2241
- }
2242
- let safeToReclaim = !run.threadId;
2243
- if (!safeToReclaim && run.threadId) {
2244
- try {
2245
- const thread = await this.readThreadWithRetry(run.threadId, 1);
2246
- const latestTurn = getThreadTurns(thread).at(-1);
2247
- safeToReclaim = thread.status === "notLoaded"
2248
- || latestTurn?.status === "interrupted"
2249
- || latestTurn?.status === "completed";
2250
- }
2251
- catch {
2252
- safeToReclaim = true;
2253
- }
2254
- }
2255
- if (!safeToReclaim) {
2256
- return false;
2257
- }
2258
- const leaseId = this.forceAcquireIssueSessionLease(run.projectId, run.linearIssueId);
2259
- if (!leaseId) {
2260
- return false;
2261
- }
2262
- this.logger.info({
2263
- issueKey: issue.issueKey,
2264
- runId: run.id,
2265
- previousWorkerId: session.workerId,
2266
- previousLeaseId: session.leaseId,
2267
- reclaimedLeaseId: leaseId,
2268
- }, "Reclaimed foreign issue-session lease for active-run recovery");
2269
- return true;
1121
+ return await this.leaseService.reclaimForeignRecoveryLeaseIfSafe(run, issue);
2270
1122
  }
2271
1123
  heartbeatIssueSessionLease(projectId, linearIssueId) {
2272
- const key = this.issueSessionLeaseKey(projectId, linearIssueId);
2273
- const leaseId = this.activeSessionLeases.get(key) ?? this.db.getIssueSession(projectId, linearIssueId)?.leaseId;
2274
- if (!leaseId)
2275
- return false;
2276
- const renewed = this.db.renewIssueSessionLease({
2277
- projectId,
2278
- linearIssueId,
2279
- leaseId,
2280
- leasedUntil: new Date(Date.now() + ISSUE_SESSION_LEASE_MS).toISOString(),
2281
- });
2282
- if (renewed) {
2283
- this.activeSessionLeases.set(key, leaseId);
2284
- return true;
2285
- }
2286
- this.activeSessionLeases.delete(key);
2287
- return false;
1124
+ return this.leaseService.heartbeat(projectId, linearIssueId);
2288
1125
  }
2289
1126
  releaseIssueSessionLease(projectId, linearIssueId) {
2290
- const key = this.issueSessionLeaseKey(projectId, linearIssueId);
2291
- const leaseId = this.activeSessionLeases.get(key);
2292
- this.db.releaseIssueSessionLease(projectId, linearIssueId, leaseId);
2293
- this.activeSessionLeases.delete(key);
1127
+ this.leaseService.release(projectId, linearIssueId);
2294
1128
  }
2295
1129
  }
2296
1130
  /**
@@ -2377,82 +1211,3 @@ function buildReviewFixBranchUpkeepContext(prNumber, baseBranch, pr, context) {
2377
1211
  baseBranch,
2378
1212
  };
2379
1213
  }
2380
- function appendQueueRepairContext(lines, context) {
2381
- const incidentTitle = typeof context?.incidentTitle === "string" ? context.incidentTitle.trim() : "";
2382
- const incidentSummary = typeof context?.incidentSummary === "string" ? context.incidentSummary.trim() : "";
2383
- const incidentId = typeof context?.incidentId === "string" ? context.incidentId.trim() : "";
2384
- const incidentUrl = typeof context?.incidentUrl === "string" ? context.incidentUrl.trim() : "";
2385
- const incidentContext = context?.incidentContext && typeof context.incidentContext === "object"
2386
- ? context.incidentContext
2387
- : undefined;
2388
- const failureClass = typeof incidentContext?.failureClass === "string" ? incidentContext.failureClass : "";
2389
- const baseSha = typeof incidentContext?.baseSha === "string" ? incidentContext.baseSha : "";
2390
- const prHeadSha = typeof incidentContext?.prHeadSha === "string" ? incidentContext.prHeadSha : "";
2391
- const baseBranch = typeof incidentContext?.baseBranch === "string" ? incidentContext.baseBranch : "";
2392
- const branch = typeof incidentContext?.branch === "string" ? incidentContext.branch : "";
2393
- const queuePosition = typeof incidentContext?.queuePosition === "number" ? String(incidentContext.queuePosition) : "";
2394
- const conflictFiles = Array.isArray(incidentContext?.conflictFiles)
2395
- ? incidentContext.conflictFiles.filter((entry) => typeof entry === "string")
2396
- : [];
2397
- const failedChecks = Array.isArray(incidentContext?.failedChecks)
2398
- ? incidentContext.failedChecks
2399
- .filter((entry) => Boolean(entry) && typeof entry === "object")
2400
- .map((entry) => ({
2401
- name: typeof entry.name === "string" ? entry.name : "unknown",
2402
- conclusion: typeof entry.conclusion === "string" ? entry.conclusion : "unknown",
2403
- ...(typeof entry.url === "string" ? { url: entry.url } : {}),
2404
- }))
2405
- : [];
2406
- const retryHistory = Array.isArray(incidentContext?.retryHistory)
2407
- ? incidentContext.retryHistory
2408
- .filter((entry) => Boolean(entry) && typeof entry === "object")
2409
- .map((entry) => ({
2410
- at: typeof entry.at === "string" ? entry.at : "unknown",
2411
- baseSha: typeof entry.baseSha === "string" ? entry.baseSha : "unknown",
2412
- outcome: typeof entry.outcome === "string" ? entry.outcome : "unknown",
2413
- }))
2414
- : [];
2415
- if (!incidentTitle && !incidentSummary && !incidentId && !incidentUrl && !failureClass && !baseSha && !prHeadSha
2416
- && !queuePosition && conflictFiles.length === 0 && failedChecks.length === 0 && retryHistory.length === 0) {
2417
- return;
2418
- }
2419
- lines.push("## Queue Incident Context", "");
2420
- if (incidentTitle)
2421
- lines.push(`Incident: ${incidentTitle}`);
2422
- if (incidentId)
2423
- lines.push(`Incident ID: ${incidentId}`);
2424
- if (incidentUrl)
2425
- lines.push(`Incident URL: ${incidentUrl}`);
2426
- if (incidentSummary)
2427
- lines.push("", incidentSummary, "");
2428
- if (failureClass)
2429
- lines.push(`Failure class: ${failureClass}`);
2430
- if (baseBranch)
2431
- lines.push(`Base branch: ${baseBranch}`);
2432
- if (baseSha)
2433
- lines.push(`Base SHA: ${baseSha}`);
2434
- if (branch)
2435
- lines.push(`Queue branch: ${branch}`);
2436
- if (prHeadSha)
2437
- lines.push(`Queue branch head SHA: ${prHeadSha}`);
2438
- if (queuePosition)
2439
- lines.push(`Queue position at eviction: ${queuePosition}`);
2440
- if (conflictFiles.length > 0) {
2441
- lines.push("", "Conflicting files:");
2442
- for (const file of conflictFiles)
2443
- lines.push(`- ${file}`);
2444
- }
2445
- if (failedChecks.length > 0) {
2446
- lines.push("", "Failed checks:");
2447
- for (const check of failedChecks) {
2448
- lines.push(`- ${check.name} (${check.conclusion})${check.url ? ` ${check.url}` : ""}`);
2449
- }
2450
- }
2451
- if (retryHistory.length > 0) {
2452
- lines.push("", "Retry history:");
2453
- for (const retry of retryHistory) {
2454
- lines.push(`- ${retry.at}: ${retry.outcome} on base ${retry.baseSha}`);
2455
- }
2456
- }
2457
- lines.push("");
2458
- }