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.
@@ -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
- ...(issueKey ? { issueKey } : {}),
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 (issueKey && event.issueKey !== issueKey) {
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
  }
@@ -1,10 +1,10 @@
1
- import { resolveWorkflowById } from "./workflow-policy.js";
1
+ import { resolveWorkflowStageConfig } from "./workflow-policy.js";
2
2
  const STATUS_MARKER = "<!-- patchrelay:status-comment -->";
3
- export function resolveActiveLinearState(project, stage) {
4
- return resolveWorkflowById(project, stage)?.activeState;
3
+ export function resolveActiveLinearState(project, stage, workflowDefinitionId) {
4
+ return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.activeState;
5
5
  }
6
- export function resolveFallbackLinearState(project, stage) {
7
- return resolveWorkflowById(project, stage)?.fallbackState;
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 [
@@ -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 workflow of project.workflows) {
76
- checks.push(...checkPath(`project:${project.id}:workflow:${workflow.id}`, workflow.workflowFile, "file", {}));
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?.workflows.find((workflow) => workflow.id === runLease.stage);
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)
@@ -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
- constructor(codex, logger, stageRunReconciler, readyIssueSource, webhookProcessor, issueProcessor) {
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
+ }