patchrelay 0.7.9 → 0.8.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/dist/build-info.json +3 -3
- package/dist/cli/commands/feed.js +17 -10
- package/dist/cli/formatters/text.js +16 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/operator-client.js +16 -0
- package/dist/config.js +169 -56
- package/dist/db/authoritative-ledger-store.js +6 -2
- package/dist/db/issue-workflow-coordinator.js +11 -0
- package/dist/db/issue-workflow-store.js +1 -0
- package/dist/db/migrations.js +22 -1
- package/dist/db/operator-feed-store.js +21 -3
- package/dist/db/webhook-event-store.js +13 -0
- package/dist/http.js +20 -10
- package/dist/install.js +18 -3
- package/dist/linear-workflow.js +20 -5
- package/dist/operator-feed.js +30 -12
- package/dist/preflight.js +5 -2
- package/dist/reconciliation-snapshot-builder.js +2 -1
- package/dist/service-stage-finalizer.js +243 -2
- package/dist/service-stage-runner.js +60 -42
- package/dist/service-webhook-processor.js +87 -11
- package/dist/service.js +1 -0
- package/dist/stage-failure.js +3 -3
- package/dist/stage-handoff.js +107 -0
- package/dist/stage-launch.js +38 -8
- package/dist/stage-lifecycle-publisher.js +37 -12
- package/dist/webhook-agent-session-handler.js +11 -3
- package/dist/webhook-desired-stage-recorder.js +24 -4
- package/dist/workflow-policy.js +115 -8
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { syncFailedStageToLinear } from "./stage-failure.js";
|
|
|
3
3
|
import { buildFailedStageReport } from "./stage-reporting.js";
|
|
4
4
|
import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
|
|
5
5
|
import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
|
|
6
|
+
import { safeJsonParse } from "./utils.js";
|
|
6
7
|
import { WorktreeManager } from "./worktree-manager.js";
|
|
7
8
|
export class ServiceStageRunner {
|
|
8
9
|
config;
|
|
@@ -47,7 +48,20 @@ export class ServiceStageRunner {
|
|
|
47
48
|
return;
|
|
48
49
|
}
|
|
49
50
|
const existingWorkspace = this.stores.workspaceOwnership.getWorkspaceOwnershipForIssue(item.projectId, item.issueId);
|
|
50
|
-
const
|
|
51
|
+
const stageHistory = this.stores.issueWorkflows.listStageRunsForIssue(item.projectId, item.issueId);
|
|
52
|
+
const previousStageRun = stageHistory.at(-1);
|
|
53
|
+
const defaultPlan = buildStageLaunchPlan(project, issue, desiredStage, {
|
|
54
|
+
...(previousStageRun ? { previousStageRun } : {}),
|
|
55
|
+
...(existingWorkspace
|
|
56
|
+
? {
|
|
57
|
+
workspace: {
|
|
58
|
+
branchName: existingWorkspace.branchName,
|
|
59
|
+
worktreePath: existingWorkspace.worktreePath,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
: {}),
|
|
63
|
+
stageHistory,
|
|
64
|
+
});
|
|
51
65
|
const plan = existingWorkspace
|
|
52
66
|
? {
|
|
53
67
|
...defaultPlan,
|
|
@@ -61,6 +75,7 @@ export class ServiceStageRunner {
|
|
|
61
75
|
issueKey: issue.issueKey,
|
|
62
76
|
projectId: item.projectId,
|
|
63
77
|
stage: desiredStage,
|
|
78
|
+
...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
|
|
64
79
|
status: "starting",
|
|
65
80
|
summary: `Starting ${desiredStage} workflow`,
|
|
66
81
|
detail: `Preparing ${plan.branchName}`,
|
|
@@ -85,12 +100,17 @@ export class ServiceStageRunner {
|
|
|
85
100
|
allowExistingOutsideRoot: existingWorkspace !== undefined,
|
|
86
101
|
});
|
|
87
102
|
await this.lifecyclePublisher.markStageActive(project, claim.issue, claim.stageRun);
|
|
88
|
-
threadLaunch = await this.launchStageThread(item.projectId, item.issueId, claim.stageRun.id, plan.worktreePath
|
|
103
|
+
threadLaunch = await this.launchStageThread(item.projectId, item.issueId, claim.stageRun.id, plan.worktreePath);
|
|
104
|
+
const pendingLaunchInput = this.collectPendingLaunchInput(item.projectId, item.issueId);
|
|
105
|
+
const initialTurnInput = pendingLaunchInput.combinedInput
|
|
106
|
+
? [plan.prompt, "", pendingLaunchInput.combinedInput].join("\n")
|
|
107
|
+
: plan.prompt;
|
|
89
108
|
turn = await this.codex.startTurn({
|
|
90
109
|
threadId: threadLaunch.threadId,
|
|
91
110
|
cwd: plan.worktreePath,
|
|
92
|
-
input:
|
|
111
|
+
input: initialTurnInput,
|
|
93
112
|
});
|
|
113
|
+
this.completeDeliveredLaunchInput(pendingLaunchInput.obligationIds, claim.stageRun.id, threadLaunch.threadId, turn.turnId);
|
|
94
114
|
}
|
|
95
115
|
catch (error) {
|
|
96
116
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -122,19 +142,8 @@ export class ServiceStageRunner {
|
|
|
122
142
|
turnId: turn.turnId,
|
|
123
143
|
});
|
|
124
144
|
this.inputDispatcher.routePendingInputs(claim.stageRun, threadLaunch.threadId, turn.turnId);
|
|
125
|
-
await this.inputDispatcher.flush({
|
|
126
|
-
id: claim.stageRun.id,
|
|
127
|
-
projectId: claim.stageRun.projectId,
|
|
128
|
-
linearIssueId: claim.stageRun.linearIssueId,
|
|
129
|
-
threadId: threadLaunch.threadId,
|
|
130
|
-
turnId: turn.turnId,
|
|
131
|
-
}, {
|
|
132
|
-
logFailures: true,
|
|
133
|
-
failureMessage: "Failed to deliver queued Linear comment during stage startup",
|
|
134
|
-
...(claim.issue.issueKey ? { issueKey: claim.issue.issueKey } : {}),
|
|
135
|
-
});
|
|
136
145
|
const deliveredToSession = await this.lifecyclePublisher.publishStageStarted(claim.issue, claim.stageRun.stage);
|
|
137
|
-
if (!deliveredToSession) {
|
|
146
|
+
if (!deliveredToSession && !claim.issue.activeAgentSessionId) {
|
|
138
147
|
await this.lifecyclePublisher.refreshRunningStatusComment(item.projectId, item.issueId, claim.stageRun.id, issue.issueKey);
|
|
139
148
|
}
|
|
140
149
|
this.logger.info({
|
|
@@ -151,11 +160,45 @@ export class ServiceStageRunner {
|
|
|
151
160
|
issueKey: issue.issueKey,
|
|
152
161
|
projectId: item.projectId,
|
|
153
162
|
stage: claim.stageRun.stage,
|
|
163
|
+
...(claim.issue.selectedWorkflowId ? { workflowId: claim.issue.selectedWorkflowId } : {}),
|
|
154
164
|
status: "running",
|
|
155
165
|
summary: `Started ${claim.stageRun.stage} workflow`,
|
|
156
166
|
detail: `Turn ${turn.turnId} is running in ${plan.branchName}.`,
|
|
157
167
|
});
|
|
158
168
|
}
|
|
169
|
+
collectPendingLaunchInput(projectId, issueId) {
|
|
170
|
+
const obligationIds = [];
|
|
171
|
+
const bodies = [];
|
|
172
|
+
for (const obligation of this.stores.obligations.listPendingObligations({ kind: "deliver_turn_input" })) {
|
|
173
|
+
if (obligation.projectId !== projectId ||
|
|
174
|
+
obligation.linearIssueId !== issueId ||
|
|
175
|
+
!obligation.source.startsWith("linear-agent-launch:")) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const payload = safeJsonParse(obligation.payloadJson);
|
|
179
|
+
const body = payload?.body?.trim();
|
|
180
|
+
if (!body) {
|
|
181
|
+
this.stores.obligations.markObligationStatus(obligation.id, "failed", "obligation payload had no deliverable body");
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
obligationIds.push(obligation.id);
|
|
185
|
+
bodies.push(body);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
...(bodies.length > 0 ? { combinedInput: bodies.join("\n\n") } : {}),
|
|
189
|
+
obligationIds,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
completeDeliveredLaunchInput(obligationIds, runLeaseId, threadId, turnId) {
|
|
193
|
+
for (const obligationId of obligationIds) {
|
|
194
|
+
this.stores.obligations.updateObligationRouting(obligationId, {
|
|
195
|
+
runLeaseId,
|
|
196
|
+
threadId,
|
|
197
|
+
turnId,
|
|
198
|
+
});
|
|
199
|
+
this.stores.obligations.markObligationStatus(obligationId, "completed");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
159
202
|
async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
|
|
160
203
|
const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
|
|
161
204
|
if (existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
|
|
@@ -179,7 +222,7 @@ export class ServiceStageRunner {
|
|
|
179
222
|
lastWebhookAt: new Date().toISOString(),
|
|
180
223
|
});
|
|
181
224
|
}
|
|
182
|
-
async launchStageThread(projectId, issueId, stageRunId, worktreePath
|
|
225
|
+
async launchStageThread(projectId, issueId, stageRunId, worktreePath) {
|
|
183
226
|
const previousStageRun = this.stores.issueWorkflows
|
|
184
227
|
.listStageRunsForIssue(projectId, issueId)
|
|
185
228
|
.filter((stageRun) => stageRun.id !== stageRunId)
|
|
@@ -187,35 +230,10 @@ export class ServiceStageRunner {
|
|
|
187
230
|
const parentThreadId = previousStageRun?.status === "completed" && isCodexThreadId(previousStageRun.threadId)
|
|
188
231
|
? previousStageRun.threadId
|
|
189
232
|
: undefined;
|
|
190
|
-
if (parentThreadId) {
|
|
191
|
-
try {
|
|
192
|
-
const thread = await this.codex.forkThread(parentThreadId, worktreePath);
|
|
193
|
-
return {
|
|
194
|
-
threadId: thread.id,
|
|
195
|
-
parentThreadId,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
catch (error) {
|
|
199
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
200
|
-
this.logger.warn({
|
|
201
|
-
issueKey,
|
|
202
|
-
parentThreadId,
|
|
203
|
-
error: err.message,
|
|
204
|
-
}, "Falling back to a fresh Codex thread after parent thread fork failed");
|
|
205
|
-
this.feed?.publish({
|
|
206
|
-
level: "warn",
|
|
207
|
-
kind: "turn",
|
|
208
|
-
issueKey,
|
|
209
|
-
projectId,
|
|
210
|
-
status: "fallback",
|
|
211
|
-
summary: "Could not fork the previous Codex thread",
|
|
212
|
-
detail: "Starting a fresh thread instead.",
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
233
|
const thread = await this.codex.startThread({ cwd: worktreePath });
|
|
217
234
|
return {
|
|
218
235
|
threadId: thread.id,
|
|
236
|
+
...(parentThreadId ? { parentThreadId } : {}),
|
|
219
237
|
};
|
|
220
238
|
}
|
|
221
239
|
async markLaunchFailed(project, issue, stageRun, message, threadId) {
|
|
@@ -45,7 +45,7 @@ export class ServiceWebhookProcessor {
|
|
|
45
45
|
this.markEventReceiptProcessed(event.webhookId, "failed");
|
|
46
46
|
throw new Error(`Stored webhook payload is invalid JSON: event ${webhookEventId}`);
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
let normalized = normalizeWebhook({
|
|
49
49
|
webhookId: event.webhookId,
|
|
50
50
|
payload,
|
|
51
51
|
});
|
|
@@ -69,12 +69,25 @@ export class ServiceWebhookProcessor {
|
|
|
69
69
|
this.markEventReceiptProcessed(event.webhookId, "processed");
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
let project = resolveProject(this.config, normalized.issue);
|
|
73
73
|
if (!project) {
|
|
74
|
+
const routed = await this.tryHydrateProjectRoute(normalized);
|
|
75
|
+
if (routed) {
|
|
76
|
+
normalized = routed.normalized;
|
|
77
|
+
project = routed.project;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!project) {
|
|
81
|
+
const unresolvedIssue = normalized.issue;
|
|
82
|
+
if (!unresolvedIssue) {
|
|
83
|
+
this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
|
|
84
|
+
this.markEventReceiptProcessed(event.webhookId, "failed");
|
|
85
|
+
throw new Error(`Normalized issue context disappeared before routing webhook ${event.webhookId}`);
|
|
86
|
+
}
|
|
74
87
|
this.feed?.publish({
|
|
75
88
|
level: "warn",
|
|
76
89
|
kind: "webhook",
|
|
77
|
-
issueKey:
|
|
90
|
+
issueKey: unresolvedIssue.identifier,
|
|
78
91
|
status: "ignored",
|
|
79
92
|
summary: "Ignored webhook with no matching project route",
|
|
80
93
|
detail: normalized.triggerEvent,
|
|
@@ -82,21 +95,27 @@ export class ServiceWebhookProcessor {
|
|
|
82
95
|
this.logger.info({
|
|
83
96
|
webhookEventId,
|
|
84
97
|
webhookId: event.webhookId,
|
|
85
|
-
issueKey:
|
|
86
|
-
issueId:
|
|
87
|
-
teamId:
|
|
88
|
-
teamKey:
|
|
98
|
+
issueKey: unresolvedIssue.identifier,
|
|
99
|
+
issueId: unresolvedIssue.id,
|
|
100
|
+
teamId: unresolvedIssue.teamId,
|
|
101
|
+
teamKey: unresolvedIssue.teamKey,
|
|
89
102
|
triggerEvent: normalized.triggerEvent,
|
|
90
103
|
}, "Ignoring webhook because no project route matched the Linear issue");
|
|
91
104
|
this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
|
|
92
105
|
this.markEventReceiptProcessed(event.webhookId, "processed");
|
|
93
106
|
return;
|
|
94
107
|
}
|
|
108
|
+
const routedIssue = normalized.issue;
|
|
109
|
+
if (!routedIssue) {
|
|
110
|
+
this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
|
|
111
|
+
this.markEventReceiptProcessed(event.webhookId, "failed");
|
|
112
|
+
throw new Error(`Normalized issue context disappeared while routing webhook ${event.webhookId}`);
|
|
113
|
+
}
|
|
95
114
|
if (!trustedActorAllowed(project, normalized.actor)) {
|
|
96
115
|
this.feed?.publish({
|
|
97
116
|
level: "warn",
|
|
98
117
|
kind: "webhook",
|
|
99
|
-
issueKey:
|
|
118
|
+
issueKey: routedIssue.identifier,
|
|
100
119
|
projectId: project.id,
|
|
101
120
|
status: "ignored",
|
|
102
121
|
summary: "Ignored webhook from an untrusted actor",
|
|
@@ -111,14 +130,15 @@ export class ServiceWebhookProcessor {
|
|
|
111
130
|
actorEmail: normalized.actor?.email,
|
|
112
131
|
}, "Ignoring webhook from untrusted Linear actor");
|
|
113
132
|
this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
|
|
114
|
-
this.assignEventReceiptContext(event.webhookId, project.id,
|
|
133
|
+
this.assignEventReceiptContext(event.webhookId, project.id, routedIssue.id);
|
|
115
134
|
this.markEventReceiptProcessed(event.webhookId, "processed");
|
|
116
135
|
return;
|
|
117
136
|
}
|
|
118
137
|
this.stores.webhookEvents.assignWebhookProject(webhookEventId, project.id);
|
|
119
|
-
const receipt = this.ensureEventReceipt(event, project.id,
|
|
138
|
+
const receipt = this.ensureEventReceipt(event, project.id, routedIssue.id);
|
|
120
139
|
const hydrated = await this.hydrateIssueContext(project.id, normalized);
|
|
121
|
-
const hydratedIssue = hydrated.issue ??
|
|
140
|
+
const hydratedIssue = hydrated.issue ?? routedIssue;
|
|
141
|
+
const priorIssue = this.stores.issueWorkflows.getTrackedIssue(project.id, hydratedIssue.id);
|
|
122
142
|
const issueState = this.desiredStageRecorder.record(project, hydrated, receipt ? { eventReceiptId: receipt.id } : undefined);
|
|
123
143
|
const observation = describeWebhookObservation(hydrated, issueState.delegatedToPatchRelay);
|
|
124
144
|
if (observation) {
|
|
@@ -127,11 +147,29 @@ export class ServiceWebhookProcessor {
|
|
|
127
147
|
kind: observation.kind,
|
|
128
148
|
issueKey: hydratedIssue.identifier,
|
|
129
149
|
projectId: project.id,
|
|
150
|
+
...(issueState.issue?.selectedWorkflowId ? { workflowId: issueState.issue.selectedWorkflowId } : {}),
|
|
130
151
|
...(observation.status ? { status: observation.status } : {}),
|
|
131
152
|
summary: observation.summary,
|
|
132
153
|
...(observation.detail ? { detail: observation.detail } : {}),
|
|
133
154
|
});
|
|
134
155
|
}
|
|
156
|
+
if (issueState.issue?.selectedWorkflowId &&
|
|
157
|
+
issueState.issue.selectedWorkflowId !== priorIssue?.selectedWorkflowId &&
|
|
158
|
+
(hydrated.triggerEvent === "agentSessionCreated" || hydrated.triggerEvent === "agentPrompted")) {
|
|
159
|
+
this.feed?.publish({
|
|
160
|
+
level: "info",
|
|
161
|
+
kind: "workflow",
|
|
162
|
+
issueKey: hydratedIssue.identifier,
|
|
163
|
+
projectId: project.id,
|
|
164
|
+
workflowId: issueState.issue.selectedWorkflowId,
|
|
165
|
+
...(issueState.desiredStage ? { nextStage: issueState.desiredStage } : {}),
|
|
166
|
+
status: "selected",
|
|
167
|
+
summary: `Selected ${issueState.issue.selectedWorkflowId} workflow`,
|
|
168
|
+
detail: issueState.desiredStage
|
|
169
|
+
? `PatchRelay will start with ${issueState.desiredStage} from ${hydratedIssue.stateName ?? "the current Linear state"}.`
|
|
170
|
+
: undefined,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
135
173
|
await this.agentSessionHandler.handle({
|
|
136
174
|
normalized: hydrated,
|
|
137
175
|
project,
|
|
@@ -149,6 +187,7 @@ export class ServiceWebhookProcessor {
|
|
|
149
187
|
issueKey: hydratedIssue.identifier,
|
|
150
188
|
projectId: project.id,
|
|
151
189
|
stage: issueState.desiredStage,
|
|
190
|
+
...(issueState.issue?.selectedWorkflowId ? { workflowId: issueState.issue.selectedWorkflowId } : {}),
|
|
152
191
|
status: "queued",
|
|
153
192
|
summary: `Queued ${issueState.desiredStage} workflow`,
|
|
154
193
|
detail: `Triggered by ${hydrated.triggerEvent}${hydratedIssue.stateName ? ` from ${hydratedIssue.stateName}` : ""}.`,
|
|
@@ -229,6 +268,43 @@ export class ServiceWebhookProcessor {
|
|
|
229
268
|
return normalized;
|
|
230
269
|
}
|
|
231
270
|
}
|
|
271
|
+
async tryHydrateProjectRoute(normalized) {
|
|
272
|
+
if (!normalized.issue) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
for (const candidate of this.config.projects) {
|
|
279
|
+
const linear = await this.linearProvider.forProject(candidate.id);
|
|
280
|
+
if (!linear) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const liveIssue = await linear.getIssue(normalized.issue.id);
|
|
285
|
+
const hydrated = {
|
|
286
|
+
...normalized,
|
|
287
|
+
issue: mergeIssueMetadata(normalized.issue, liveIssue),
|
|
288
|
+
};
|
|
289
|
+
const resolved = resolveProject(this.config, hydrated.issue);
|
|
290
|
+
if (resolved) {
|
|
291
|
+
return {
|
|
292
|
+
project: resolved,
|
|
293
|
+
normalized: hydrated,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
this.logger.debug({
|
|
299
|
+
candidateProjectId: candidate.id,
|
|
300
|
+
issueId: normalized.issue.id,
|
|
301
|
+
triggerEvent: normalized.triggerEvent,
|
|
302
|
+
error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
|
|
303
|
+
}, "Failed to hydrate Linear issue context while resolving project route");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
232
308
|
assignEventReceiptContext(webhookId, projectId, linearIssueId) {
|
|
233
309
|
const receipt = this.lookupEventReceipt(webhookId);
|
|
234
310
|
if (!receipt) {
|
package/dist/service.js
CHANGED
|
@@ -12,6 +12,7 @@ function createServiceStores(db) {
|
|
|
12
12
|
webhookEvents: db.webhookEvents,
|
|
13
13
|
eventReceipts: db.eventReceipts,
|
|
14
14
|
issueControl: db.issueControl,
|
|
15
|
+
issueSessions: db.issueSessions,
|
|
15
16
|
workspaceOwnership: db.workspaceOwnership,
|
|
16
17
|
runLeases: db.runLeases,
|
|
17
18
|
obligations: db.obligations,
|
package/dist/stage-failure.js
CHANGED
|
@@ -9,10 +9,10 @@ export async function syncFailedStageToLinear(params) {
|
|
|
9
9
|
if (!linear) {
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
|
-
const fallbackState = resolveFallbackLinearState(params.project, params.stageRun.stage);
|
|
12
|
+
const fallbackState = resolveFallbackLinearState(params.project, params.stageRun.stage, params.issue.selectedWorkflowId);
|
|
13
13
|
let shouldWriteFailureState = true;
|
|
14
14
|
if (params.requireActiveLinearStateMatch) {
|
|
15
|
-
const activeState = resolveActiveLinearState(params.project, params.stageRun.stage);
|
|
15
|
+
const activeState = resolveActiveLinearState(params.project, params.stageRun.stage, params.issue.selectedWorkflowId);
|
|
16
16
|
if (!activeState) {
|
|
17
17
|
shouldWriteFailureState = false;
|
|
18
18
|
}
|
|
@@ -71,7 +71,7 @@ export async function syncFailedStageToLinear(params) {
|
|
|
71
71
|
.then(() => true)
|
|
72
72
|
.catch(() => false)) || deliveredToSession;
|
|
73
73
|
}
|
|
74
|
-
if (!deliveredToSession) {
|
|
74
|
+
if (!deliveredToSession && !params.issue.activeAgentSessionId) {
|
|
75
75
|
const result = await linear
|
|
76
76
|
.upsertIssueComment({
|
|
77
77
|
issueId: params.stageRun.linearIssueId,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { safeJsonParse } from "./utils.js";
|
|
2
|
+
import { listAllowedTransitionTargets, listWorkflowStageIds, resolveWorkflowStageCandidate } from "./workflow-policy.js";
|
|
3
|
+
function normalize(value) {
|
|
4
|
+
const trimmed = value?.trim();
|
|
5
|
+
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
6
|
+
}
|
|
7
|
+
function stripListPrefix(value) {
|
|
8
|
+
return value.replace(/^[-*•]\s+/, "").replace(/^\d+\.\s+/, "").trim();
|
|
9
|
+
}
|
|
10
|
+
function resolveTerminalTarget(value) {
|
|
11
|
+
const normalized = normalize(value)?.replace(/[\s_-]+/g, "");
|
|
12
|
+
if (!normalized) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (["done", "complete", "completed", "shipped", "ship"].includes(normalized)) {
|
|
16
|
+
return "done";
|
|
17
|
+
}
|
|
18
|
+
if (["humanneeded", "humaninput", "needsinput", "unclear", "unknown", "blocked", "ambiguous"].includes(normalized)) {
|
|
19
|
+
return "human_needed";
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
export function resolveWorkflowTarget(project, value) {
|
|
24
|
+
return resolveWorkflowTargetForDefinition(project, value);
|
|
25
|
+
}
|
|
26
|
+
export function resolveWorkflowTargetForDefinition(project, value, workflowDefinitionId) {
|
|
27
|
+
return resolveWorkflowStageCandidate(project, value, workflowDefinitionId) ?? resolveTerminalTarget(value);
|
|
28
|
+
}
|
|
29
|
+
function summarizeSignalsHumanNeeded(lines) {
|
|
30
|
+
const joined = normalize(lines.join(" "));
|
|
31
|
+
if (!joined) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return ["blocked", "unclear", "ambiguous", "human input", "human needed", "need human", "cannot determine"].some((token) => joined.includes(token));
|
|
35
|
+
}
|
|
36
|
+
export function parseStageHandoff(project, assistantMessages, workflowDefinitionId) {
|
|
37
|
+
const latestMessage = [...assistantMessages].reverse().find((message) => typeof message === "string" && message.trim().length > 0);
|
|
38
|
+
if (!latestMessage) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const lines = latestMessage
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map((line) => line.trimEnd());
|
|
44
|
+
const markerIndex = lines.findIndex((line) => /^stage result\s*:?\s*$/i.test(line.trim()));
|
|
45
|
+
const relevantLines = (markerIndex >= 0 ? lines.slice(markerIndex + 1) : lines)
|
|
46
|
+
.map((line) => stripListPrefix(line.trim()))
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
if (relevantLines.length === 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const summaryLines = [];
|
|
52
|
+
let nextLikelyStageText;
|
|
53
|
+
let nextAttention;
|
|
54
|
+
for (const line of relevantLines) {
|
|
55
|
+
const nextStageMatch = line.match(/^next likely stage\s*:\s*(.+)$/i);
|
|
56
|
+
if (nextStageMatch) {
|
|
57
|
+
nextLikelyStageText = nextStageMatch[1]?.trim();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const attentionMatch = line.match(/^(next attention|what to watch|watch carefully|pay attention|human attention)\s*:\s*(.+)$/i);
|
|
61
|
+
if (attentionMatch) {
|
|
62
|
+
nextAttention = attentionMatch[2]?.trim();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
summaryLines.push(line);
|
|
66
|
+
}
|
|
67
|
+
const resolvedNextStage = resolveWorkflowTargetForDefinition(project, nextLikelyStageText, workflowDefinitionId);
|
|
68
|
+
return {
|
|
69
|
+
sourceText: latestMessage,
|
|
70
|
+
summaryLines,
|
|
71
|
+
...(nextLikelyStageText ? { nextLikelyStageText } : {}),
|
|
72
|
+
...(nextAttention ? { nextAttention } : {}),
|
|
73
|
+
suggestsHumanNeeded: summarizeSignalsHumanNeeded(summaryLines),
|
|
74
|
+
...(resolvedNextStage ? { resolvedNextStage } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function extractPriorStageHandoff(project, stageRun, workflowDefinitionId) {
|
|
78
|
+
if (!stageRun?.reportJson) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const report = safeJsonParse(stageRun.reportJson);
|
|
82
|
+
return report ? parseStageHandoff(project, report.assistantMessages, workflowDefinitionId) : undefined;
|
|
83
|
+
}
|
|
84
|
+
export function buildCarryForwardPrompt(params) {
|
|
85
|
+
const availableStages = listWorkflowStageIds(params.project, params.workflowDefinitionId);
|
|
86
|
+
const attemptNumber = params.stageHistory.filter((stageRun) => stageRun.stage === params.currentStage).length + 1;
|
|
87
|
+
const recentHistory = params.stageHistory.slice(-4).map((stageRun) => stageRun.stage);
|
|
88
|
+
const previousHandoff = extractPriorStageHandoff(params.project, params.previousStageRun, params.workflowDefinitionId);
|
|
89
|
+
const lines = [
|
|
90
|
+
`Workflow stage ids: ${availableStages.join(", ")}`,
|
|
91
|
+
`Allowed next targets from ${params.currentStage}: ${listAllowedTransitionTargets(params.project, params.currentStage, params.workflowDefinitionId).join(", ")}`,
|
|
92
|
+
`This is attempt ${attemptNumber} for the ${params.currentStage} stage.`,
|
|
93
|
+
recentHistory.length > 0 ? `Recent workflow history: ${recentHistory.join(" -> ")}` : undefined,
|
|
94
|
+
params.workspace?.branchName ? `Branch: ${params.workspace.branchName}` : undefined,
|
|
95
|
+
params.workspace?.worktreePath ? `Worktree: ${params.workspace.worktreePath}` : undefined,
|
|
96
|
+
params.previousStageRun ? "" : undefined,
|
|
97
|
+
params.previousStageRun ? "Carry-forward from the previous stage:" : undefined,
|
|
98
|
+
params.previousStageRun ? `- Prior stage: ${params.previousStageRun.stage}` : undefined,
|
|
99
|
+
previousHandoff?.summaryLines[0] ? `- Outcome: ${previousHandoff.summaryLines[0]}` : undefined,
|
|
100
|
+
previousHandoff && previousHandoff.summaryLines.length > 1
|
|
101
|
+
? `- Key facts: ${previousHandoff.summaryLines.slice(1, 3).join(" ")}`
|
|
102
|
+
: undefined,
|
|
103
|
+
previousHandoff?.nextAttention ? `- Watch next: ${previousHandoff.nextAttention}` : undefined,
|
|
104
|
+
params.previousStageRun?.threadId ? `- Prior thread: ${params.previousStageRun.threadId}` : undefined,
|
|
105
|
+
].filter((value) => Boolean(value));
|
|
106
|
+
return lines.length > 0 ? lines.join("\n") : undefined;
|
|
107
|
+
}
|
package/dist/stage-launch.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { buildCarryForwardPrompt } from "./stage-handoff.js";
|
|
4
|
+
import { listWorkflowStageIds, resolveWorkflowStageConfig } from "./workflow-policy.js";
|
|
4
5
|
function slugify(value) {
|
|
5
6
|
return value
|
|
6
7
|
.toLowerCase()
|
|
@@ -17,8 +18,8 @@ export function isCodexThreadId(value) {
|
|
|
17
18
|
}
|
|
18
19
|
return !value.startsWith("missing-thread-") && !value.startsWith("launch-failed-");
|
|
19
20
|
}
|
|
20
|
-
export function buildStageLaunchPlan(project, issue, stage) {
|
|
21
|
-
const workflow =
|
|
21
|
+
export function buildStageLaunchPlan(project, issue, stage, options) {
|
|
22
|
+
const workflow = resolveWorkflowStageConfig(project, stage, issue.selectedWorkflowId);
|
|
22
23
|
if (!workflow) {
|
|
23
24
|
throw new Error(`Workflow "${stage}" is not configured for project ${project.id}`);
|
|
24
25
|
}
|
|
@@ -26,15 +27,31 @@ export function buildStageLaunchPlan(project, issue, stage) {
|
|
|
26
27
|
const slug = issue.title ? slugify(issue.title) : "";
|
|
27
28
|
const branchSuffix = slug ? `${issueRef}-${slug}` : issueRef;
|
|
28
29
|
return {
|
|
29
|
-
branchName: `${project.branchPrefix}/${branchSuffix}`,
|
|
30
|
-
worktreePath: path.join(project.worktreeRoot, issueRef),
|
|
30
|
+
branchName: options?.branchName ?? `${project.branchPrefix}/${branchSuffix}`,
|
|
31
|
+
worktreePath: options?.worktreePath ?? path.join(project.worktreeRoot, issueRef),
|
|
31
32
|
workflowFile: workflow.workflowFile,
|
|
32
33
|
stage,
|
|
33
|
-
prompt: buildStagePrompt(issue, workflow.id, workflow.whenState, workflow.workflowFile
|
|
34
|
+
prompt: buildStagePrompt(project, issue, workflow.id, workflow.whenState, workflow.workflowFile, {
|
|
35
|
+
branchName: options?.branchName ?? `${project.branchPrefix}/${branchSuffix}`,
|
|
36
|
+
worktreePath: options?.worktreePath ?? path.join(project.worktreeRoot, issueRef),
|
|
37
|
+
...(issue.selectedWorkflowId ? { workflowDefinitionId: issue.selectedWorkflowId } : {}),
|
|
38
|
+
...(options?.previousStageRun ? { previousStageRun: options.previousStageRun } : {}),
|
|
39
|
+
...(options?.workspace ? { workspace: options.workspace } : {}),
|
|
40
|
+
stageHistory: options?.stageHistory ?? [],
|
|
41
|
+
}),
|
|
34
42
|
};
|
|
35
43
|
}
|
|
36
|
-
export function buildStagePrompt(issue, stage, triggerState, workflowFile) {
|
|
44
|
+
export function buildStagePrompt(project, issue, stage, triggerState, workflowFile, options) {
|
|
37
45
|
const workflowBody = existsSync(workflowFile) ? readFileSync(workflowFile, "utf8").trim() : "";
|
|
46
|
+
const carryForward = buildCarryForwardPrompt({
|
|
47
|
+
project,
|
|
48
|
+
currentStage: stage,
|
|
49
|
+
...(options?.workflowDefinitionId ? { workflowDefinitionId: options.workflowDefinitionId } : {}),
|
|
50
|
+
...(options?.previousStageRun ? { previousStageRun: options.previousStageRun } : {}),
|
|
51
|
+
...(options?.workspace ? { workspace: options.workspace } : {}),
|
|
52
|
+
stageHistory: options?.stageHistory ?? [],
|
|
53
|
+
});
|
|
54
|
+
const availableStages = listWorkflowStageIds(project, options?.workflowDefinitionId).join(", ");
|
|
38
55
|
return [
|
|
39
56
|
`Issue: ${issue.issueKey ?? issue.linearIssueId}`,
|
|
40
57
|
issue.title ? `Title: ${issue.title}` : undefined,
|
|
@@ -42,9 +59,22 @@ export function buildStagePrompt(issue, stage, triggerState, workflowFile) {
|
|
|
42
59
|
issue.currentLinearState ? `Current Linear State: ${issue.currentLinearState}` : undefined,
|
|
43
60
|
`Workflow: ${stage}`,
|
|
44
61
|
`Triggered By State: ${triggerState}`,
|
|
62
|
+
options?.branchName ? `Branch: ${options.branchName}` : undefined,
|
|
63
|
+
options?.worktreePath ? `Worktree: ${options.worktreePath}` : undefined,
|
|
64
|
+
"",
|
|
65
|
+
"Complete only the current workflow stage. Do not invent a new workflow or skip directly to another stage.",
|
|
66
|
+
"If the correct next step is unclear, say so plainly and use `human_needed` as the next likely stage.",
|
|
67
|
+
"",
|
|
68
|
+
carryForward ? "Carry-forward Context:" : undefined,
|
|
69
|
+
carryForward,
|
|
45
70
|
"",
|
|
46
71
|
"Operate only inside the prepared worktree for this issue. Continue the issue lifecycle in this workspace.",
|
|
47
|
-
"
|
|
72
|
+
"Use the repo workflow instructions below for this stage.",
|
|
73
|
+
"End with a short `Stage result:` section in plain text with exactly four bullets:",
|
|
74
|
+
"- what happened",
|
|
75
|
+
"- key facts or artifacts",
|
|
76
|
+
`- Next likely stage: one of ${availableStages}, done, or human_needed`,
|
|
77
|
+
"- what the next stage or human should pay attention to",
|
|
48
78
|
"",
|
|
49
79
|
`Workflow File: ${path.basename(workflowFile)}`,
|
|
50
80
|
workflowBody,
|