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.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/config/patchrelay.example.json +5 -0
- package/dist/build-info.js +29 -0
- package/dist/build-info.json +6 -0
- package/dist/cli/data.js +461 -0
- package/dist/cli/formatters/json.js +3 -0
- package/dist/cli/formatters/text.js +119 -0
- package/dist/cli/index.js +761 -0
- package/dist/codex-app-server.js +353 -0
- package/dist/codex-types.js +1 -0
- package/dist/config-types.js +1 -0
- package/dist/config.js +494 -0
- package/dist/db/authoritative-ledger-store.js +437 -0
- package/dist/db/issue-workflow-store.js +690 -0
- package/dist/db/linear-installation-store.js +184 -0
- package/dist/db/migrations.js +183 -0
- package/dist/db/shared.js +101 -0
- package/dist/db/stage-event-store.js +33 -0
- package/dist/db/webhook-event-store.js +46 -0
- package/dist/db-ports.js +5 -0
- package/dist/db-types.js +1 -0
- package/dist/db.js +40 -0
- package/dist/file-permissions.js +40 -0
- package/dist/http.js +321 -0
- package/dist/index.js +69 -0
- package/dist/install.js +302 -0
- package/dist/installation-ports.js +1 -0
- package/dist/issue-query-service.js +68 -0
- package/dist/ledger-ports.js +1 -0
- package/dist/linear-client.js +338 -0
- package/dist/linear-oauth-service.js +131 -0
- package/dist/linear-oauth.js +154 -0
- package/dist/linear-types.js +1 -0
- package/dist/linear-workflow.js +78 -0
- package/dist/logging.js +62 -0
- package/dist/preflight.js +227 -0
- package/dist/project-resolution.js +51 -0
- package/dist/reconciliation-action-applier.js +55 -0
- package/dist/reconciliation-actions.js +1 -0
- package/dist/reconciliation-engine.js +312 -0
- package/dist/reconciliation-snapshot-builder.js +96 -0
- package/dist/reconciliation-types.js +1 -0
- package/dist/runtime-paths.js +89 -0
- package/dist/service-queue.js +49 -0
- package/dist/service-runtime.js +96 -0
- package/dist/service-stage-finalizer.js +348 -0
- package/dist/service-stage-runner.js +233 -0
- package/dist/service-webhook-processor.js +181 -0
- package/dist/service-webhooks.js +148 -0
- package/dist/service.js +139 -0
- package/dist/stage-agent-activity-publisher.js +33 -0
- package/dist/stage-event-ports.js +1 -0
- package/dist/stage-failure.js +92 -0
- package/dist/stage-launch.js +54 -0
- package/dist/stage-lifecycle-publisher.js +213 -0
- package/dist/stage-reporting.js +153 -0
- package/dist/stage-turn-input-dispatcher.js +102 -0
- package/dist/token-crypto.js +21 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +163 -0
- package/dist/webhook-agent-session-handler.js +157 -0
- package/dist/webhook-archive.js +24 -0
- package/dist/webhook-comment-handler.js +89 -0
- package/dist/webhook-desired-stage-recorder.js +150 -0
- package/dist/webhook-event-ports.js +1 -0
- package/dist/webhook-installation-handler.js +57 -0
- package/dist/webhooks.js +301 -0
- package/dist/workflow-policy.js +42 -0
- package/dist/workflow-ports.js +1 -0
- package/dist/workflow-types.js +1 -0
- package/dist/worktree-manager.js +66 -0
- package/infra/patchrelay-reload.service +6 -0
- package/infra/patchrelay.path +11 -0
- package/infra/patchrelay.service +28 -0
- package/package.json +55 -0
- package/runtime.env.example +8 -0
- 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
|
+
}
|