patchrelay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/config/patchrelay.example.json +5 -0
  4. package/dist/build-info.js +29 -0
  5. package/dist/build-info.json +6 -0
  6. package/dist/cli/data.js +461 -0
  7. package/dist/cli/formatters/json.js +3 -0
  8. package/dist/cli/formatters/text.js +119 -0
  9. package/dist/cli/index.js +761 -0
  10. package/dist/codex-app-server.js +353 -0
  11. package/dist/codex-types.js +1 -0
  12. package/dist/config-types.js +1 -0
  13. package/dist/config.js +494 -0
  14. package/dist/db/authoritative-ledger-store.js +437 -0
  15. package/dist/db/issue-workflow-store.js +690 -0
  16. package/dist/db/linear-installation-store.js +184 -0
  17. package/dist/db/migrations.js +183 -0
  18. package/dist/db/shared.js +101 -0
  19. package/dist/db/stage-event-store.js +33 -0
  20. package/dist/db/webhook-event-store.js +46 -0
  21. package/dist/db-ports.js +5 -0
  22. package/dist/db-types.js +1 -0
  23. package/dist/db.js +40 -0
  24. package/dist/file-permissions.js +40 -0
  25. package/dist/http.js +321 -0
  26. package/dist/index.js +69 -0
  27. package/dist/install.js +302 -0
  28. package/dist/installation-ports.js +1 -0
  29. package/dist/issue-query-service.js +68 -0
  30. package/dist/ledger-ports.js +1 -0
  31. package/dist/linear-client.js +338 -0
  32. package/dist/linear-oauth-service.js +131 -0
  33. package/dist/linear-oauth.js +154 -0
  34. package/dist/linear-types.js +1 -0
  35. package/dist/linear-workflow.js +78 -0
  36. package/dist/logging.js +62 -0
  37. package/dist/preflight.js +227 -0
  38. package/dist/project-resolution.js +51 -0
  39. package/dist/reconciliation-action-applier.js +55 -0
  40. package/dist/reconciliation-actions.js +1 -0
  41. package/dist/reconciliation-engine.js +312 -0
  42. package/dist/reconciliation-snapshot-builder.js +96 -0
  43. package/dist/reconciliation-types.js +1 -0
  44. package/dist/runtime-paths.js +89 -0
  45. package/dist/service-queue.js +49 -0
  46. package/dist/service-runtime.js +96 -0
  47. package/dist/service-stage-finalizer.js +348 -0
  48. package/dist/service-stage-runner.js +233 -0
  49. package/dist/service-webhook-processor.js +181 -0
  50. package/dist/service-webhooks.js +148 -0
  51. package/dist/service.js +139 -0
  52. package/dist/stage-agent-activity-publisher.js +33 -0
  53. package/dist/stage-event-ports.js +1 -0
  54. package/dist/stage-failure.js +92 -0
  55. package/dist/stage-launch.js +54 -0
  56. package/dist/stage-lifecycle-publisher.js +213 -0
  57. package/dist/stage-reporting.js +153 -0
  58. package/dist/stage-turn-input-dispatcher.js +102 -0
  59. package/dist/token-crypto.js +21 -0
  60. package/dist/types.js +5 -0
  61. package/dist/utils.js +163 -0
  62. package/dist/webhook-agent-session-handler.js +157 -0
  63. package/dist/webhook-archive.js +24 -0
  64. package/dist/webhook-comment-handler.js +89 -0
  65. package/dist/webhook-desired-stage-recorder.js +150 -0
  66. package/dist/webhook-event-ports.js +1 -0
  67. package/dist/webhook-installation-handler.js +57 -0
  68. package/dist/webhooks.js +301 -0
  69. package/dist/workflow-policy.js +42 -0
  70. package/dist/workflow-ports.js +1 -0
  71. package/dist/workflow-types.js +1 -0
  72. package/dist/worktree-manager.js +66 -0
  73. package/infra/patchrelay-reload.service +6 -0
  74. package/infra/patchrelay.path +11 -0
  75. package/infra/patchrelay.service +28 -0
  76. package/package.json +55 -0
  77. package/runtime.env.example +8 -0
  78. package/service.env.example +7 -0
@@ -0,0 +1,348 @@
1
+ import { ReconciliationActionApplier } from "./reconciliation-action-applier.js";
2
+ import { reconcileIssue } from "./reconciliation-engine.js";
3
+ import { buildReconciliationSnapshot } from "./reconciliation-snapshot-builder.js";
4
+ import { syncFailedStageToLinear } from "./stage-failure.js";
5
+ import { buildFailedStageReport, buildPendingMaterializationThread, buildStageReport, countEventMethods, extractStageSummary, extractTurnId, resolveStageRunStatus, summarizeCurrentThread, } from "./stage-reporting.js";
6
+ import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
7
+ import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
8
+ import { safeJsonParse } from "./utils.js";
9
+ export class ServiceStageFinalizer {
10
+ config;
11
+ stores;
12
+ codex;
13
+ linearProvider;
14
+ enqueueIssue;
15
+ inputDispatcher;
16
+ lifecyclePublisher;
17
+ actionApplier;
18
+ runAtomically;
19
+ constructor(config, stores, codex, linearProvider, enqueueIssue, logger, runAtomically = (fn) => fn()) {
20
+ this.config = config;
21
+ this.stores = stores;
22
+ this.codex = codex;
23
+ this.linearProvider = linearProvider;
24
+ this.enqueueIssue = enqueueIssue;
25
+ this.runAtomically = runAtomically;
26
+ const lifecycleLogger = logger ?? consoleLogger();
27
+ this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, lifecycleLogger);
28
+ this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, lifecycleLogger);
29
+ this.actionApplier = new ReconciliationActionApplier({
30
+ enqueueIssue,
31
+ deliverPendingObligations: (projectId, linearIssueId, threadId, turnId) => this.deliverPendingObligations(projectId, linearIssueId, threadId, turnId),
32
+ completeRun: (projectId, linearIssueId, thread, params) => this.completeReconciledRun(projectId, linearIssueId, thread, params),
33
+ failRunDuringReconciliation: (projectId, linearIssueId, threadId, message, options) => this.failRunLeaseDuringReconciliation(projectId, linearIssueId, threadId, message, options),
34
+ });
35
+ }
36
+ async getActiveStageStatus(issueKey) {
37
+ const issue = this.stores.issueWorkflows.getTrackedIssueByKey(issueKey);
38
+ if (!issue) {
39
+ return undefined;
40
+ }
41
+ const stageRun = this.resolveActiveStageRun(issue);
42
+ if (!stageRun?.threadId) {
43
+ return undefined;
44
+ }
45
+ const thread = await this.codex.readThread(stageRun.threadId, true).catch((error) => {
46
+ const err = error instanceof Error ? error : new Error(String(error));
47
+ return buildPendingMaterializationThread(stageRun, err);
48
+ });
49
+ return {
50
+ issue,
51
+ stageRun,
52
+ liveThread: summarizeCurrentThread(thread),
53
+ };
54
+ }
55
+ async handleCodexNotification(notification) {
56
+ const threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
57
+ if (!threadId) {
58
+ return;
59
+ }
60
+ const stageRun = this.stores.issueWorkflows.getStageRunByThreadId(threadId);
61
+ if (!stageRun) {
62
+ return;
63
+ }
64
+ const turnId = typeof notification.params.turnId === "string" ? notification.params.turnId : undefined;
65
+ if (this.config.runner.codex.persistExtendedHistory) {
66
+ this.stores.stageEvents.saveThreadEvent({
67
+ stageRunId: stageRun.id,
68
+ threadId,
69
+ ...(turnId ? { turnId } : {}),
70
+ method: notification.method,
71
+ eventJson: JSON.stringify(notification.params),
72
+ });
73
+ }
74
+ if (notification.method === "turn/started" || notification.method.startsWith("item/")) {
75
+ await this.flushQueuedTurnInputs(stageRun);
76
+ }
77
+ if (notification.method !== "turn/completed") {
78
+ return;
79
+ }
80
+ const thread = await this.codex.readThread(threadId, true);
81
+ const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
82
+ if (!issue) {
83
+ return;
84
+ }
85
+ const completedTurnId = extractTurnId(notification.params);
86
+ const status = resolveStageRunStatus(notification.params);
87
+ if (status === "failed") {
88
+ await this.failStageRunAndSync(stageRun, issue, threadId, "Codex reported the turn completed in a failed state", {
89
+ ...(completedTurnId ? { turnId: completedTurnId } : {}),
90
+ });
91
+ return;
92
+ }
93
+ this.completeStageRun(stageRun, issue, thread, status, {
94
+ threadId,
95
+ ...(completedTurnId ? { turnId: completedTurnId } : {}),
96
+ });
97
+ }
98
+ async reconcileActiveStageRuns() {
99
+ for (const runLeaseId of this.stores.runLeases.listActiveRunLeases().filter((runLease) => runLease.status === "running").map((runLease) => runLease.id)) {
100
+ await this.reconcileRunLease(runLeaseId);
101
+ }
102
+ }
103
+ completeStageRun(stageRun, issue, thread, status, params) {
104
+ const refreshedStageRun = this.stores.issueWorkflows.getStageRun(stageRun.id) ?? stageRun;
105
+ const finalizedStageRun = {
106
+ ...refreshedStageRun,
107
+ status,
108
+ threadId: params.threadId,
109
+ ...(params.turnId ? { turnId: params.turnId } : {}),
110
+ };
111
+ const report = buildStageReport(finalizedStageRun, issue, thread, countEventMethods(this.stores.stageEvents.listThreadEvents(stageRun.id)));
112
+ this.runAtomically(() => {
113
+ this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "completed", {
114
+ threadId: params.threadId,
115
+ ...(params.turnId ? { turnId: params.turnId } : {}),
116
+ nextLifecycleStatus: params.nextLifecycleStatus ?? (issue.desiredStage ? "queued" : "completed"),
117
+ });
118
+ this.stores.issueWorkflows.finishStageRun({
119
+ stageRunId: stageRun.id,
120
+ status,
121
+ threadId: params.threadId,
122
+ ...(params.turnId ? { turnId: params.turnId } : {}),
123
+ summaryJson: JSON.stringify(extractStageSummary(report)),
124
+ reportJson: JSON.stringify(report),
125
+ });
126
+ });
127
+ void this.advanceAfterStageCompletion(stageRun);
128
+ }
129
+ failStageRun(stageRun, threadId, message, options) {
130
+ this.runAtomically(() => {
131
+ this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "failed", {
132
+ threadId,
133
+ ...(options?.turnId ? { turnId: options.turnId } : {}),
134
+ failureReason: message,
135
+ nextLifecycleStatus: "failed",
136
+ });
137
+ this.stores.issueWorkflows.finishStageRun({
138
+ stageRunId: stageRun.id,
139
+ status: "failed",
140
+ threadId,
141
+ ...(options?.turnId ? { turnId: options.turnId } : {}),
142
+ summaryJson: JSON.stringify({ message }),
143
+ reportJson: JSON.stringify(buildFailedStageReport(stageRun, "failed", {
144
+ threadId,
145
+ ...(options?.turnId ? { turnId: options.turnId } : {}),
146
+ })),
147
+ });
148
+ });
149
+ }
150
+ async failStageRunDuringReconciliation(stageRun, threadId, message, options) {
151
+ this.failStageRun(stageRun, threadId, message, options);
152
+ const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
153
+ const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
154
+ if (!issue || !project) {
155
+ return;
156
+ }
157
+ await syncFailedStageToLinear({
158
+ stores: this.stores,
159
+ linearProvider: this.linearProvider,
160
+ project,
161
+ issue,
162
+ stageRun: {
163
+ ...stageRun,
164
+ threadId,
165
+ ...(options?.turnId ? { turnId: options.turnId } : {}),
166
+ },
167
+ message,
168
+ mode: "failed",
169
+ requireActiveLinearStateMatch: true,
170
+ });
171
+ }
172
+ async failStageRunAndSync(stageRun, issue, threadId, message, options) {
173
+ this.failStageRun(stageRun, threadId, message, options);
174
+ const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
175
+ if (!project) {
176
+ return;
177
+ }
178
+ await syncFailedStageToLinear({
179
+ stores: this.stores,
180
+ linearProvider: this.linearProvider,
181
+ project,
182
+ issue,
183
+ stageRun: {
184
+ ...stageRun,
185
+ threadId,
186
+ ...(options?.turnId ? { turnId: options.turnId } : {}),
187
+ },
188
+ message,
189
+ mode: "failed",
190
+ });
191
+ }
192
+ async flushQueuedTurnInputs(stageRun) {
193
+ await this.inputDispatcher.flush(stageRun);
194
+ }
195
+ async advanceAfterStageCompletion(stageRun) {
196
+ await this.lifecyclePublisher.publishStageCompletion(stageRun, this.enqueueIssue);
197
+ }
198
+ finishLedgerRun(projectId, linearIssueId, status, params) {
199
+ const issueControl = this.stores.issueControl.getIssueControl(projectId, linearIssueId);
200
+ if (!issueControl?.activeRunLeaseId) {
201
+ return;
202
+ }
203
+ this.stores.runLeases.finishRunLease({
204
+ runLeaseId: issueControl.activeRunLeaseId,
205
+ status,
206
+ ...(params.threadId ? { threadId: params.threadId } : {}),
207
+ ...(params.turnId ? { turnId: params.turnId } : {}),
208
+ ...(params.failureReason ? { failureReason: params.failureReason } : {}),
209
+ });
210
+ if (issueControl.activeWorkspaceOwnershipId !== undefined) {
211
+ const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(issueControl.activeWorkspaceOwnershipId);
212
+ if (workspace) {
213
+ this.stores.workspaceOwnership.upsertWorkspaceOwnership({
214
+ projectId,
215
+ linearIssueId,
216
+ branchName: workspace.branchName,
217
+ worktreePath: workspace.worktreePath,
218
+ status: status === "completed" ? "active" : "paused",
219
+ currentRunLeaseId: null,
220
+ });
221
+ }
222
+ }
223
+ this.stores.issueControl.upsertIssueControl({
224
+ projectId,
225
+ linearIssueId,
226
+ activeRunLeaseId: null,
227
+ ...(issueControl.activeWorkspaceOwnershipId !== undefined
228
+ ? { activeWorkspaceOwnershipId: issueControl.activeWorkspaceOwnershipId }
229
+ : {}),
230
+ ...(issueControl.serviceOwnedCommentId ? { serviceOwnedCommentId: issueControl.serviceOwnedCommentId } : {}),
231
+ ...(issueControl.activeAgentSessionId ? { activeAgentSessionId: issueControl.activeAgentSessionId } : {}),
232
+ lifecycleStatus: params.nextLifecycleStatus,
233
+ });
234
+ }
235
+ async deliverPendingObligations(projectId, linearIssueId, threadId, turnId) {
236
+ if (!turnId) {
237
+ return;
238
+ }
239
+ await this.inputDispatcher.flush({
240
+ id: 0,
241
+ projectId,
242
+ linearIssueId,
243
+ threadId,
244
+ turnId,
245
+ }, {
246
+ retryInProgress: true,
247
+ });
248
+ }
249
+ async reconcileRunLease(runLeaseId) {
250
+ const snapshot = await buildReconciliationSnapshot({
251
+ config: this.config,
252
+ stores: {
253
+ issueControl: this.stores.issueControl,
254
+ runLeases: this.stores.runLeases,
255
+ workspaceOwnership: this.stores.workspaceOwnership,
256
+ obligations: this.stores.obligations,
257
+ },
258
+ codex: this.codex,
259
+ linearProvider: this.linearProvider,
260
+ runLeaseId,
261
+ });
262
+ if (!snapshot) {
263
+ return;
264
+ }
265
+ const decision = reconcileIssue(snapshot.input);
266
+ if (decision.outcome === "hydrate_live_state") {
267
+ throw new Error(`Startup reconciliation requires live state hydration for ${snapshot.runLease.projectId}:${snapshot.runLease.linearIssueId}: ${decision.reasons.join("; ")}`);
268
+ }
269
+ await this.actionApplier.apply({
270
+ snapshot,
271
+ decision,
272
+ });
273
+ }
274
+ completeReconciledRun(projectId, linearIssueId, thread, params) {
275
+ const stageRun = this.findStageRunForIssue(projectId, linearIssueId, params.threadId);
276
+ const issue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
277
+ if (!stageRun || !issue) {
278
+ this.finishLedgerRun(projectId, linearIssueId, "completed", {
279
+ threadId: params.threadId,
280
+ ...(params.turnId ? { turnId: params.turnId } : {}),
281
+ nextLifecycleStatus: params.nextLifecycleStatus ?? "completed",
282
+ });
283
+ return;
284
+ }
285
+ this.completeStageRun(stageRun, issue, thread, "completed", params);
286
+ }
287
+ async failRunLeaseDuringReconciliation(projectId, linearIssueId, threadId, message, options) {
288
+ const stageRun = this.findStageRunForIssue(projectId, linearIssueId, threadId);
289
+ if (!stageRun) {
290
+ this.finishLedgerRun(projectId, linearIssueId, "failed", {
291
+ threadId,
292
+ ...(options?.turnId ? { turnId: options.turnId } : {}),
293
+ failureReason: message,
294
+ nextLifecycleStatus: "failed",
295
+ });
296
+ return;
297
+ }
298
+ await this.failStageRunDuringReconciliation(stageRun, threadId, message, options);
299
+ }
300
+ findStageRunForIssue(projectId, linearIssueId, threadId) {
301
+ return (threadId ? this.stores.issueWorkflows.getStageRunByThreadId(threadId) : undefined) ??
302
+ this.stores.issueWorkflows.getLatestStageRunForIssue(projectId, linearIssueId);
303
+ }
304
+ resolveActiveStageRun(issue) {
305
+ const issueControl = this.stores.issueControl.getIssueControl(issue.projectId, issue.linearIssueId);
306
+ if (issueControl?.activeRunLeaseId !== undefined) {
307
+ const directStageRun = this.stores.issueWorkflows.getStageRun(issueControl.activeRunLeaseId);
308
+ if (directStageRun) {
309
+ return directStageRun;
310
+ }
311
+ const runLease = this.stores.runLeases.getRunLease(issueControl.activeRunLeaseId);
312
+ if (runLease) {
313
+ return {
314
+ id: runLease.id,
315
+ pipelineRunId: runLease.id,
316
+ projectId: runLease.projectId,
317
+ linearIssueId: runLease.linearIssueId,
318
+ workspaceId: runLease.workspaceOwnershipId,
319
+ stage: runLease.stage,
320
+ status: runLease.status === "failed" ? "failed" : runLease.status === "completed" || runLease.status === "released" ? "completed" : "running",
321
+ triggerWebhookId: "ledger-trigger",
322
+ workflowFile: runLease.workflowFile,
323
+ promptText: runLease.promptText,
324
+ ...(runLease.threadId ? { threadId: runLease.threadId } : {}),
325
+ ...(runLease.parentThreadId ? { parentThreadId: runLease.parentThreadId } : {}),
326
+ ...(runLease.turnId ? { turnId: runLease.turnId } : {}),
327
+ startedAt: runLease.startedAt,
328
+ ...(runLease.endedAt ? { endedAt: runLease.endedAt } : {}),
329
+ };
330
+ }
331
+ }
332
+ return undefined;
333
+ }
334
+ }
335
+ function consoleLogger() {
336
+ const noop = () => undefined;
337
+ return {
338
+ fatal: noop,
339
+ error: noop,
340
+ warn: noop,
341
+ info: noop,
342
+ debug: noop,
343
+ trace: noop,
344
+ silent: noop,
345
+ child: () => consoleLogger(),
346
+ level: "silent",
347
+ };
348
+ }
@@ -0,0 +1,233 @@
1
+ import { buildStageLaunchPlan, isCodexThreadId } from "./stage-launch.js";
2
+ import { syncFailedStageToLinear } from "./stage-failure.js";
3
+ import { buildFailedStageReport } from "./stage-reporting.js";
4
+ import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
5
+ import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
6
+ import { WorktreeManager } from "./worktree-manager.js";
7
+ export class ServiceStageRunner {
8
+ config;
9
+ stores;
10
+ codex;
11
+ linearProvider;
12
+ logger;
13
+ worktreeManager;
14
+ inputDispatcher;
15
+ lifecyclePublisher;
16
+ runAtomically;
17
+ constructor(config, stores, codex, linearProvider, logger, runAtomically = (fn) => fn()) {
18
+ this.config = config;
19
+ this.stores = stores;
20
+ this.codex = codex;
21
+ this.linearProvider = linearProvider;
22
+ this.logger = logger;
23
+ this.runAtomically = runAtomically;
24
+ this.worktreeManager = new WorktreeManager(config);
25
+ this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, logger);
26
+ this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, logger);
27
+ }
28
+ async run(item) {
29
+ const project = this.config.projects.find((candidate) => candidate.id === item.projectId);
30
+ if (!project) {
31
+ return;
32
+ }
33
+ const issueControl = this.stores.issueControl.getIssueControl(item.projectId, item.issueId);
34
+ if (!issueControl?.desiredStage || issueControl.activeRunLeaseId !== undefined) {
35
+ return;
36
+ }
37
+ const receipt = issueControl.desiredReceiptId !== undefined ? this.stores.eventReceipts.getEventReceipt(issueControl.desiredReceiptId) : undefined;
38
+ if (!receipt?.externalId) {
39
+ return;
40
+ }
41
+ const desiredStage = issueControl.desiredStage;
42
+ const desiredWebhookId = receipt.externalId;
43
+ const issue = await this.ensureLaunchIssueMirror(project, item.issueId, desiredStage, desiredWebhookId);
44
+ if (!issue) {
45
+ return;
46
+ }
47
+ const plan = buildStageLaunchPlan(project, issue, desiredStage);
48
+ const claim = this.stores.issueWorkflows.claimStageRun({
49
+ projectId: item.projectId,
50
+ linearIssueId: item.issueId,
51
+ stage: desiredStage,
52
+ triggerWebhookId: desiredWebhookId,
53
+ branchName: plan.branchName,
54
+ worktreePath: plan.worktreePath,
55
+ workflowFile: plan.workflowFile,
56
+ promptText: plan.prompt,
57
+ });
58
+ if (!claim) {
59
+ return;
60
+ }
61
+ let threadLaunch;
62
+ let turn;
63
+ try {
64
+ await this.worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, plan.worktreePath, plan.branchName);
65
+ await this.lifecyclePublisher.markStageActive(project, claim.issue, claim.stageRun);
66
+ threadLaunch = await this.launchStageThread(item.projectId, item.issueId, claim.stageRun.id, plan.worktreePath, issue.issueKey);
67
+ turn = await this.codex.startTurn({
68
+ threadId: threadLaunch.threadId,
69
+ cwd: plan.worktreePath,
70
+ input: plan.prompt,
71
+ });
72
+ }
73
+ catch (error) {
74
+ const err = error instanceof Error ? error : new Error(String(error));
75
+ await this.markLaunchFailed(project, claim.issue, claim.stageRun, err.message, threadLaunch?.threadId);
76
+ this.logger.error({
77
+ issueKey: issue.issueKey,
78
+ stage: claim.stageRun.stage,
79
+ worktreePath: plan.worktreePath,
80
+ branchName: plan.branchName,
81
+ error: err.message,
82
+ stack: err.stack,
83
+ }, "Failed to launch Codex stage run");
84
+ throw err;
85
+ }
86
+ this.stores.issueWorkflows.updateStageRunThread({
87
+ stageRunId: claim.stageRun.id,
88
+ threadId: threadLaunch.threadId,
89
+ ...(threadLaunch.parentThreadId ? { parentThreadId: threadLaunch.parentThreadId } : {}),
90
+ turnId: turn.turnId,
91
+ });
92
+ this.inputDispatcher.routePendingInputs(claim.stageRun, threadLaunch.threadId, turn.turnId);
93
+ await this.inputDispatcher.flush({
94
+ id: claim.stageRun.id,
95
+ projectId: claim.stageRun.projectId,
96
+ linearIssueId: claim.stageRun.linearIssueId,
97
+ threadId: threadLaunch.threadId,
98
+ turnId: turn.turnId,
99
+ }, {
100
+ logFailures: true,
101
+ failureMessage: "Failed to deliver queued Linear comment during stage startup",
102
+ ...(claim.issue.issueKey ? { issueKey: claim.issue.issueKey } : {}),
103
+ });
104
+ await this.lifecyclePublisher.refreshRunningStatusComment(item.projectId, item.issueId, claim.stageRun.id, issue.issueKey);
105
+ await this.lifecyclePublisher.publishStageStarted(claim.issue, claim.stageRun.stage);
106
+ this.logger.info({
107
+ issueKey: issue.issueKey,
108
+ stage: claim.stageRun.stage,
109
+ worktreePath: plan.worktreePath,
110
+ branchName: plan.branchName,
111
+ threadId: threadLaunch.threadId,
112
+ turnId: turn.turnId,
113
+ }, "Started Codex stage run");
114
+ }
115
+ async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
116
+ const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
117
+ if (existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
118
+ return existing;
119
+ }
120
+ const liveIssue = await this.linearProvider
121
+ .forProject(project.id)
122
+ .then((linear) => linear?.getIssue(linearIssueId))
123
+ .catch(() => undefined);
124
+ return this.stores.issueWorkflows.recordDesiredStage({
125
+ projectId: project.id,
126
+ linearIssueId,
127
+ ...(liveIssue?.identifier ? { issueKey: liveIssue.identifier } : existing?.issueKey ? { issueKey: existing.issueKey } : {}),
128
+ ...(liveIssue?.title ? { title: liveIssue.title } : existing?.title ? { title: existing.title } : {}),
129
+ ...(liveIssue?.url ? { issueUrl: liveIssue.url } : existing?.issueUrl ? { issueUrl: existing.issueUrl } : {}),
130
+ ...(liveIssue?.stateName
131
+ ? { currentLinearState: liveIssue.stateName }
132
+ : existing?.currentLinearState
133
+ ? { currentLinearState: existing.currentLinearState }
134
+ : {}),
135
+ lastWebhookAt: new Date().toISOString(),
136
+ });
137
+ }
138
+ async launchStageThread(projectId, issueId, stageRunId, worktreePath, issueKey) {
139
+ const previousStageRun = this.stores.issueWorkflows
140
+ .listStageRunsForIssue(projectId, issueId)
141
+ .filter((stageRun) => stageRun.id !== stageRunId)
142
+ .at(-1);
143
+ const parentThreadId = previousStageRun?.status === "completed" && isCodexThreadId(previousStageRun.threadId)
144
+ ? previousStageRun.threadId
145
+ : undefined;
146
+ if (parentThreadId) {
147
+ try {
148
+ const thread = await this.codex.forkThread(parentThreadId, worktreePath);
149
+ return {
150
+ threadId: thread.id,
151
+ parentThreadId,
152
+ };
153
+ }
154
+ catch (error) {
155
+ const err = error instanceof Error ? error : new Error(String(error));
156
+ this.logger.warn({
157
+ issueKey,
158
+ parentThreadId,
159
+ error: err.message,
160
+ }, "Falling back to a fresh Codex thread after parent thread fork failed");
161
+ }
162
+ }
163
+ const thread = await this.codex.startThread({ cwd: worktreePath });
164
+ return {
165
+ threadId: thread.id,
166
+ };
167
+ }
168
+ async markLaunchFailed(project, issue, stageRun, message, threadId) {
169
+ const failureThreadId = threadId ?? `launch-failed-${stageRun.id}`;
170
+ this.runAtomically(() => {
171
+ this.stores.issueWorkflows.finishStageRun({
172
+ stageRunId: stageRun.id,
173
+ status: "failed",
174
+ threadId: failureThreadId,
175
+ summaryJson: JSON.stringify({ message }),
176
+ reportJson: JSON.stringify(buildFailedStageReport(stageRun, "failed", { threadId: failureThreadId })),
177
+ });
178
+ this.finishRunLease(stageRun.projectId, stageRun.linearIssueId, "failed", {
179
+ threadId: failureThreadId,
180
+ failureReason: message,
181
+ });
182
+ });
183
+ await syncFailedStageToLinear({
184
+ stores: this.stores,
185
+ linearProvider: this.linearProvider,
186
+ project,
187
+ issue,
188
+ stageRun: {
189
+ ...stageRun,
190
+ threadId: failureThreadId,
191
+ },
192
+ message,
193
+ mode: "launch",
194
+ });
195
+ }
196
+ finishRunLease(projectId, linearIssueId, status, params) {
197
+ const issueControl = this.stores.issueControl.getIssueControl(projectId, linearIssueId);
198
+ if (!issueControl?.activeRunLeaseId) {
199
+ return;
200
+ }
201
+ this.stores.runLeases.finishRunLease({
202
+ runLeaseId: issueControl.activeRunLeaseId,
203
+ status,
204
+ ...(params.threadId ? { threadId: params.threadId } : {}),
205
+ ...(params.turnId ? { turnId: params.turnId } : {}),
206
+ ...(params.failureReason ? { failureReason: params.failureReason } : {}),
207
+ });
208
+ if (issueControl.activeWorkspaceOwnershipId !== undefined) {
209
+ const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(issueControl.activeWorkspaceOwnershipId);
210
+ if (workspace) {
211
+ this.stores.workspaceOwnership.upsertWorkspaceOwnership({
212
+ projectId,
213
+ linearIssueId,
214
+ branchName: workspace.branchName,
215
+ worktreePath: workspace.worktreePath,
216
+ status: "paused",
217
+ currentRunLeaseId: null,
218
+ });
219
+ }
220
+ }
221
+ this.stores.issueControl.upsertIssueControl({
222
+ projectId,
223
+ linearIssueId,
224
+ activeRunLeaseId: null,
225
+ lifecycleStatus: "failed",
226
+ ...(issueControl.activeWorkspaceOwnershipId !== undefined
227
+ ? { activeWorkspaceOwnershipId: issueControl.activeWorkspaceOwnershipId }
228
+ : {}),
229
+ ...(issueControl.serviceOwnedCommentId ? { serviceOwnedCommentId: issueControl.serviceOwnedCommentId } : {}),
230
+ ...(issueControl.activeAgentSessionId ? { activeAgentSessionId: issueControl.activeAgentSessionId } : {}),
231
+ });
232
+ }
233
+ }