patchrelay 0.8.8 → 0.9.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 (57) hide show
  1. package/README.md +64 -62
  2. package/dist/agent-session-plan.js +17 -17
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/commands/issues.js +12 -12
  5. package/dist/cli/data.js +109 -298
  6. package/dist/cli/formatters/text.js +22 -28
  7. package/dist/config.js +13 -166
  8. package/dist/db/migrations.js +46 -154
  9. package/dist/db.js +369 -45
  10. package/dist/factory-state.js +55 -0
  11. package/dist/github-webhook-handler.js +199 -0
  12. package/dist/github-webhooks.js +166 -0
  13. package/dist/hook-runner.js +28 -0
  14. package/dist/http.js +48 -22
  15. package/dist/issue-query-service.js +33 -38
  16. package/dist/linear-workflow.js +5 -118
  17. package/dist/preflight.js +1 -6
  18. package/dist/project-resolution.js +12 -1
  19. package/dist/run-orchestrator.js +446 -0
  20. package/dist/{stage-reporting.js → run-reporting.js} +11 -13
  21. package/dist/service-runtime.js +21 -54
  22. package/dist/service-webhooks.js +7 -52
  23. package/dist/service.js +39 -61
  24. package/dist/webhook-handler.js +387 -0
  25. package/dist/webhook-installation-handler.js +3 -8
  26. package/package.json +2 -1
  27. package/dist/db/authoritative-ledger-store.js +0 -536
  28. package/dist/db/issue-projection-store.js +0 -54
  29. package/dist/db/issue-workflow-coordinator.js +0 -320
  30. package/dist/db/issue-workflow-store.js +0 -194
  31. package/dist/db/run-report-store.js +0 -33
  32. package/dist/db/stage-event-store.js +0 -33
  33. package/dist/db/webhook-event-store.js +0 -59
  34. package/dist/db-ports.js +0 -5
  35. package/dist/ledger-ports.js +0 -1
  36. package/dist/reconciliation-action-applier.js +0 -68
  37. package/dist/reconciliation-actions.js +0 -1
  38. package/dist/reconciliation-engine.js +0 -350
  39. package/dist/reconciliation-snapshot-builder.js +0 -135
  40. package/dist/reconciliation-types.js +0 -1
  41. package/dist/service-stage-finalizer.js +0 -753
  42. package/dist/service-stage-runner.js +0 -336
  43. package/dist/service-webhook-processor.js +0 -411
  44. package/dist/stage-agent-activity-publisher.js +0 -59
  45. package/dist/stage-event-ports.js +0 -1
  46. package/dist/stage-failure.js +0 -92
  47. package/dist/stage-handoff.js +0 -107
  48. package/dist/stage-launch.js +0 -84
  49. package/dist/stage-lifecycle-publisher.js +0 -284
  50. package/dist/stage-turn-input-dispatcher.js +0 -104
  51. package/dist/webhook-agent-session-handler.js +0 -228
  52. package/dist/webhook-comment-handler.js +0 -141
  53. package/dist/webhook-desired-stage-recorder.js +0 -122
  54. package/dist/webhook-event-ports.js +0 -1
  55. package/dist/workflow-policy.js +0 -149
  56. package/dist/workflow-ports.js +0 -1
  57. /package/dist/{installation-ports.js → github-types.js} +0 -0
@@ -1,753 +0,0 @@
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 { parseStageHandoff } from "./stage-handoff.js";
6
- import { resolveAuthoritativeLinearStopState, resolveDoneLinearState, resolveFallbackLinearState } from "./linear-workflow.js";
7
- import { resolveDefaultTransitionTarget, transitionTargetAllowed } from "./workflow-policy.js";
8
- import { buildFailedStageReport, buildPendingMaterializationThread, buildStageReport, countEventMethods, extractStageSummary, extractTurnId, resolveStageRunStatus, summarizeCurrentThread, } from "./stage-reporting.js";
9
- import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
10
- import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
11
- export class ServiceStageFinalizer {
12
- config;
13
- stores;
14
- codex;
15
- linearProvider;
16
- enqueueIssue;
17
- logger;
18
- feed;
19
- inputDispatcher;
20
- lifecyclePublisher;
21
- actionApplier;
22
- runAtomically;
23
- constructor(config, stores, codex, linearProvider, enqueueIssue, logger = consoleLogger(), feed, runAtomically = (fn) => fn()) {
24
- this.config = config;
25
- this.stores = stores;
26
- this.codex = codex;
27
- this.linearProvider = linearProvider;
28
- this.enqueueIssue = enqueueIssue;
29
- this.logger = logger;
30
- this.feed = feed;
31
- this.runAtomically = runAtomically;
32
- this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, this.logger);
33
- this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, this.logger, feed);
34
- this.actionApplier = new ReconciliationActionApplier({
35
- enqueueIssue,
36
- deliverPendingObligations: (projectId, linearIssueId, threadId, turnId) => this.deliverPendingObligations(projectId, linearIssueId, threadId, turnId),
37
- completeRun: (projectId, linearIssueId, thread, params) => this.completeReconciledRun(projectId, linearIssueId, thread, params),
38
- failRunDuringReconciliation: (projectId, linearIssueId, threadId, message, options) => this.failRunLeaseDuringReconciliation(projectId, linearIssueId, threadId, message, options),
39
- releaseRunDuringReconciliation: (projectId, linearIssueId, params) => this.releaseRunDuringReconciliation(projectId, linearIssueId, params),
40
- });
41
- }
42
- async getActiveStageStatus(issueKey) {
43
- const issue = this.stores.issueWorkflows.getTrackedIssueByKey(issueKey);
44
- if (!issue) {
45
- return undefined;
46
- }
47
- const stageRun = this.resolveActiveStageRun(issue);
48
- if (!stageRun?.threadId) {
49
- return undefined;
50
- }
51
- const thread = await this.codex.readThread(stageRun.threadId, true).catch((error) => {
52
- const err = error instanceof Error ? error : new Error(String(error));
53
- return buildPendingMaterializationThread(stageRun, err);
54
- });
55
- return {
56
- issue,
57
- stageRun,
58
- liveThread: summarizeCurrentThread(thread),
59
- };
60
- }
61
- async handleCodexNotification(notification) {
62
- const threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
63
- if (!threadId) {
64
- return;
65
- }
66
- const stageRun = this.stores.issueWorkflows.getStageRunByThreadId(threadId);
67
- if (!stageRun) {
68
- return;
69
- }
70
- const turnId = typeof notification.params.turnId === "string" ? notification.params.turnId : undefined;
71
- if (this.config.runner.codex.persistExtendedHistory) {
72
- this.stores.stageEvents.saveThreadEvent({
73
- stageRunId: stageRun.id,
74
- threadId,
75
- ...(turnId ? { turnId } : {}),
76
- method: notification.method,
77
- eventJson: JSON.stringify(notification.params),
78
- });
79
- }
80
- if (notification.method === "turn/started" || notification.method.startsWith("item/")) {
81
- if (notification.method === "turn/started") {
82
- const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
83
- this.feed?.publish({
84
- level: "info",
85
- kind: "turn",
86
- issueKey: issue?.issueKey,
87
- projectId: stageRun.projectId,
88
- stage: stageRun.stage,
89
- ...(issue?.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
90
- status: "started",
91
- summary: `Turn started for ${stageRun.stage}`,
92
- detail: turnId ? `Turn ${turnId} is now live.` : undefined,
93
- });
94
- }
95
- await this.flushQueuedTurnInputs(stageRun);
96
- }
97
- if (notification.method !== "turn/completed") {
98
- return;
99
- }
100
- const thread = await this.codex.readThread(threadId, true);
101
- const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
102
- if (!issue) {
103
- return;
104
- }
105
- const completedTurnId = extractTurnId(notification.params);
106
- const status = resolveStageRunStatus(notification.params);
107
- if (status === "failed") {
108
- this.feed?.publish({
109
- level: "error",
110
- kind: "turn",
111
- issueKey: issue.issueKey,
112
- projectId: stageRun.projectId,
113
- stage: stageRun.stage,
114
- ...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
115
- status: "failed",
116
- summary: `Turn failed for ${stageRun.stage}`,
117
- detail: completedTurnId ? `Turn ${completedTurnId} completed in a failed state.` : undefined,
118
- });
119
- await this.failStageRunAndSync(stageRun, issue, threadId, "Codex reported the turn completed in a failed state", {
120
- ...(completedTurnId ? { turnId: completedTurnId } : {}),
121
- });
122
- return;
123
- }
124
- this.feed?.publish({
125
- level: "info",
126
- kind: "turn",
127
- issueKey: issue.issueKey,
128
- projectId: stageRun.projectId,
129
- stage: stageRun.stage,
130
- ...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
131
- status: "completed",
132
- summary: `Turn completed for ${stageRun.stage}`,
133
- detail: summarizeCurrentThread(thread).latestAgentMessage,
134
- });
135
- this.completeStageRun(stageRun, issue, thread, status, {
136
- threadId,
137
- ...(completedTurnId ? { turnId: completedTurnId } : {}),
138
- });
139
- }
140
- async reconcileActiveStageRuns() {
141
- for (const runLeaseId of this.stores.runLeases.listActiveRunLeases().filter((runLease) => runLease.status === "running").map((runLease) => runLease.id)) {
142
- await this.reconcileRunLease(runLeaseId);
143
- }
144
- }
145
- completeStageRun(stageRun, issue, thread, status, params) {
146
- const refreshedStageRun = this.stores.issueWorkflows.getStageRun(stageRun.id) ?? stageRun;
147
- const finalizedStageRun = {
148
- ...refreshedStageRun,
149
- status,
150
- threadId: params.threadId,
151
- ...(params.turnId ? { turnId: params.turnId } : {}),
152
- };
153
- const report = buildStageReport(finalizedStageRun, issue, thread, countEventMethods(this.stores.stageEvents.listThreadEvents(stageRun.id)));
154
- this.runAtomically(() => {
155
- this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "completed", {
156
- stageRunId: stageRun.id,
157
- threadId: params.threadId,
158
- ...(params.turnId ? { turnId: params.turnId } : {}),
159
- nextLifecycleStatus: params.nextLifecycleStatus ?? (issue.desiredStage ? "queued" : "completed"),
160
- });
161
- this.stores.workflowCoordinator.finishStageRun({
162
- stageRunId: stageRun.id,
163
- status,
164
- threadId: params.threadId,
165
- ...(params.turnId ? { turnId: params.turnId } : {}),
166
- summaryJson: JSON.stringify(extractStageSummary(report)),
167
- reportJson: JSON.stringify(report),
168
- });
169
- });
170
- void this.advanceAfterStageCompletion(stageRun, report);
171
- }
172
- failStageRun(stageRun, threadId, message, options) {
173
- this.runAtomically(() => {
174
- this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "failed", {
175
- stageRunId: stageRun.id,
176
- threadId,
177
- ...(options?.turnId ? { turnId: options.turnId } : {}),
178
- failureReason: message,
179
- nextLifecycleStatus: "failed",
180
- });
181
- this.stores.workflowCoordinator.finishStageRun({
182
- stageRunId: stageRun.id,
183
- status: "failed",
184
- threadId,
185
- ...(options?.turnId ? { turnId: options.turnId } : {}),
186
- summaryJson: JSON.stringify({ message }),
187
- reportJson: JSON.stringify(buildFailedStageReport(stageRun, "failed", {
188
- threadId,
189
- ...(options?.turnId ? { turnId: options.turnId } : {}),
190
- })),
191
- });
192
- });
193
- }
194
- async failStageRunDuringReconciliation(stageRun, threadId, message, options) {
195
- this.failStageRun(stageRun, threadId, message, options);
196
- const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
197
- const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
198
- if (!issue || !project) {
199
- return;
200
- }
201
- await syncFailedStageToLinear({
202
- stores: this.stores,
203
- linearProvider: this.linearProvider,
204
- project,
205
- issue,
206
- stageRun: {
207
- ...stageRun,
208
- threadId,
209
- ...(options?.turnId ? { turnId: options.turnId } : {}),
210
- },
211
- message,
212
- mode: "failed",
213
- requireActiveLinearStateMatch: true,
214
- });
215
- }
216
- async failStageRunAndSync(stageRun, issue, threadId, message, options) {
217
- this.failStageRun(stageRun, threadId, message, options);
218
- const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
219
- if (!project) {
220
- return;
221
- }
222
- await syncFailedStageToLinear({
223
- stores: this.stores,
224
- linearProvider: this.linearProvider,
225
- project,
226
- issue,
227
- stageRun: {
228
- ...stageRun,
229
- threadId,
230
- ...(options?.turnId ? { turnId: options.turnId } : {}),
231
- },
232
- message,
233
- mode: "failed",
234
- });
235
- }
236
- async flushQueuedTurnInputs(stageRun) {
237
- await this.inputDispatcher.flush(stageRun);
238
- }
239
- async advanceAfterStageCompletion(stageRun, report) {
240
- await this.maybeQueueAutomaticTransition(stageRun, report);
241
- await this.lifecyclePublisher.publishStageCompletion(stageRun, this.enqueueIssue);
242
- }
243
- async maybeQueueAutomaticTransition(stageRun, report) {
244
- const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
245
- if (!refreshedIssue) {
246
- return;
247
- }
248
- const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
249
- if (!project) {
250
- return;
251
- }
252
- const handoff = parseStageHandoff(project, report.assistantMessages, refreshedIssue.selectedWorkflowId);
253
- if (!handoff) {
254
- return;
255
- }
256
- const linear = await this.linearProvider.forProject(stageRun.projectId);
257
- if (!linear) {
258
- return;
259
- }
260
- const linearIssue = await linear.getIssue(stageRun.linearIssueId).catch(() => undefined);
261
- if (!linearIssue) {
262
- return;
263
- }
264
- const authoritativeStopState = resolveAuthoritativeLinearStopState(linearIssue);
265
- if (authoritativeStopState) {
266
- this.syncIssueToAuthoritativeLinearStopState(stageRun, refreshedIssue, authoritativeStopState);
267
- return;
268
- }
269
- if (refreshedIssue.desiredStage) {
270
- return;
271
- }
272
- const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(stageRun, refreshedIssue, linear, linearIssue);
273
- if (!continuationPrecondition.allowed) {
274
- this.feed?.publish({
275
- level: "info",
276
- kind: "workflow",
277
- issueKey: refreshedIssue.issueKey,
278
- projectId: stageRun.projectId,
279
- stage: stageRun.stage,
280
- ...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
281
- status: "transition_suppressed",
282
- summary: `Suppressed automatic continuation after ${stageRun.stage}`,
283
- detail: continuationPrecondition.reason,
284
- });
285
- return;
286
- }
287
- const nextTarget = this.resolveTransitionTarget(project, stageRun, refreshedIssue.selectedWorkflowId, handoff);
288
- if (nextTarget === "done") {
289
- const doneState = resolveDoneLinearState(linearIssue);
290
- if (!doneState) {
291
- await this.routeStageToHumanNeeded(project, stageRun, linearIssue, "PatchRelay could not determine the repo's done state.");
292
- return;
293
- }
294
- this.feed?.publish({
295
- level: "info",
296
- kind: "workflow",
297
- issueKey: refreshedIssue.issueKey,
298
- projectId: stageRun.projectId,
299
- stage: stageRun.stage,
300
- ...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
301
- status: "completed",
302
- summary: `Completed workflow after ${stageRun.stage}`,
303
- });
304
- await linear.setIssueState(stageRun.linearIssueId, doneState);
305
- this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, undefined, {
306
- lifecycleStatus: "completed",
307
- });
308
- this.stores.workflowCoordinator.upsertTrackedIssue({
309
- projectId: stageRun.projectId,
310
- linearIssueId: stageRun.linearIssueId,
311
- currentLinearState: doneState,
312
- lifecycleStatus: "completed",
313
- });
314
- return;
315
- }
316
- if (nextTarget === "human_needed") {
317
- await this.routeStageToHumanNeeded(project, stageRun, linearIssue, handoff.nextLikelyStageText
318
- ? `PatchRelay could not safely continue from "${handoff.nextLikelyStageText}".`
319
- : handoff.suggestsHumanNeeded
320
- ? "PatchRelay needs human input before the next stage is clear."
321
- : `PatchRelay could not map the ${stageRun.stage} result to an allowed next transition.`);
322
- return;
323
- }
324
- if (nextTarget === stageRun.stage) {
325
- await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay received ${nextTarget} as the next stage again and needs a human to confirm the intended loop.`);
326
- return;
327
- }
328
- if (this.isTransitionAlreadyInFlight(stageRun, nextTarget)) {
329
- this.feed?.publish({
330
- level: "info",
331
- kind: "workflow",
332
- issueKey: refreshedIssue.issueKey,
333
- projectId: stageRun.projectId,
334
- stage: stageRun.stage,
335
- ...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
336
- nextStage: nextTarget,
337
- status: "transition_in_progress",
338
- summary: `${nextTarget} is already queued or running`,
339
- detail: `PatchRelay kept ${stageRun.stage} completion from re-queueing ${nextTarget}.`,
340
- });
341
- return;
342
- }
343
- this.feed?.publish({
344
- level: "info",
345
- kind: "workflow",
346
- issueKey: refreshedIssue.issueKey,
347
- projectId: stageRun.projectId,
348
- stage: stageRun.stage,
349
- ...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
350
- nextStage: nextTarget,
351
- status: "transition_chosen",
352
- summary: `Chose ${stageRun.stage} -> ${nextTarget}`,
353
- detail: handoff.nextLikelyStageText ? `Stage result suggested "${handoff.nextLikelyStageText}".` : "PatchRelay used the workflow policy default.",
354
- });
355
- this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, nextTarget, {
356
- desiredWebhookId: `auto-transition:${stageRun.id}:${nextTarget}`,
357
- lifecycleStatus: "queued",
358
- });
359
- }
360
- syncIssueToAuthoritativeLinearStopState(stageRun, issue, stopState) {
361
- this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, undefined, {
362
- lifecycleStatus: stopState.lifecycleStatus,
363
- });
364
- this.stores.workflowCoordinator.upsertTrackedIssue({
365
- projectId: stageRun.projectId,
366
- linearIssueId: stageRun.linearIssueId,
367
- currentLinearState: stopState.stateName,
368
- lifecycleStatus: stopState.lifecycleStatus,
369
- });
370
- this.feed?.publish({
371
- level: "info",
372
- kind: "workflow",
373
- issueKey: issue.issueKey,
374
- projectId: stageRun.projectId,
375
- stage: stageRun.stage,
376
- ...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
377
- status: stopState.lifecycleStatus === "completed" ? "completed" : "transition_suppressed",
378
- summary: stopState.lifecycleStatus === "completed"
379
- ? `Kept workflow completed after ${stageRun.stage}`
380
- : `Ignored stale ${stageRun.stage} completion`,
381
- detail: stopState.lifecycleStatus === "completed"
382
- ? `Live Linear state is already ${stopState.stateName}, so PatchRelay kept the issue finished.`
383
- : `Live Linear state is already ${stopState.stateName}, so PatchRelay kept the issue paused.`,
384
- });
385
- }
386
- async checkAutomaticContinuationPreconditions(stageRun, issue, linear, linearIssue) {
387
- const actorProfile = await linear.getActorProfile().catch(() => undefined);
388
- if (actorProfile?.actorId && linearIssue.delegateId && linearIssue.delegateId !== actorProfile.actorId) {
389
- return {
390
- allowed: false,
391
- reason: "The issue is no longer delegated to PatchRelay.",
392
- };
393
- }
394
- return { allowed: true };
395
- }
396
- resolveTransitionTarget(project, stageRun, workflowDefinitionId, handoff) {
397
- if (!handoff) {
398
- return "human_needed";
399
- }
400
- const requestedTarget = handoff.resolvedNextStage;
401
- if (requestedTarget) {
402
- return transitionTargetAllowed(project, stageRun.stage, requestedTarget, workflowDefinitionId) ? requestedTarget : "human_needed";
403
- }
404
- if (handoff.suggestsHumanNeeded) {
405
- return "human_needed";
406
- }
407
- return resolveDefaultTransitionTarget(project, stageRun.stage, workflowDefinitionId) ?? "human_needed";
408
- }
409
- isTransitionAlreadyInFlight(stageRun, nextTarget) {
410
- const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
411
- if (!refreshedIssue) {
412
- return false;
413
- }
414
- if (refreshedIssue.desiredStage === nextTarget) {
415
- return true;
416
- }
417
- const activeStageRun = this.resolveActiveStageRun(refreshedIssue);
418
- return activeStageRun !== undefined && activeStageRun.id !== stageRun.id && activeStageRun.stage === nextTarget;
419
- }
420
- async routeStageToHumanNeeded(project, stageRun, linearIssue, reason) {
421
- const linear = await this.linearProvider.forProject(stageRun.projectId);
422
- if (!linear) {
423
- return;
424
- }
425
- const trackedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
426
- const fallbackState = resolveFallbackLinearState(project, stageRun.stage, trackedIssue?.selectedWorkflowId) ??
427
- linearIssue.workflowStates.find((state) => normalizeLinearState(state.name) === "human needed")?.name;
428
- if (fallbackState) {
429
- await linear.setIssueState(stageRun.linearIssueId, fallbackState);
430
- }
431
- this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, undefined, {
432
- lifecycleStatus: "paused",
433
- });
434
- this.stores.workflowCoordinator.upsertTrackedIssue({
435
- projectId: stageRun.projectId,
436
- linearIssueId: stageRun.linearIssueId,
437
- ...(fallbackState ? { currentLinearState: fallbackState } : linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
438
- lifecycleStatus: "paused",
439
- });
440
- this.feed?.publish({
441
- level: "warn",
442
- kind: "workflow",
443
- issueKey: trackedIssue?.issueKey,
444
- projectId: stageRun.projectId,
445
- stage: stageRun.stage,
446
- ...(trackedIssue?.selectedWorkflowId ? { workflowId: trackedIssue.selectedWorkflowId } : {}),
447
- status: "transition_suppressed",
448
- summary: `Paused after ${stageRun.stage}`,
449
- detail: reason,
450
- });
451
- }
452
- finishLedgerRun(projectId, linearIssueId, status, params) {
453
- const issueControl = this.stores.issueControl.getIssueControl(projectId, linearIssueId);
454
- const targetRunLeaseId = params.stageRunId ?? issueControl?.activeRunLeaseId;
455
- if (!targetRunLeaseId) {
456
- return;
457
- }
458
- const targetRunLease = this.stores.runLeases.getRunLease(targetRunLeaseId);
459
- if (!targetRunLease) {
460
- return;
461
- }
462
- this.stores.runLeases.finishRunLease({
463
- runLeaseId: targetRunLeaseId,
464
- status,
465
- ...(params.threadId ? { threadId: params.threadId } : {}),
466
- ...(params.turnId ? { turnId: params.turnId } : {}),
467
- ...(params.failureReason ? { failureReason: params.failureReason } : {}),
468
- });
469
- if (targetRunLease.workspaceOwnershipId !== undefined) {
470
- const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(targetRunLease.workspaceOwnershipId);
471
- if (workspace) {
472
- const workspaceOwnedByTargetRun = workspace.currentRunLeaseId === targetRunLeaseId ||
473
- (issueControl?.activeWorkspaceOwnershipId === workspace.id && issueControl.activeRunLeaseId === targetRunLeaseId);
474
- this.stores.workspaceOwnership.upsertWorkspaceOwnership({
475
- projectId,
476
- linearIssueId,
477
- branchName: workspace.branchName,
478
- worktreePath: workspace.worktreePath,
479
- status: workspaceOwnedByTargetRun
480
- ? status === "released"
481
- ? "released"
482
- : status === "completed"
483
- ? "active"
484
- : "paused"
485
- : workspace.status,
486
- ...(workspaceOwnedByTargetRun
487
- ? { currentRunLeaseId: null }
488
- : workspace.currentRunLeaseId !== undefined
489
- ? { currentRunLeaseId: workspace.currentRunLeaseId }
490
- : {}),
491
- });
492
- }
493
- }
494
- if (!issueControl?.activeRunLeaseId || issueControl.activeRunLeaseId !== targetRunLeaseId) {
495
- return;
496
- }
497
- this.stores.issueControl.upsertIssueControl({
498
- projectId,
499
- linearIssueId,
500
- activeRunLeaseId: null,
501
- ...(status === "released"
502
- ? { activeWorkspaceOwnershipId: null }
503
- : issueControl.activeWorkspaceOwnershipId !== undefined
504
- ? { activeWorkspaceOwnershipId: issueControl.activeWorkspaceOwnershipId }
505
- : {}),
506
- ...(issueControl.serviceOwnedCommentId ? { serviceOwnedCommentId: issueControl.serviceOwnedCommentId } : {}),
507
- ...(issueControl.activeAgentSessionId ? { activeAgentSessionId: issueControl.activeAgentSessionId } : {}),
508
- lifecycleStatus: params.nextLifecycleStatus,
509
- });
510
- }
511
- async deliverPendingObligations(projectId, linearIssueId, threadId, turnId) {
512
- if (!turnId) {
513
- return;
514
- }
515
- await this.inputDispatcher.flush({
516
- id: 0,
517
- projectId,
518
- linearIssueId,
519
- threadId,
520
- turnId,
521
- }, {
522
- retryInProgress: true,
523
- });
524
- }
525
- async reconcileRunLease(runLeaseId) {
526
- const snapshot = await buildReconciliationSnapshot({
527
- config: this.config,
528
- stores: {
529
- issueControl: this.stores.issueControl,
530
- runLeases: this.stores.runLeases,
531
- workspaceOwnership: this.stores.workspaceOwnership,
532
- obligations: this.stores.obligations,
533
- },
534
- codex: this.codex,
535
- linearProvider: this.linearProvider,
536
- runLeaseId,
537
- });
538
- if (!snapshot) {
539
- return;
540
- }
541
- if (await this.restartInterruptedRun(snapshot)) {
542
- return;
543
- }
544
- const decision = reconcileIssue(snapshot.input);
545
- if (decision.outcome === "hydrate_live_state") {
546
- throw new Error(`Startup reconciliation requires live state hydration for ${snapshot.runLease.projectId}:${snapshot.runLease.linearIssueId}: ${decision.reasons.join("; ")}`);
547
- }
548
- await this.actionApplier.apply({
549
- snapshot,
550
- decision,
551
- });
552
- }
553
- async restartInterruptedRun(snapshot) {
554
- const liveCodex = snapshot.input.live?.codex;
555
- const liveLinear = snapshot.input.live?.linear;
556
- const latestTurn = liveCodex?.status === "found" ? liveCodex.thread?.turns.at(-1) : undefined;
557
- if (latestTurn?.status !== "interrupted") {
558
- return false;
559
- }
560
- if (liveLinear?.status === "known") {
561
- const authoritativeStopState = resolveAuthoritativeLinearStopState({
562
- ...(liveLinear.issue?.stateName ? { stateName: liveLinear.issue.stateName } : {}),
563
- workflowStates: liveLinear.issue?.stateName
564
- ? [
565
- {
566
- name: liveLinear.issue.stateName,
567
- ...(liveLinear.issue.stateType ? { type: liveLinear.issue.stateType } : {}),
568
- },
569
- ]
570
- : [],
571
- });
572
- if (authoritativeStopState) {
573
- return false;
574
- }
575
- }
576
- if (snapshot.runLease.turnId && latestTurn.id !== snapshot.runLease.turnId) {
577
- return true;
578
- }
579
- if (!snapshot.runLease.threadId || !snapshot.workspaceOwnership?.worktreePath) {
580
- return false;
581
- }
582
- const stageRun = this.findStageRunForIssue(snapshot.runLease.projectId, snapshot.runLease.linearIssueId, snapshot.runLease.threadId);
583
- if (!stageRun) {
584
- return false;
585
- }
586
- const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
587
- const turn = await this.codex.startTurn({
588
- threadId: snapshot.runLease.threadId,
589
- cwd: snapshot.workspaceOwnership.worktreePath,
590
- input: buildRestartRecoveryPrompt(stageRun.stage),
591
- });
592
- this.stores.workflowCoordinator.updateStageRunThread({
593
- stageRunId: stageRun.id,
594
- threadId: snapshot.runLease.threadId,
595
- turnId: turn.turnId,
596
- });
597
- this.inputDispatcher.routePendingInputs(stageRun, snapshot.runLease.threadId, turn.turnId);
598
- await this.inputDispatcher.flush({
599
- id: stageRun.id,
600
- projectId: stageRun.projectId,
601
- linearIssueId: stageRun.linearIssueId,
602
- threadId: snapshot.runLease.threadId,
603
- turnId: turn.turnId,
604
- }, {
605
- logFailures: true,
606
- failureMessage: "Failed to deliver queued Linear comment during interrupted-turn recovery",
607
- ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
608
- });
609
- this.logger.info({
610
- issueKey: issue?.issueKey,
611
- stage: stageRun.stage,
612
- threadId: snapshot.runLease.threadId,
613
- turnId: turn.turnId,
614
- }, "Restarted interrupted Codex stage run during startup reconciliation");
615
- this.feed?.publish({
616
- level: "info",
617
- kind: "stage",
618
- issueKey: issue?.issueKey,
619
- projectId: stageRun.projectId,
620
- stage: stageRun.stage,
621
- ...(issue?.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
622
- status: "running",
623
- summary: `Recovered ${stageRun.stage} workflow after restart`,
624
- detail: `Turn ${turn.turnId} resumed on the existing thread.`,
625
- });
626
- return true;
627
- }
628
- completeReconciledRun(projectId, linearIssueId, thread, params) {
629
- const stageRun = this.findStageRunForIssue(projectId, linearIssueId, params.threadId);
630
- const issue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
631
- if (!stageRun || !issue) {
632
- this.finishLedgerRun(projectId, linearIssueId, "completed", {
633
- threadId: params.threadId,
634
- ...(params.turnId ? { turnId: params.turnId } : {}),
635
- nextLifecycleStatus: params.nextLifecycleStatus ?? "completed",
636
- });
637
- return;
638
- }
639
- this.completeStageRun(stageRun, issue, thread, "completed", params);
640
- }
641
- async failRunLeaseDuringReconciliation(projectId, linearIssueId, threadId, message, options) {
642
- const stageRun = this.findStageRunForIssue(projectId, linearIssueId, threadId);
643
- if (!stageRun) {
644
- this.finishLedgerRun(projectId, linearIssueId, "failed", {
645
- threadId,
646
- ...(options?.turnId ? { turnId: options.turnId } : {}),
647
- failureReason: message,
648
- nextLifecycleStatus: "failed",
649
- });
650
- return;
651
- }
652
- await this.failStageRunDuringReconciliation(stageRun, threadId, message, options);
653
- }
654
- async releaseRunDuringReconciliation(projectId, linearIssueId, params) {
655
- const runId = typeof params.runId === "number" ? params.runId : Number(params.runId);
656
- if (!Number.isFinite(runId)) {
657
- return;
658
- }
659
- this.runAtomically(() => {
660
- this.finishLedgerRun(projectId, linearIssueId, "released", {
661
- stageRunId: runId,
662
- ...(params.threadId ? { threadId: params.threadId } : {}),
663
- ...(params.turnId ? { turnId: params.turnId } : {}),
664
- nextLifecycleStatus: params.nextLifecycleStatus ?? "completed",
665
- });
666
- const existingIssue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
667
- this.stores.workflowCoordinator.upsertTrackedIssue({
668
- projectId,
669
- linearIssueId,
670
- ...(params.currentLinearState
671
- ? { currentLinearState: params.currentLinearState }
672
- : existingIssue?.currentLinearState
673
- ? { currentLinearState: existingIssue.currentLinearState }
674
- : {}),
675
- lifecycleStatus: params.nextLifecycleStatus ?? "completed",
676
- });
677
- });
678
- const issue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
679
- this.feed?.publish({
680
- level: "info",
681
- kind: "workflow",
682
- issueKey: issue?.issueKey,
683
- projectId,
684
- ...(issue?.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
685
- status: params.nextLifecycleStatus === "paused" ? "transition_suppressed" : "completed",
686
- summary: params.nextLifecycleStatus === "paused"
687
- ? "Released stale run after terminal Linear pause"
688
- : "Released stale run after terminal Linear completion",
689
- detail: params.currentLinearState ? `Live Linear state is already ${params.currentLinearState}.` : undefined,
690
- });
691
- }
692
- findStageRunForIssue(projectId, linearIssueId, threadId) {
693
- return (threadId ? this.stores.issueWorkflows.getStageRunByThreadId(threadId) : undefined) ??
694
- this.stores.issueWorkflows.getLatestStageRunForIssue(projectId, linearIssueId);
695
- }
696
- resolveActiveStageRun(issue) {
697
- const issueControl = this.stores.issueControl.getIssueControl(issue.projectId, issue.linearIssueId);
698
- if (issueControl?.activeRunLeaseId !== undefined) {
699
- const directStageRun = this.stores.issueWorkflows.getStageRun(issueControl.activeRunLeaseId);
700
- if (directStageRun) {
701
- return directStageRun;
702
- }
703
- const runLease = this.stores.runLeases.getRunLease(issueControl.activeRunLeaseId);
704
- if (runLease) {
705
- return {
706
- id: runLease.id,
707
- pipelineRunId: runLease.id,
708
- projectId: runLease.projectId,
709
- linearIssueId: runLease.linearIssueId,
710
- workspaceId: runLease.workspaceOwnershipId,
711
- stage: runLease.stage,
712
- status: runLease.status === "failed" ? "failed" : runLease.status === "completed" || runLease.status === "released" ? "completed" : "running",
713
- triggerWebhookId: "ledger-trigger",
714
- workflowFile: runLease.workflowFile,
715
- promptText: runLease.promptText,
716
- ...(runLease.threadId ? { threadId: runLease.threadId } : {}),
717
- ...(runLease.parentThreadId ? { parentThreadId: runLease.parentThreadId } : {}),
718
- ...(runLease.turnId ? { turnId: runLease.turnId } : {}),
719
- startedAt: runLease.startedAt,
720
- ...(runLease.endedAt ? { endedAt: runLease.endedAt } : {}),
721
- };
722
- }
723
- }
724
- return undefined;
725
- }
726
- }
727
- function consoleLogger() {
728
- const noop = () => undefined;
729
- return {
730
- fatal: noop,
731
- error: noop,
732
- warn: noop,
733
- info: noop,
734
- debug: noop,
735
- trace: noop,
736
- silent: noop,
737
- child: () => consoleLogger(),
738
- level: "silent",
739
- };
740
- }
741
- function buildRestartRecoveryPrompt(stage) {
742
- return [
743
- `PatchRelay restarted while the ${stage} workflow was mid-turn.`,
744
- "Resume the existing work from the current worktree state on this same thread.",
745
- "Inspect any uncommitted changes you already made before continuing.",
746
- "Continue from the interrupted point instead of restarting the task from scratch.",
747
- "When the work is actually complete, finish the normal workflow handoff for this stage.",
748
- ].join("\n");
749
- }
750
- function normalizeLinearState(value) {
751
- const trimmed = value?.trim();
752
- return trimmed ? trimmed.toLowerCase() : undefined;
753
- }