patchrelay 0.7.10 → 0.8.1
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-runtime.js +46 -1
- package/dist/service-stage-finalizer.js +243 -2
- package/dist/service-stage-runner.js +19 -29
- package/dist/service-webhook-processor.js +20 -0
- package/dist/service.js +1 -0
- package/dist/stage-failure.js +2 -2
- package/dist/stage-handoff.js +107 -0
- package/dist/stage-launch.js +38 -8
- package/dist/stage-lifecycle-publisher.js +35 -10
- package/dist/webhook-agent-session-handler.js +9 -1
- package/dist/webhook-desired-stage-recorder.js +24 -4
- package/dist/workflow-policy.js +115 -8
- package/package.json +1 -1
|
@@ -28,6 +28,19 @@ export class WebhookEventStore {
|
|
|
28
28
|
const row = this.connection.prepare("SELECT * FROM webhook_events WHERE id = ?").get(id);
|
|
29
29
|
return row ? mapWebhookEvent(row) : undefined;
|
|
30
30
|
}
|
|
31
|
+
listWebhookEventsForIssueSince(issueId, receivedAfter) {
|
|
32
|
+
const rows = this.connection
|
|
33
|
+
.prepare(`
|
|
34
|
+
SELECT *
|
|
35
|
+
FROM webhook_events
|
|
36
|
+
WHERE issue_id = ?
|
|
37
|
+
AND dedupe_status = 'accepted'
|
|
38
|
+
AND received_at > ?
|
|
39
|
+
ORDER BY received_at ASC, id ASC
|
|
40
|
+
`)
|
|
41
|
+
.all(issueId, receivedAfter);
|
|
42
|
+
return rows.map((row) => mapWebhookEvent(row));
|
|
43
|
+
}
|
|
31
44
|
}
|
|
32
45
|
function mapWebhookEvent(row) {
|
|
33
46
|
return {
|
package/dist/http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fastify from "fastify";
|
|
2
2
|
import rawBody from "fastify-raw-body";
|
|
3
3
|
import { getBuildInfo } from "./build-info.js";
|
|
4
|
+
import { matchesOperatorFeedEvent } from "./operator-feed.js";
|
|
4
5
|
export async function buildHttpServer(config, service, logger) {
|
|
5
6
|
const buildInfo = getBuildInfo();
|
|
6
7
|
const loopbackBind = isLoopbackBind(config.server.bind);
|
|
@@ -264,13 +265,9 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
264
265
|
}
|
|
265
266
|
if (managementRoutesEnabled) {
|
|
266
267
|
app.get("/api/feed", async (request, reply) => {
|
|
267
|
-
const limit = getPositiveIntegerQueryParam(request, "limit") ?? 50;
|
|
268
|
-
const issueKey = getQueryParam(request, "issue")?.trim() || undefined;
|
|
269
|
-
const projectId = getQueryParam(request, "project")?.trim() || undefined;
|
|
270
268
|
const feedQuery = {
|
|
271
|
-
limit,
|
|
272
|
-
...(
|
|
273
|
-
...(projectId ? { projectId } : {}),
|
|
269
|
+
limit: getPositiveIntegerQueryParam(request, "limit") ?? 50,
|
|
270
|
+
...readFeedQueryFilters(request),
|
|
274
271
|
};
|
|
275
272
|
if (getQueryParam(request, "follow") !== "1") {
|
|
276
273
|
return reply.send({ ok: true, events: service.listOperatorFeed(feedQuery) });
|
|
@@ -290,10 +287,7 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
290
287
|
writeEvent(event);
|
|
291
288
|
}
|
|
292
289
|
const unsubscribe = service.subscribeOperatorFeed((event) => {
|
|
293
|
-
if (
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
if (projectId && event.projectId !== projectId) {
|
|
290
|
+
if (!matchesOperatorFeedEvent(event, feedQuery)) {
|
|
297
291
|
return;
|
|
298
292
|
}
|
|
299
293
|
writeEvent(event);
|
|
@@ -385,6 +379,22 @@ function getQueryParam(request, key) {
|
|
|
385
379
|
const value = request.query?.[key];
|
|
386
380
|
return typeof value === "string" ? value : undefined;
|
|
387
381
|
}
|
|
382
|
+
function readFeedQueryFilters(request) {
|
|
383
|
+
const issueKey = getQueryParam(request, "issue")?.trim() || undefined;
|
|
384
|
+
const projectId = getQueryParam(request, "project")?.trim() || undefined;
|
|
385
|
+
const kind = (getQueryParam(request, "kind")?.trim() || undefined);
|
|
386
|
+
const stage = getQueryParam(request, "stage")?.trim() || undefined;
|
|
387
|
+
const status = getQueryParam(request, "status")?.trim() || undefined;
|
|
388
|
+
const workflowId = getQueryParam(request, "workflow")?.trim() || undefined;
|
|
389
|
+
return {
|
|
390
|
+
...(issueKey ? { issueKey } : {}),
|
|
391
|
+
...(projectId ? { projectId } : {}),
|
|
392
|
+
...(kind ? { kind } : {}),
|
|
393
|
+
...(stage ? { stage } : {}),
|
|
394
|
+
...(status ? { status } : {}),
|
|
395
|
+
...(workflowId ? { workflowId } : {}),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
388
398
|
function getPositiveIntegerQueryParam(request, key) {
|
|
389
399
|
const value = getQueryParam(request, key);
|
|
390
400
|
if (!value || !/^\d+$/.test(value)) {
|
package/dist/install.js
CHANGED
|
@@ -39,6 +39,23 @@ function defaultProjectWorkflows() {
|
|
|
39
39
|
},
|
|
40
40
|
];
|
|
41
41
|
}
|
|
42
|
+
function defaultRepoProjectSettings() {
|
|
43
|
+
return {
|
|
44
|
+
workflow_definitions: [
|
|
45
|
+
{
|
|
46
|
+
id: "default",
|
|
47
|
+
stages: defaultProjectWorkflows(),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
workflow_selection: {
|
|
51
|
+
default_workflow: "default",
|
|
52
|
+
by_label: [],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function getRepoProjectSettingsPath(repoPath) {
|
|
57
|
+
return `${repoPath}/.patchrelay/project.json`;
|
|
58
|
+
}
|
|
42
59
|
function renderTemplate(template, replacements) {
|
|
43
60
|
const home = homedir();
|
|
44
61
|
const user = basename(home);
|
|
@@ -200,9 +217,6 @@ export async function upsertProjectInConfig(options) {
|
|
|
200
217
|
...(existingProject ?? {}),
|
|
201
218
|
id: resolvedProjectId,
|
|
202
219
|
repo_path: repoPath,
|
|
203
|
-
workflows: Array.isArray(existingProject?.workflows) && existingProject.workflows.length > 0
|
|
204
|
-
? existingProject.workflows
|
|
205
|
-
: defaultProjectWorkflows(),
|
|
206
220
|
};
|
|
207
221
|
if (issueKeyPrefixes.length > 0) {
|
|
208
222
|
nextProject.issue_key_prefixes = issueKeyPrefixes;
|
|
@@ -281,6 +295,7 @@ export async function upsertProjectInConfig(options) {
|
|
|
281
295
|
const next = stringifyConfig(document);
|
|
282
296
|
await writeFile(configPath, next, "utf8");
|
|
283
297
|
}
|
|
298
|
+
await writeTemplateFile(getRepoProjectSettingsPath(repoPath), stringifyConfig(defaultRepoProjectSettings()), false);
|
|
284
299
|
try {
|
|
285
300
|
loadConfig(configPath, { profile: "write_config" });
|
|
286
301
|
}
|
package/dist/linear-workflow.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveWorkflowStageConfig } from "./workflow-policy.js";
|
|
2
2
|
const STATUS_MARKER = "<!-- patchrelay:status-comment -->";
|
|
3
|
-
export function resolveActiveLinearState(project, stage) {
|
|
4
|
-
return
|
|
3
|
+
export function resolveActiveLinearState(project, stage, workflowDefinitionId) {
|
|
4
|
+
return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.activeState;
|
|
5
5
|
}
|
|
6
|
-
export function resolveFallbackLinearState(project, stage) {
|
|
7
|
-
return
|
|
6
|
+
export function resolveFallbackLinearState(project, stage, workflowDefinitionId) {
|
|
7
|
+
return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.fallbackState;
|
|
8
8
|
}
|
|
9
9
|
export function buildRunningStatusComment(params) {
|
|
10
10
|
return [
|
|
@@ -35,6 +35,21 @@ export function buildAwaitingHandoffComment(params) {
|
|
|
35
35
|
"The workflow likely finished without moving the issue to its next Linear state. Please review the thread report and update the issue state.",
|
|
36
36
|
].join("\n");
|
|
37
37
|
}
|
|
38
|
+
export function buildHumanNeededComment(params) {
|
|
39
|
+
return [
|
|
40
|
+
STATUS_MARKER,
|
|
41
|
+
`PatchRelay finished the ${params.stageRun.stage} workflow and now needs human input.`,
|
|
42
|
+
"",
|
|
43
|
+
`- Issue: \`${params.issue.issueKey ?? params.issue.linearIssueId}\``,
|
|
44
|
+
`- Workflow: \`${params.stageRun.stage}\``,
|
|
45
|
+
`- Thread: \`${params.stageRun.threadId ?? "unknown"}\``,
|
|
46
|
+
`- Turn: \`${params.stageRun.turnId ?? "unknown"}\``,
|
|
47
|
+
`- Completed: \`${params.stageRun.endedAt ?? new Date().toISOString()}\``,
|
|
48
|
+
"- Status: `human-needed`",
|
|
49
|
+
"",
|
|
50
|
+
"Review the stage report, decide the right next workflow step, and move or re-prompt the issue when ready.",
|
|
51
|
+
].join("\n");
|
|
52
|
+
}
|
|
38
53
|
export function buildStageFailedComment(params) {
|
|
39
54
|
const mode = params.mode ?? "launch";
|
|
40
55
|
return [
|
package/dist/operator-feed.js
CHANGED
|
@@ -39,6 +39,8 @@ export class OperatorEventFeed {
|
|
|
39
39
|
...(event.projectId ? { projectId: event.projectId } : {}),
|
|
40
40
|
...(event.stage ? { stage: event.stage } : {}),
|
|
41
41
|
...(event.status ? { status: event.status } : {}),
|
|
42
|
+
...(event.workflowId ? { workflowId: event.workflowId } : {}),
|
|
43
|
+
...(event.nextStage ? { nextStage: event.nextStage } : {}),
|
|
42
44
|
};
|
|
43
45
|
if (!this.store) {
|
|
44
46
|
return this.pushFallbackEvent(normalizedEvent);
|
|
@@ -63,20 +65,36 @@ export class OperatorEventFeed {
|
|
|
63
65
|
return fullEvent;
|
|
64
66
|
}
|
|
65
67
|
listFallback(options) {
|
|
66
|
-
return this.persistedFallbackEvents.filter((event) =>
|
|
67
|
-
if (options?.afterId !== undefined && event.id <= options.afterId) {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
if (options?.issueKey && event.issueKey !== options.issueKey) {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
if (options?.projectId && event.projectId !== options.projectId) {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
return true;
|
|
77
|
-
});
|
|
68
|
+
return this.persistedFallbackEvents.filter((event) => matchesOperatorFeedEvent(event, options));
|
|
78
69
|
}
|
|
79
70
|
}
|
|
71
|
+
export function matchesOperatorFeedEvent(event, options) {
|
|
72
|
+
if (!options) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (options.afterId !== undefined && event.id <= options.afterId) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (options.issueKey && event.issueKey !== options.issueKey) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (options.projectId && event.projectId !== options.projectId) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (options.kind && event.kind !== options.kind) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (options.stage && event.stage !== options.stage) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (options.status && event.status !== options.status) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (options.workflowId && event.workflowId !== options.workflowId) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
80
98
|
function compareFeedEvents(left, right) {
|
|
81
99
|
if (left.at !== right.at) {
|
|
82
100
|
return left.at.localeCompare(right.at);
|
package/dist/preflight.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
4
4
|
import { SqliteConnection } from "./db/shared.js";
|
|
5
5
|
import { execCommand } from "./utils.js";
|
|
6
|
+
import { listProjectWorkflowDefinitions } from "./workflow-policy.js";
|
|
6
7
|
export async function runPreflight(config) {
|
|
7
8
|
const checks = [];
|
|
8
9
|
if (!config.linear.webhookSecret) {
|
|
@@ -72,8 +73,10 @@ export async function runPreflight(config) {
|
|
|
72
73
|
for (const project of config.projects) {
|
|
73
74
|
checks.push(...checkPath(`project:${project.id}:repo`, project.repoPath, "directory", { writable: true }));
|
|
74
75
|
checks.push(...checkPath(`project:${project.id}:worktrees`, project.worktreeRoot, "directory", { createIfMissing: true, writable: true }));
|
|
75
|
-
for (const
|
|
76
|
-
|
|
76
|
+
for (const definition of listProjectWorkflowDefinitions(project)) {
|
|
77
|
+
for (const workflow of definition.stages) {
|
|
78
|
+
checks.push(...checkPath(`project:${project.id}:workflow:${definition.id}:${workflow.id}`, workflow.workflowFile, "file", {}));
|
|
79
|
+
}
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
checks.push(await checkExecutable("git", config.runner.gitBin));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveWorkflowStageConfig } from "./workflow-policy.js";
|
|
1
2
|
import { safeJsonParse } from "./utils.js";
|
|
2
3
|
export async function buildReconciliationSnapshot(params) {
|
|
3
4
|
const runLease = params.stores.runLeases.getRunLease(params.runLeaseId);
|
|
@@ -10,7 +11,7 @@ export async function buildReconciliationSnapshot(params) {
|
|
|
10
11
|
}
|
|
11
12
|
const workspaceOwnership = params.stores.workspaceOwnership.getWorkspaceOwnership(runLease.workspaceOwnershipId);
|
|
12
13
|
const project = params.config.projects.find((candidate) => candidate.id === runLease.projectId);
|
|
13
|
-
const workflowConfig = project
|
|
14
|
+
const workflowConfig = project ? resolveWorkflowStageConfig(project, runLease.stage, issueControl.selectedWorkflowId) : undefined;
|
|
14
15
|
const liveLinear = project
|
|
15
16
|
? await params.linearProvider
|
|
16
17
|
.forProject(runLease.projectId)
|
package/dist/service-runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SerialWorkQueue } from "./service-queue.js";
|
|
2
2
|
const ISSUE_KEY_DELIMITER = "::";
|
|
3
|
+
const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
|
|
3
4
|
function toReconciler(value) {
|
|
4
5
|
if (typeof value === "function") {
|
|
5
6
|
return {
|
|
@@ -40,13 +41,17 @@ function makeIssueQueueKey(item) {
|
|
|
40
41
|
export class ServiceRuntime {
|
|
41
42
|
codex;
|
|
42
43
|
logger;
|
|
44
|
+
options;
|
|
43
45
|
webhookQueue;
|
|
44
46
|
issueQueue;
|
|
45
47
|
ready = false;
|
|
46
48
|
startupError;
|
|
47
|
-
|
|
49
|
+
reconcileTimer;
|
|
50
|
+
reconcileInProgress = false;
|
|
51
|
+
constructor(codex, logger, stageRunReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
|
|
48
52
|
this.codex = codex;
|
|
49
53
|
this.logger = logger;
|
|
54
|
+
this.options = options;
|
|
50
55
|
this.stageRunReconciler = toReconciler(stageRunReconciler);
|
|
51
56
|
this.readyIssueSource = toReadyIssueSource(readyIssueSource);
|
|
52
57
|
this.webhookProcessor = toWebhookProcessor(webhookProcessor);
|
|
@@ -69,6 +74,7 @@ export class ServiceRuntime {
|
|
|
69
74
|
}
|
|
70
75
|
this.ready = true;
|
|
71
76
|
this.startupError = undefined;
|
|
77
|
+
this.scheduleBackgroundReconcile();
|
|
72
78
|
}
|
|
73
79
|
catch (error) {
|
|
74
80
|
this.ready = false;
|
|
@@ -78,6 +84,7 @@ export class ServiceRuntime {
|
|
|
78
84
|
}
|
|
79
85
|
stop() {
|
|
80
86
|
this.ready = false;
|
|
87
|
+
this.clearBackgroundReconcile();
|
|
81
88
|
void this.codex.stop();
|
|
82
89
|
}
|
|
83
90
|
enqueueWebhookEvent(eventId, options) {
|
|
@@ -93,4 +100,42 @@ export class ServiceRuntime {
|
|
|
93
100
|
...(this.startupError ? { startupError: this.startupError } : {}),
|
|
94
101
|
};
|
|
95
102
|
}
|
|
103
|
+
scheduleBackgroundReconcile() {
|
|
104
|
+
this.clearBackgroundReconcile();
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
void this.runBackgroundReconcile();
|
|
107
|
+
}, this.options.reconcileIntervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
this.reconcileTimer = timer;
|
|
110
|
+
}
|
|
111
|
+
clearBackgroundReconcile() {
|
|
112
|
+
if (this.reconcileTimer !== undefined) {
|
|
113
|
+
clearTimeout(this.reconcileTimer);
|
|
114
|
+
this.reconcileTimer = undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async runBackgroundReconcile() {
|
|
118
|
+
if (!this.ready || !this.codex.isStarted()) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (this.reconcileInProgress) {
|
|
122
|
+
this.scheduleBackgroundReconcile();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.reconcileInProgress = true;
|
|
126
|
+
try {
|
|
127
|
+
await this.stageRunReconciler.reconcileActiveStageRuns();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
this.logger.warn({
|
|
131
|
+
error: error instanceof Error ? error.message : String(error),
|
|
132
|
+
}, "Background active-stage reconciliation failed");
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
this.reconcileInProgress = false;
|
|
136
|
+
if (this.ready) {
|
|
137
|
+
this.scheduleBackgroundReconcile();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
96
141
|
}
|
|
@@ -2,9 +2,15 @@ import { ReconciliationActionApplier } from "./reconciliation-action-applier.js"
|
|
|
2
2
|
import { reconcileIssue } from "./reconciliation-engine.js";
|
|
3
3
|
import { buildReconciliationSnapshot } from "./reconciliation-snapshot-builder.js";
|
|
4
4
|
import { syncFailedStageToLinear } from "./stage-failure.js";
|
|
5
|
+
import { parseStageHandoff } from "./stage-handoff.js";
|
|
6
|
+
import { resolveActiveLinearState, resolveFallbackLinearState } from "./linear-workflow.js";
|
|
7
|
+
import { resolveDefaultTransitionTarget, transitionTargetAllowed } from "./workflow-policy.js";
|
|
5
8
|
import { buildFailedStageReport, buildPendingMaterializationThread, buildStageReport, countEventMethods, extractStageSummary, extractTurnId, resolveStageRunStatus, summarizeCurrentThread, } from "./stage-reporting.js";
|
|
6
9
|
import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
|
|
7
10
|
import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
|
|
11
|
+
import { safeJsonParse } from "./utils.js";
|
|
12
|
+
import { normalizeWebhook } from "./webhooks.js";
|
|
13
|
+
const MAX_AUTOMATIC_TRANSITION_ATTEMPTS = 3;
|
|
8
14
|
export class ServiceStageFinalizer {
|
|
9
15
|
config;
|
|
10
16
|
stores;
|
|
@@ -82,6 +88,7 @@ export class ServiceStageFinalizer {
|
|
|
82
88
|
issueKey: issue?.issueKey,
|
|
83
89
|
projectId: stageRun.projectId,
|
|
84
90
|
stage: stageRun.stage,
|
|
91
|
+
...(issue?.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
|
|
85
92
|
status: "started",
|
|
86
93
|
summary: `Turn started for ${stageRun.stage}`,
|
|
87
94
|
detail: turnId ? `Turn ${turnId} is now live.` : undefined,
|
|
@@ -106,6 +113,7 @@ export class ServiceStageFinalizer {
|
|
|
106
113
|
issueKey: issue.issueKey,
|
|
107
114
|
projectId: stageRun.projectId,
|
|
108
115
|
stage: stageRun.stage,
|
|
116
|
+
...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
|
|
109
117
|
status: "failed",
|
|
110
118
|
summary: `Turn failed for ${stageRun.stage}`,
|
|
111
119
|
detail: completedTurnId ? `Turn ${completedTurnId} completed in a failed state.` : undefined,
|
|
@@ -121,6 +129,7 @@ export class ServiceStageFinalizer {
|
|
|
121
129
|
issueKey: issue.issueKey,
|
|
122
130
|
projectId: stageRun.projectId,
|
|
123
131
|
stage: stageRun.stage,
|
|
132
|
+
...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
|
|
124
133
|
status: "completed",
|
|
125
134
|
summary: `Turn completed for ${stageRun.stage}`,
|
|
126
135
|
detail: summarizeCurrentThread(thread).latestAgentMessage,
|
|
@@ -159,7 +168,7 @@ export class ServiceStageFinalizer {
|
|
|
159
168
|
reportJson: JSON.stringify(report),
|
|
160
169
|
});
|
|
161
170
|
});
|
|
162
|
-
void this.advanceAfterStageCompletion(stageRun);
|
|
171
|
+
void this.advanceAfterStageCompletion(stageRun, report);
|
|
163
172
|
}
|
|
164
173
|
failStageRun(stageRun, threadId, message, options) {
|
|
165
174
|
this.runAtomically(() => {
|
|
@@ -227,9 +236,225 @@ export class ServiceStageFinalizer {
|
|
|
227
236
|
async flushQueuedTurnInputs(stageRun) {
|
|
228
237
|
await this.inputDispatcher.flush(stageRun);
|
|
229
238
|
}
|
|
230
|
-
async advanceAfterStageCompletion(stageRun) {
|
|
239
|
+
async advanceAfterStageCompletion(stageRun, report) {
|
|
240
|
+
await this.maybeQueueAutomaticTransition(stageRun, report);
|
|
231
241
|
await this.lifecyclePublisher.publishStageCompletion(stageRun, this.enqueueIssue);
|
|
232
242
|
}
|
|
243
|
+
async maybeQueueAutomaticTransition(stageRun, report) {
|
|
244
|
+
const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
245
|
+
if (!refreshedIssue || refreshedIssue.desiredStage) {
|
|
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 continuationPrecondition = await this.checkAutomaticContinuationPreconditions(project, stageRun, refreshedIssue, linear, linearIssue);
|
|
265
|
+
if (!continuationPrecondition.allowed) {
|
|
266
|
+
this.feed?.publish({
|
|
267
|
+
level: "info",
|
|
268
|
+
kind: "workflow",
|
|
269
|
+
issueKey: refreshedIssue.issueKey,
|
|
270
|
+
projectId: stageRun.projectId,
|
|
271
|
+
stage: stageRun.stage,
|
|
272
|
+
...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
|
|
273
|
+
status: "transition_suppressed",
|
|
274
|
+
summary: `Suppressed automatic continuation after ${stageRun.stage}`,
|
|
275
|
+
detail: continuationPrecondition.reason,
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const nextTarget = this.resolveTransitionTarget(project, stageRun, refreshedIssue.selectedWorkflowId, handoff);
|
|
280
|
+
if (nextTarget === "done") {
|
|
281
|
+
const doneState = resolveDoneLinearState(linearIssue);
|
|
282
|
+
if (!doneState) {
|
|
283
|
+
await this.routeStageToHumanNeeded(project, stageRun, linearIssue, "PatchRelay could not determine the repo's done state.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.feed?.publish({
|
|
287
|
+
level: "info",
|
|
288
|
+
kind: "workflow",
|
|
289
|
+
issueKey: refreshedIssue.issueKey,
|
|
290
|
+
projectId: stageRun.projectId,
|
|
291
|
+
stage: stageRun.stage,
|
|
292
|
+
...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
|
|
293
|
+
status: "completed",
|
|
294
|
+
summary: `Completed workflow after ${stageRun.stage}`,
|
|
295
|
+
});
|
|
296
|
+
await linear.setIssueState(stageRun.linearIssueId, doneState);
|
|
297
|
+
this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, undefined, {
|
|
298
|
+
lifecycleStatus: "completed",
|
|
299
|
+
});
|
|
300
|
+
this.stores.workflowCoordinator.upsertTrackedIssue({
|
|
301
|
+
projectId: stageRun.projectId,
|
|
302
|
+
linearIssueId: stageRun.linearIssueId,
|
|
303
|
+
currentLinearState: doneState,
|
|
304
|
+
lifecycleStatus: "completed",
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (nextTarget === "human_needed") {
|
|
309
|
+
await this.routeStageToHumanNeeded(project, stageRun, linearIssue, handoff.nextLikelyStageText
|
|
310
|
+
? `PatchRelay could not safely continue from "${handoff.nextLikelyStageText}".`
|
|
311
|
+
: handoff.suggestsHumanNeeded
|
|
312
|
+
? "PatchRelay needs human input before the next stage is clear."
|
|
313
|
+
: `PatchRelay could not map the ${stageRun.stage} result to an allowed next transition.`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (nextTarget === stageRun.stage) {
|
|
317
|
+
await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay received ${nextTarget} as the next stage again and needs a human to confirm the intended loop.`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const priorAttempts = this.countPriorTransitionAttempts(stageRun.projectId, stageRun.linearIssueId, stageRun.stage, nextTarget);
|
|
321
|
+
if (priorAttempts >= MAX_AUTOMATIC_TRANSITION_ATTEMPTS) {
|
|
322
|
+
await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay hit the automatic continuation limit for ${stageRun.stage} -> ${nextTarget}.`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.feed?.publish({
|
|
326
|
+
level: "info",
|
|
327
|
+
kind: "workflow",
|
|
328
|
+
issueKey: refreshedIssue.issueKey,
|
|
329
|
+
projectId: stageRun.projectId,
|
|
330
|
+
stage: stageRun.stage,
|
|
331
|
+
...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
|
|
332
|
+
nextStage: nextTarget,
|
|
333
|
+
status: "transition_chosen",
|
|
334
|
+
summary: `Chose ${stageRun.stage} -> ${nextTarget}`,
|
|
335
|
+
detail: handoff.nextLikelyStageText ? `Stage result suggested "${handoff.nextLikelyStageText}".` : "PatchRelay used the workflow policy default.",
|
|
336
|
+
});
|
|
337
|
+
this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, nextTarget, {
|
|
338
|
+
desiredWebhookId: `auto-transition:${stageRun.id}:${nextTarget}`,
|
|
339
|
+
lifecycleStatus: "queued",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
async checkAutomaticContinuationPreconditions(project, stageRun, issue, linear, linearIssue) {
|
|
343
|
+
const activeState = resolveActiveLinearState(project, stageRun.stage, issue.selectedWorkflowId);
|
|
344
|
+
if (activeState && normalizeLinearState(linearIssue.stateName) !== normalizeLinearState(activeState)) {
|
|
345
|
+
return {
|
|
346
|
+
allowed: false,
|
|
347
|
+
reason: `Linear moved from ${activeState} to ${linearIssue.stateName ?? "an unknown state"} while the stage was running.`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const actorProfile = await linear.getActorProfile().catch(() => undefined);
|
|
351
|
+
if (actorProfile?.actorId && linearIssue.delegateId && linearIssue.delegateId !== actorProfile.actorId) {
|
|
352
|
+
return {
|
|
353
|
+
allowed: false,
|
|
354
|
+
reason: "The issue is no longer delegated to PatchRelay.",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const stageSession = stageRun.threadId ? this.stores.issueSessions.getIssueSessionByThreadId(stageRun.threadId) : undefined;
|
|
358
|
+
if (stageSession?.linkedAgentSessionId && issue.activeAgentSessionId !== stageSession.linkedAgentSessionId) {
|
|
359
|
+
return {
|
|
360
|
+
allowed: false,
|
|
361
|
+
reason: "The active Linear agent session changed while the stage was running.",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const recentInterruptions = this.listInterruptingWebhooksSince(stageRun, actorProfile?.actorId);
|
|
365
|
+
if (recentInterruptions.length > 0) {
|
|
366
|
+
return {
|
|
367
|
+
allowed: false,
|
|
368
|
+
reason: `A newer human webhook (${recentInterruptions[0]}) arrived while the stage was running.`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return { allowed: true };
|
|
372
|
+
}
|
|
373
|
+
listInterruptingWebhooksSince(stageRun, patchRelayActorId) {
|
|
374
|
+
const events = this.stores.webhookEvents.listWebhookEventsForIssueSince(stageRun.linearIssueId, stageRun.startedAt);
|
|
375
|
+
const interrupts = [];
|
|
376
|
+
for (const event of events) {
|
|
377
|
+
const payload = safeJsonParse(event.payloadJson);
|
|
378
|
+
if (!payload) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const normalized = normalizeWebhook({
|
|
382
|
+
webhookId: event.webhookId,
|
|
383
|
+
payload: payload,
|
|
384
|
+
});
|
|
385
|
+
if (patchRelayActorId && normalized.actor?.id === patchRelayActorId) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (normalized.triggerEvent === "commentCreated" ||
|
|
389
|
+
normalized.triggerEvent === "commentUpdated" ||
|
|
390
|
+
normalized.triggerEvent === "agentPrompted" ||
|
|
391
|
+
normalized.triggerEvent === "agentSessionCreated" ||
|
|
392
|
+
normalized.triggerEvent === "delegateChanged" ||
|
|
393
|
+
normalized.triggerEvent === "statusChanged") {
|
|
394
|
+
interrupts.push(normalized.triggerEvent);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return interrupts;
|
|
398
|
+
}
|
|
399
|
+
resolveTransitionTarget(project, stageRun, workflowDefinitionId, handoff) {
|
|
400
|
+
if (!handoff) {
|
|
401
|
+
return "human_needed";
|
|
402
|
+
}
|
|
403
|
+
const requestedTarget = handoff.resolvedNextStage;
|
|
404
|
+
if (requestedTarget) {
|
|
405
|
+
return transitionTargetAllowed(project, stageRun.stage, requestedTarget, workflowDefinitionId) ? requestedTarget : "human_needed";
|
|
406
|
+
}
|
|
407
|
+
if (handoff.suggestsHumanNeeded) {
|
|
408
|
+
return "human_needed";
|
|
409
|
+
}
|
|
410
|
+
return resolveDefaultTransitionTarget(project, stageRun.stage, workflowDefinitionId) ?? "human_needed";
|
|
411
|
+
}
|
|
412
|
+
countPriorTransitionAttempts(projectId, linearIssueId, currentStage, nextTarget) {
|
|
413
|
+
const stageHistory = this.stores.issueWorkflows
|
|
414
|
+
.listStageRunsForIssue(projectId, linearIssueId)
|
|
415
|
+
.filter((stageRun) => stageRun.status === "completed");
|
|
416
|
+
let count = 0;
|
|
417
|
+
for (let index = 0; index < stageHistory.length - 1; index += 1) {
|
|
418
|
+
const current = stageHistory[index];
|
|
419
|
+
const next = stageHistory[index + 1];
|
|
420
|
+
if (current?.stage === currentStage && next?.stage === nextTarget) {
|
|
421
|
+
count += 1;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return count;
|
|
425
|
+
}
|
|
426
|
+
async routeStageToHumanNeeded(project, stageRun, linearIssue, reason) {
|
|
427
|
+
const linear = await this.linearProvider.forProject(stageRun.projectId);
|
|
428
|
+
if (!linear) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const trackedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
432
|
+
const fallbackState = resolveFallbackLinearState(project, stageRun.stage, trackedIssue?.selectedWorkflowId) ??
|
|
433
|
+
linearIssue.workflowStates.find((state) => normalizeLinearState(state.name) === "human needed")?.name;
|
|
434
|
+
if (fallbackState) {
|
|
435
|
+
await linear.setIssueState(stageRun.linearIssueId, fallbackState);
|
|
436
|
+
}
|
|
437
|
+
this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, undefined, {
|
|
438
|
+
lifecycleStatus: "paused",
|
|
439
|
+
});
|
|
440
|
+
this.stores.workflowCoordinator.upsertTrackedIssue({
|
|
441
|
+
projectId: stageRun.projectId,
|
|
442
|
+
linearIssueId: stageRun.linearIssueId,
|
|
443
|
+
...(fallbackState ? { currentLinearState: fallbackState } : linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
|
|
444
|
+
lifecycleStatus: "paused",
|
|
445
|
+
});
|
|
446
|
+
this.feed?.publish({
|
|
447
|
+
level: "warn",
|
|
448
|
+
kind: "workflow",
|
|
449
|
+
issueKey: trackedIssue?.issueKey,
|
|
450
|
+
projectId: stageRun.projectId,
|
|
451
|
+
stage: stageRun.stage,
|
|
452
|
+
...(trackedIssue?.selectedWorkflowId ? { workflowId: trackedIssue.selectedWorkflowId } : {}),
|
|
453
|
+
status: "transition_suppressed",
|
|
454
|
+
summary: `Paused after ${stageRun.stage}`,
|
|
455
|
+
detail: reason,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
233
458
|
finishLedgerRun(projectId, linearIssueId, status, params) {
|
|
234
459
|
const issueControl = this.stores.issueControl.getIssueControl(projectId, linearIssueId);
|
|
235
460
|
if (!issueControl?.activeRunLeaseId) {
|
|
@@ -360,6 +585,7 @@ export class ServiceStageFinalizer {
|
|
|
360
585
|
issueKey: issue?.issueKey,
|
|
361
586
|
projectId: stageRun.projectId,
|
|
362
587
|
stage: stageRun.stage,
|
|
588
|
+
...(issue?.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
|
|
363
589
|
status: "running",
|
|
364
590
|
summary: `Recovered ${stageRun.stage} workflow after restart`,
|
|
365
591
|
detail: `Turn ${turn.turnId} resumed on the existing thread.`,
|
|
@@ -450,3 +676,18 @@ function buildRestartRecoveryPrompt(stage) {
|
|
|
450
676
|
"When the work is actually complete, finish the normal workflow handoff for this stage.",
|
|
451
677
|
].join("\n");
|
|
452
678
|
}
|
|
679
|
+
function normalizeLinearState(value) {
|
|
680
|
+
const trimmed = value?.trim();
|
|
681
|
+
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
682
|
+
}
|
|
683
|
+
function resolveDoneLinearState(issue) {
|
|
684
|
+
const typedMatch = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
|
|
685
|
+
if (typedMatch?.name) {
|
|
686
|
+
return typedMatch.name;
|
|
687
|
+
}
|
|
688
|
+
const nameMatch = issue.workflowStates.find((state) => {
|
|
689
|
+
const normalized = normalizeLinearState(state.name);
|
|
690
|
+
return normalized === "done" || normalized === "completed" || normalized === "complete";
|
|
691
|
+
});
|
|
692
|
+
return nameMatch?.name;
|
|
693
|
+
}
|