patchrelay 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +64 -62
  2. package/dist/agent-session-plan.js +17 -17
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/commands/issues.js +12 -12
  5. package/dist/cli/data.js +109 -298
  6. package/dist/cli/formatters/text.js +22 -28
  7. package/dist/config.js +13 -166
  8. package/dist/db/migrations.js +46 -154
  9. package/dist/db.js +369 -45
  10. package/dist/factory-state.js +55 -0
  11. package/dist/github-webhook-handler.js +199 -0
  12. package/dist/github-webhooks.js +166 -0
  13. package/dist/hook-runner.js +28 -0
  14. package/dist/http.js +48 -22
  15. package/dist/issue-query-service.js +33 -38
  16. package/dist/linear-workflow.js +5 -118
  17. package/dist/preflight.js +1 -6
  18. package/dist/project-resolution.js +12 -1
  19. package/dist/run-orchestrator.js +446 -0
  20. package/dist/{stage-reporting.js → run-reporting.js} +11 -13
  21. package/dist/service-runtime.js +12 -61
  22. package/dist/service-webhooks.js +7 -52
  23. package/dist/service.js +39 -61
  24. package/dist/webhook-handler.js +387 -0
  25. package/dist/webhook-installation-handler.js +3 -8
  26. package/package.json +2 -1
  27. package/dist/db/authoritative-ledger-store.js +0 -536
  28. package/dist/db/issue-projection-store.js +0 -54
  29. package/dist/db/issue-workflow-coordinator.js +0 -320
  30. package/dist/db/issue-workflow-store.js +0 -194
  31. package/dist/db/run-report-store.js +0 -33
  32. package/dist/db/stage-event-store.js +0 -33
  33. package/dist/db/webhook-event-store.js +0 -59
  34. package/dist/db-ports.js +0 -5
  35. package/dist/ledger-ports.js +0 -1
  36. package/dist/reconciliation-action-applier.js +0 -68
  37. package/dist/reconciliation-actions.js +0 -1
  38. package/dist/reconciliation-engine.js +0 -350
  39. package/dist/reconciliation-snapshot-builder.js +0 -135
  40. package/dist/reconciliation-types.js +0 -1
  41. package/dist/service-stage-finalizer.js +0 -753
  42. package/dist/service-stage-runner.js +0 -336
  43. package/dist/service-webhook-processor.js +0 -411
  44. package/dist/stage-agent-activity-publisher.js +0 -59
  45. package/dist/stage-event-ports.js +0 -1
  46. package/dist/stage-failure.js +0 -92
  47. package/dist/stage-handoff.js +0 -107
  48. package/dist/stage-launch.js +0 -84
  49. package/dist/stage-lifecycle-publisher.js +0 -284
  50. package/dist/stage-turn-input-dispatcher.js +0 -104
  51. package/dist/webhook-agent-session-handler.js +0 -228
  52. package/dist/webhook-comment-handler.js +0 -141
  53. package/dist/webhook-desired-stage-recorder.js +0 -122
  54. package/dist/webhook-event-ports.js +0 -1
  55. package/dist/workflow-policy.js +0 -149
  56. package/dist/workflow-ports.js +0 -1
  57. /package/dist/{installation-ports.js → github-types.js} +0 -0
@@ -1,26 +1,7 @@
1
- import { resolveWorkflowStageConfig } from "./workflow-policy.js";
2
- const STATUS_MARKER = "<!-- patchrelay:status-comment -->";
3
1
  function normalizeLinearState(value) {
4
2
  const trimmed = value?.trim();
5
3
  return trimmed ? trimmed.toLowerCase() : undefined;
6
4
  }
7
- export function resolveActiveLinearState(project, stage, workflowDefinitionId) {
8
- return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.activeState;
9
- }
10
- export function resolveFallbackLinearState(project, stage, workflowDefinitionId) {
11
- return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.fallbackState;
12
- }
13
- export function resolveDoneLinearState(issue) {
14
- const typedMatch = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
15
- if (typedMatch?.name) {
16
- return typedMatch.name;
17
- }
18
- const nameMatch = issue.workflowStates.find((state) => {
19
- const normalized = normalizeLinearState(state.name);
20
- return normalized === "done" || normalized === "completed" || normalized === "complete";
21
- });
22
- return nameMatch?.name;
23
- }
24
5
  export function resolveAuthoritativeLinearStopState(issue) {
25
6
  const currentStateName = issue.stateName?.trim();
26
7
  const normalizedCurrentState = normalizeLinearState(currentStateName);
@@ -29,107 +10,13 @@ export function resolveAuthoritativeLinearStopState(issue) {
29
10
  }
30
11
  const currentWorkflowState = issue.workflowStates.find((state) => normalizeLinearState(state.name) === normalizedCurrentState);
31
12
  if (normalizeLinearState(currentWorkflowState?.type) === "completed") {
32
- return {
33
- stateName: currentWorkflowState?.name ?? currentStateName,
34
- lifecycleStatus: "completed",
35
- };
36
- }
37
- if (normalizedCurrentState === "human needed") {
38
- return {
39
- stateName: currentStateName,
40
- lifecycleStatus: "paused",
41
- };
13
+ return { stateName: currentWorkflowState?.name ?? currentStateName, isFinal: true };
42
14
  }
43
15
  if (normalizedCurrentState === "done" || normalizedCurrentState === "completed" || normalizedCurrentState === "complete") {
44
- return {
45
- stateName: currentStateName,
46
- lifecycleStatus: "completed",
47
- };
16
+ return { stateName: currentStateName, isFinal: true };
48
17
  }
49
- return undefined;
50
- }
51
- export function buildRunningStatusComment(params) {
52
- return [
53
- STATUS_MARKER,
54
- `PatchRelay is running the ${params.stageRun.stage} workflow.`,
55
- "",
56
- `- Issue: \`${params.issue.issueKey ?? params.issue.linearIssueId}\``,
57
- `- Workflow: \`${params.stageRun.stage}\``,
58
- `- Branch: \`${params.branchName}\``,
59
- `- Thread: \`${params.stageRun.threadId ?? "starting"}\``,
60
- `- Turn: \`${params.stageRun.turnId ?? "starting"}\``,
61
- `- Started: \`${params.stageRun.startedAt}\``,
62
- "- Status: `working`",
63
- ].join("\n");
64
- }
65
- export function buildAwaitingHandoffComment(params) {
66
- return [
67
- STATUS_MARKER,
68
- `PatchRelay finished the ${params.stageRun.stage} workflow, but Linear is still in \`${params.activeState}\`.`,
69
- "",
70
- `- Issue: \`${params.issue.issueKey ?? params.issue.linearIssueId}\``,
71
- `- Workflow: \`${params.stageRun.stage}\``,
72
- `- Thread: \`${params.stageRun.threadId ?? "unknown"}\``,
73
- `- Turn: \`${params.stageRun.turnId ?? "unknown"}\``,
74
- `- Completed: \`${params.stageRun.endedAt ?? new Date().toISOString()}\``,
75
- "- Status: `awaiting-final-state`",
76
- "",
77
- "The workflow likely finished without moving the issue to its next Linear state. Please review the thread report and update the issue state.",
78
- ].join("\n");
79
- }
80
- export function buildHumanNeededComment(params) {
81
- return [
82
- STATUS_MARKER,
83
- `PatchRelay finished the ${params.stageRun.stage} workflow and now needs human input.`,
84
- "",
85
- `- Issue: \`${params.issue.issueKey ?? params.issue.linearIssueId}\``,
86
- `- Workflow: \`${params.stageRun.stage}\``,
87
- `- Thread: \`${params.stageRun.threadId ?? "unknown"}\``,
88
- `- Turn: \`${params.stageRun.turnId ?? "unknown"}\``,
89
- `- Completed: \`${params.stageRun.endedAt ?? new Date().toISOString()}\``,
90
- "- Status: `human-needed`",
91
- "",
92
- "Review the stage report, decide the right next workflow step, and move or re-prompt the issue when ready.",
93
- ].join("\n");
94
- }
95
- export function buildStageFailedComment(params) {
96
- const mode = params.mode ?? "launch";
97
- return [
98
- STATUS_MARKER,
99
- mode === "launch"
100
- ? `PatchRelay could not start the ${params.stageRun.stage} workflow.`
101
- : `PatchRelay marked the ${params.stageRun.stage} workflow as failed.`,
102
- "",
103
- `- Issue: \`${params.issue.issueKey ?? params.issue.linearIssueId}\``,
104
- `- Workflow: \`${params.stageRun.stage}\``,
105
- `- Started: \`${params.stageRun.startedAt}\``,
106
- `- Failure: \`${params.message}\``,
107
- `- Recommended state: \`${params.fallbackState ?? "Human Needed"}\``,
108
- mode === "launch" ? "- Status: `launch-failed`" : "- Status: `stage-failed`",
109
- ].join("\n");
110
- }
111
- export function isPatchRelayStatusComment(commentId, body, trackedCommentId) {
112
- if (trackedCommentId && commentId === trackedCommentId) {
113
- return true;
114
- }
115
- return typeof body === "string" && body.includes(STATUS_MARKER);
116
- }
117
- export function resolveWorkflowLabelNames(project, mode) {
118
- const working = project.workflowLabels?.working;
119
- const awaitingHandoff = project.workflowLabels?.awaitingHandoff;
120
- if (mode === "working") {
121
- return {
122
- add: working ? [working] : [],
123
- remove: awaitingHandoff ? [awaitingHandoff] : [],
124
- };
18
+ if (normalizedCurrentState === "human needed") {
19
+ return { stateName: currentStateName, isFinal: false };
125
20
  }
126
- return {
127
- add: awaitingHandoff ? [awaitingHandoff] : [],
128
- remove: working ? [working] : [],
129
- };
130
- }
131
- export function resolveWorkflowLabelCleanup(project) {
132
- return {
133
- remove: [project.workflowLabels?.working, project.workflowLabels?.awaitingHandoff].filter((value) => Boolean(value)),
134
- };
21
+ return undefined;
135
22
  }
package/dist/preflight.js CHANGED
@@ -3,7 +3,6 @@ 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";
7
6
  export async function runPreflight(config) {
8
7
  const checks = [];
9
8
  if (!config.linear.webhookSecret) {
@@ -79,11 +78,7 @@ export async function runPreflight(config) {
79
78
  for (const project of config.projects) {
80
79
  checks.push(...checkPath(`project:${project.id}:repo`, project.repoPath, "directory", { writable: true }));
81
80
  checks.push(...checkPath(`project:${project.id}:worktrees`, project.worktreeRoot, "directory", { createIfMissing: true, writable: true }));
82
- for (const definition of listProjectWorkflowDefinitions(project)) {
83
- for (const workflow of definition.stages) {
84
- checks.push(...checkPath(`project:${project.id}:workflow:${definition.id}:${workflow.id}`, workflow.workflowFile, "file", {}));
85
- }
86
- }
81
+ // Workflow file checks removed — factory state machine replaces workflow definitions
87
82
  }
88
83
  checks.push(await checkExecutable("git", config.runner.gitBin));
89
84
  checks.push(await checkExecutable("codex", config.runner.codex.bin));
@@ -1,4 +1,15 @@
1
- import { matchesProject } from "./workflow-policy.js";
1
+ function matchesProject(issue, project) {
2
+ if (project.issueKeyPrefixes.length > 0 && issue.identifier) {
3
+ const prefix = issue.identifier.split("-")[0];
4
+ if (prefix && project.issueKeyPrefixes.includes(prefix))
5
+ return true;
6
+ }
7
+ if (project.linearTeamIds.length > 0 && issue.teamId) {
8
+ if (project.linearTeamIds.includes(issue.teamId))
9
+ return true;
10
+ }
11
+ return false;
12
+ }
2
13
  export function resolveProject(config, issue) {
3
14
  const matches = config.projects.filter((project) => matchesProject(issue, project));
4
15
  if (matches.length === 1) {
@@ -0,0 +1,446 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { buildHookEnv, runProjectHook } from "./hook-runner.js";
4
+ import { buildRunningSessionPlan, buildCompletedSessionPlan, buildFailedSessionPlan, } from "./agent-session-plan.js";
5
+ import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
6
+ import { WorktreeManager } from "./worktree-manager.js";
7
+ import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
8
+ const DEFAULT_CI_REPAIR_BUDGET = 2;
9
+ const DEFAULT_QUEUE_REPAIR_BUDGET = 2;
10
+ function slugify(value) {
11
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
12
+ }
13
+ function sanitizePathSegment(value) {
14
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
15
+ }
16
+ const WORKFLOW_FILES = {
17
+ implementation: "IMPLEMENTATION_WORKFLOW.md",
18
+ review_fix: "REVIEW_WORKFLOW.md",
19
+ ci_repair: "IMPLEMENTATION_WORKFLOW.md",
20
+ queue_repair: "IMPLEMENTATION_WORKFLOW.md",
21
+ };
22
+ function readWorkflowFile(repoPath, runType) {
23
+ const filename = WORKFLOW_FILES[runType];
24
+ const filePath = path.join(repoPath, filename);
25
+ if (!existsSync(filePath))
26
+ return undefined;
27
+ return readFileSync(filePath, "utf8").trim();
28
+ }
29
+ function buildRunPrompt(issue, runType, repoPath, context) {
30
+ const lines = [
31
+ `Issue: ${issue.issueKey ?? issue.linearIssueId}`,
32
+ issue.title ? `Title: ${issue.title}` : undefined,
33
+ `Branch: ${issue.branchName}`,
34
+ issue.prNumber ? `PR: #${issue.prNumber}` : undefined,
35
+ "",
36
+ ].filter(Boolean);
37
+ // Add run-type-specific context for reactive runs
38
+ switch (runType) {
39
+ case "ci_repair":
40
+ lines.push("## CI Repair", "", "A CI check has failed on your PR. Fix the failure and push.", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", "", "Read the CI failure logs, fix the code issue, run verification, commit and push.", "Do not change test expectations unless the test is genuinely wrong.", "");
41
+ break;
42
+ case "review_fix":
43
+ lines.push("## Review Changes Requested", "", "A reviewer has requested changes on your PR. Address the feedback and push.", context?.reviewerName ? `Reviewer: ${String(context.reviewerName)}` : "", context?.reviewBody ? `\n## Review comment\n\n${String(context.reviewBody)}` : "", "", "Read the review feedback and PR comments (`gh pr view --comments`), address each point, run verification, commit and push.", "");
44
+ break;
45
+ case "queue_repair":
46
+ lines.push("## Merge Queue Failure", "", "The merge queue rejected this PR. Rebase onto latest main and fix conflicts.", context?.failureReason ? `Failure reason: ${String(context.failureReason)}` : "", "", "Fetch and rebase onto latest main, resolve conflicts, run verification, push.", "If the conflict is a semantic contradiction, explain and stop.", "");
47
+ break;
48
+ }
49
+ // Append the repo's workflow file
50
+ const workflowBody = readWorkflowFile(repoPath, runType);
51
+ if (workflowBody) {
52
+ lines.push(workflowBody);
53
+ }
54
+ else if (runType === "implementation") {
55
+ // Fallback if no workflow file exists
56
+ lines.push("Implement the Linear issue. Read the issue via MCP for details.", "Run verification before finishing. Commit, push, and open a PR.");
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+ export class RunOrchestrator {
61
+ config;
62
+ db;
63
+ codex;
64
+ linearProvider;
65
+ enqueueIssue;
66
+ logger;
67
+ feed;
68
+ worktreeManager;
69
+ constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
70
+ this.config = config;
71
+ this.db = db;
72
+ this.codex = codex;
73
+ this.linearProvider = linearProvider;
74
+ this.enqueueIssue = enqueueIssue;
75
+ this.logger = logger;
76
+ this.feed = feed;
77
+ this.worktreeManager = new WorktreeManager(config);
78
+ }
79
+ // ─── Run ────────────────────────────────────────────────────────
80
+ async run(item) {
81
+ const project = this.config.projects.find((p) => p.id === item.projectId);
82
+ if (!project)
83
+ return;
84
+ const issue = this.db.getIssue(item.projectId, item.issueId);
85
+ if (!issue?.pendingRunType || issue.activeRunId !== undefined)
86
+ return;
87
+ const runType = issue.pendingRunType;
88
+ const contextJson = issue.pendingRunContextJson;
89
+ const context = contextJson ? JSON.parse(contextJson) : undefined;
90
+ // Check repair budgets
91
+ if (runType === "ci_repair" && issue.ciRepairAttempts >= DEFAULT_CI_REPAIR_BUDGET) {
92
+ this.escalate(issue, runType, `CI repair budget exhausted (${DEFAULT_CI_REPAIR_BUDGET} attempts)`);
93
+ return;
94
+ }
95
+ if (runType === "queue_repair" && issue.queueRepairAttempts >= DEFAULT_QUEUE_REPAIR_BUDGET) {
96
+ this.escalate(issue, runType, `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`);
97
+ return;
98
+ }
99
+ // Increment repair counters
100
+ if (runType === "ci_repair") {
101
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, ciRepairAttempts: issue.ciRepairAttempts + 1 });
102
+ }
103
+ if (runType === "queue_repair") {
104
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueRepairAttempts: issue.queueRepairAttempts + 1 });
105
+ }
106
+ // Build prompt
107
+ const prompt = buildRunPrompt(issue, runType, project.repoPath, context);
108
+ // Resolve workspace
109
+ const issueRef = sanitizePathSegment(issue.issueKey ?? issue.linearIssueId);
110
+ const slug = issue.title ? slugify(issue.title) : "";
111
+ const branchSuffix = slug ? `${issueRef}-${slug}` : issueRef;
112
+ const branchName = issue.branchName ?? `${project.branchPrefix}/${branchSuffix}`;
113
+ const worktreePath = issue.worktreePath ?? `${project.worktreeRoot}/${issueRef}`;
114
+ // Claim the run atomically
115
+ const run = this.db.transaction(() => {
116
+ const fresh = this.db.getIssue(item.projectId, item.issueId);
117
+ if (!fresh?.pendingRunType || fresh.activeRunId !== undefined)
118
+ return undefined;
119
+ const created = this.db.createRun({
120
+ issueId: fresh.id,
121
+ projectId: item.projectId,
122
+ linearIssueId: item.issueId,
123
+ runType,
124
+ promptText: prompt,
125
+ });
126
+ this.db.upsertIssue({
127
+ projectId: item.projectId,
128
+ linearIssueId: item.issueId,
129
+ pendingRunType: null,
130
+ pendingRunContextJson: null,
131
+ activeRunId: created.id,
132
+ branchName,
133
+ worktreePath,
134
+ factoryState: runType === "implementation" ? "implementing"
135
+ : runType === "ci_repair" ? "repairing_ci"
136
+ : runType === "review_fix" ? "changes_requested"
137
+ : runType === "queue_repair" ? "repairing_queue"
138
+ : "implementing",
139
+ });
140
+ return created;
141
+ });
142
+ if (!run)
143
+ return;
144
+ this.feed?.publish({
145
+ level: "info",
146
+ kind: "stage",
147
+ issueKey: issue.issueKey,
148
+ projectId: item.projectId,
149
+ stage: runType,
150
+ status: "starting",
151
+ summary: `Starting ${runType} run`,
152
+ });
153
+ let threadId;
154
+ let turnId;
155
+ try {
156
+ // Ensure worktree
157
+ await this.worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktreePath, branchName, { allowExistingOutsideRoot: issue.branchName !== undefined });
158
+ // Run prepare-worktree hook
159
+ const hookEnv = buildHookEnv(issue.issueKey ?? issue.linearIssueId, branchName, runType, worktreePath);
160
+ const prepareResult = await runProjectHook(project.repoPath, "prepare-worktree", { cwd: worktreePath, env: hookEnv });
161
+ if (prepareResult.ran && prepareResult.exitCode !== 0) {
162
+ throw new Error(`prepare-worktree hook failed (exit ${prepareResult.exitCode}): ${prepareResult.stderr?.slice(0, 500) ?? ""}`);
163
+ }
164
+ // Start or reuse Codex thread
165
+ if (issue.threadId && runType !== "implementation") {
166
+ threadId = issue.threadId;
167
+ }
168
+ else {
169
+ const thread = await this.codex.startThread({ cwd: worktreePath });
170
+ threadId = thread.id;
171
+ this.db.upsertIssue({ projectId: item.projectId, linearIssueId: item.issueId, threadId });
172
+ }
173
+ const turn = await this.codex.startTurn({ threadId, cwd: worktreePath, input: prompt });
174
+ turnId = turn.turnId;
175
+ }
176
+ catch (error) {
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ this.db.finishRun(run.id, { status: "failed", failureReason: message });
179
+ this.db.upsertIssue({
180
+ projectId: item.projectId,
181
+ linearIssueId: item.issueId,
182
+ activeRunId: null,
183
+ factoryState: "failed",
184
+ });
185
+ this.logger.error({ issueKey: issue.issueKey, runType, error: message }, `Failed to launch ${runType} run`);
186
+ void this.emitLinearActivity(issue, "error", `Failed to start ${runType}: ${message}`);
187
+ void this.updateLinearPlan(issue, buildFailedSessionPlan(runType));
188
+ throw error;
189
+ }
190
+ this.db.updateRunThread(run.id, { threadId, turnId });
191
+ this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
192
+ // Emit Linear activity + plan
193
+ const freshIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
194
+ void this.emitLinearActivity(freshIssue, "thought", `Started ${runType} run.`, { ephemeral: true });
195
+ void this.updateLinearPlan(freshIssue, buildRunningSessionPlan(runType));
196
+ }
197
+ // ─── Notification handler ─────────────────────────────────────────
198
+ async handleCodexNotification(notification) {
199
+ const threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
200
+ if (!threadId)
201
+ return;
202
+ const run = this.db.getRunByThreadId(threadId);
203
+ if (!run)
204
+ return;
205
+ const turnId = typeof notification.params.turnId === "string" ? notification.params.turnId : undefined;
206
+ if (this.config.runner.codex.persistExtendedHistory) {
207
+ this.db.saveThreadEvent({
208
+ runId: run.id,
209
+ threadId,
210
+ ...(turnId ? { turnId } : {}),
211
+ method: notification.method,
212
+ eventJson: JSON.stringify(notification.params),
213
+ });
214
+ }
215
+ if (notification.method !== "turn/completed")
216
+ return;
217
+ const thread = await this.readThreadWithRetry(threadId);
218
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
219
+ if (!issue)
220
+ return;
221
+ const completedTurnId = extractTurnId(notification.params);
222
+ const status = resolveRunCompletionStatus(notification.params);
223
+ if (status === "failed") {
224
+ this.db.finishRun(run.id, {
225
+ status: "failed",
226
+ threadId,
227
+ ...(completedTurnId ? { turnId: completedTurnId } : {}),
228
+ failureReason: "Codex reported the turn completed in a failed state",
229
+ });
230
+ this.db.upsertIssue({
231
+ projectId: run.projectId,
232
+ linearIssueId: run.linearIssueId,
233
+ activeRunId: null,
234
+ factoryState: "failed",
235
+ });
236
+ this.feed?.publish({
237
+ level: "error",
238
+ kind: "turn",
239
+ issueKey: issue.issueKey,
240
+ projectId: run.projectId,
241
+ stage: run.runType,
242
+ status: "failed",
243
+ summary: `Turn failed for ${run.runType}`,
244
+ });
245
+ void this.emitLinearActivity(issue, "error", `${run.runType} run failed.`);
246
+ void this.updateLinearPlan(issue, buildFailedSessionPlan(run.runType, run));
247
+ return;
248
+ }
249
+ // Complete the run
250
+ const trackedIssue = this.db.issueToTrackedIssue(issue);
251
+ const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
252
+ this.db.transaction(() => {
253
+ this.db.finishRun(run.id, {
254
+ status: "completed",
255
+ threadId,
256
+ ...(completedTurnId ? { turnId: completedTurnId } : {}),
257
+ summaryJson: JSON.stringify({ latestAssistantMessage: report.assistantMessages.at(-1) ?? null }),
258
+ reportJson: JSON.stringify(report),
259
+ });
260
+ this.db.upsertIssue({
261
+ projectId: run.projectId,
262
+ linearIssueId: run.linearIssueId,
263
+ activeRunId: null,
264
+ });
265
+ });
266
+ this.feed?.publish({
267
+ level: "info",
268
+ kind: "turn",
269
+ issueKey: issue.issueKey,
270
+ projectId: run.projectId,
271
+ stage: run.runType,
272
+ status: "completed",
273
+ summary: `Turn completed for ${run.runType}`,
274
+ detail: summarizeCurrentThread(thread).latestAgentMessage,
275
+ });
276
+ // Emit Linear completion activity + plan
277
+ const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
278
+ const prInfo = issue.prNumber ? ` PR #${issue.prNumber}` : "";
279
+ void this.emitLinearActivity(issue, "response", `${run.runType} completed.${prInfo}\n\n${completionSummary}`);
280
+ void this.updateLinearPlan(issue, buildCompletedSessionPlan(run.runType));
281
+ }
282
+ // ─── Active status for query ──────────────────────────────────────
283
+ async getActiveRunStatus(issueKey) {
284
+ const issue = this.db.getIssueByKey(issueKey);
285
+ if (!issue?.activeRunId)
286
+ return undefined;
287
+ const run = this.db.getRun(issue.activeRunId);
288
+ if (!run?.threadId)
289
+ return undefined;
290
+ const trackedIssue = this.db.issueToTrackedIssue(issue);
291
+ const thread = await this.codex.readThread(run.threadId, true).catch(() => undefined);
292
+ return {
293
+ issue: trackedIssue,
294
+ run,
295
+ ...(thread ? { liveThread: summarizeCurrentThread(thread) } : {}),
296
+ };
297
+ }
298
+ // ─── Reconciliation ───────────────────────────────────────────────
299
+ async reconcileActiveRuns() {
300
+ for (const run of this.db.listRunningRuns()) {
301
+ await this.reconcileRun(run);
302
+ }
303
+ }
304
+ async reconcileRun(run) {
305
+ if (!run.threadId) {
306
+ this.failRunAndClear(run, "Run has no thread ID during reconciliation");
307
+ return;
308
+ }
309
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
310
+ if (!issue)
311
+ return;
312
+ // Read Codex state
313
+ let thread;
314
+ try {
315
+ thread = await this.readThreadWithRetry(run.threadId);
316
+ }
317
+ catch {
318
+ this.failRunAndClear(run, "Codex thread not found during reconciliation");
319
+ return;
320
+ }
321
+ // Check Linear state
322
+ const linear = await this.linearProvider.forProject(run.projectId);
323
+ if (linear) {
324
+ const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
325
+ if (linearIssue) {
326
+ const stopState = resolveAuthoritativeLinearStopState(linearIssue);
327
+ if (stopState?.isFinal) {
328
+ this.db.transaction(() => {
329
+ this.db.finishRun(run.id, { status: "released" });
330
+ this.db.upsertIssue({
331
+ projectId: run.projectId,
332
+ linearIssueId: run.linearIssueId,
333
+ activeRunId: null,
334
+ currentLinearState: stopState.stateName,
335
+ factoryState: "done",
336
+ });
337
+ });
338
+ return;
339
+ }
340
+ }
341
+ }
342
+ const latestTurn = thread.turns.at(-1);
343
+ // Handle interrupted turn — fail the run rather than retrying indefinitely.
344
+ // The agent may have partially completed work (commits, PR) before interruption.
345
+ // Reactive loops (CI repair, review fix) will handle follow-up if needed.
346
+ if (latestTurn?.status === "interrupted") {
347
+ this.logger.warn({ issueKey: issue.issueKey, runType: run.runType, threadId: run.threadId }, "Run has interrupted turn — marking as failed");
348
+ this.failRunAndClear(run, "Codex turn was interrupted");
349
+ void this.emitLinearActivity(issue, "error", `${run.runType} run was interrupted.`);
350
+ return;
351
+ }
352
+ // Handle completed turn discovered during reconciliation
353
+ if (latestTurn?.status === "completed") {
354
+ const trackedIssue = this.db.issueToTrackedIssue(issue);
355
+ const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
356
+ this.db.transaction(() => {
357
+ this.db.finishRun(run.id, {
358
+ status: "completed",
359
+ ...(run.threadId ? { threadId: run.threadId } : {}),
360
+ ...(latestTurn.id ? { turnId: latestTurn.id } : {}),
361
+ summaryJson: JSON.stringify({ latestAssistantMessage: report.assistantMessages.at(-1) ?? null }),
362
+ reportJson: JSON.stringify(report),
363
+ });
364
+ this.db.upsertIssue({
365
+ projectId: run.projectId,
366
+ linearIssueId: run.linearIssueId,
367
+ activeRunId: null,
368
+ });
369
+ });
370
+ }
371
+ }
372
+ // ─── Internal helpers ─────────────────────────────────────────────
373
+ escalate(issue, runType, reason) {
374
+ this.logger.warn({ issueKey: issue.issueKey, runType, reason }, "Escalating to human");
375
+ this.db.upsertIssue({
376
+ projectId: issue.projectId,
377
+ linearIssueId: issue.linearIssueId,
378
+ pendingRunType: null,
379
+ pendingRunContextJson: null,
380
+ factoryState: "escalated",
381
+ });
382
+ this.feed?.publish({
383
+ level: "error",
384
+ kind: "workflow",
385
+ issueKey: issue.issueKey,
386
+ projectId: issue.projectId,
387
+ stage: runType,
388
+ status: "escalated",
389
+ summary: `Escalated: ${reason}`,
390
+ });
391
+ }
392
+ failRunAndClear(run, message) {
393
+ this.db.transaction(() => {
394
+ this.db.finishRun(run.id, { status: "failed", failureReason: message });
395
+ this.db.upsertIssue({
396
+ projectId: run.projectId,
397
+ linearIssueId: run.linearIssueId,
398
+ activeRunId: null,
399
+ factoryState: "failed",
400
+ });
401
+ });
402
+ }
403
+ async emitLinearActivity(issue, type, body, options) {
404
+ if (!issue.agentSessionId)
405
+ return;
406
+ const linear = await this.linearProvider.forProject(issue.projectId);
407
+ if (!linear)
408
+ return;
409
+ try {
410
+ await linear.createAgentActivity({
411
+ agentSessionId: issue.agentSessionId,
412
+ content: { type, body },
413
+ ...(options?.ephemeral ? { ephemeral: true } : {}),
414
+ });
415
+ }
416
+ catch (error) {
417
+ this.logger.debug({ issueKey: issue.issueKey, type, error: error instanceof Error ? error.message : String(error) }, "Failed to emit Linear activity (non-blocking)");
418
+ }
419
+ }
420
+ async updateLinearPlan(issue, plan) {
421
+ if (!issue.agentSessionId)
422
+ return;
423
+ const linear = await this.linearProvider.forProject(issue.projectId);
424
+ if (!linear?.updateAgentSession)
425
+ return;
426
+ try {
427
+ await linear.updateAgentSession({ agentSessionId: issue.agentSessionId, plan });
428
+ }
429
+ catch (error) {
430
+ this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to update Linear plan (non-blocking)");
431
+ }
432
+ }
433
+ async readThreadWithRetry(threadId, maxRetries = 3) {
434
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
435
+ try {
436
+ return await this.codex.readThread(threadId, true);
437
+ }
438
+ catch {
439
+ if (attempt === maxRetries - 1)
440
+ throw new Error(`Failed to read thread ${threadId} after ${maxRetries} attempts`);
441
+ await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
442
+ }
443
+ }
444
+ throw new Error(`Failed to read thread ${threadId}`);
445
+ }
446
+ }
@@ -19,7 +19,7 @@ export function summarizeCurrentThread(thread) {
19
19
  ...(latestAgentMessage ? { latestAgentMessage } : {}),
20
20
  };
21
21
  }
22
- export function buildStageReport(stageRun, issue, thread, eventCounts) {
22
+ export function buildStageReport(run, issue, thread, eventCounts) {
23
23
  const assistantMessages = [];
24
24
  const plans = [];
25
25
  const reasoning = [];
@@ -78,13 +78,12 @@ export function buildStageReport(stageRun, issue, thread, eventCounts) {
78
78
  }
79
79
  return {
80
80
  ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
81
- stage: stageRun.stage,
82
- status: stageRun.status,
83
- ...(stageRun.threadId ? { threadId: stageRun.threadId } : {}),
84
- ...(stageRun.parentThreadId ? { parentThreadId: stageRun.parentThreadId } : {}),
85
- ...(stageRun.turnId ? { turnId: stageRun.turnId } : {}),
86
- prompt: stageRun.promptText,
87
- workflowFile: stageRun.workflowFile,
81
+ runType: run.runType,
82
+ status: run.status,
83
+ ...(run.threadId ? { threadId: run.threadId } : {}),
84
+ ...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
85
+ ...(run.turnId ? { turnId: run.turnId } : {}),
86
+ prompt: run.promptText ?? "",
88
87
  assistantMessages,
89
88
  plans,
90
89
  reasoning,
@@ -94,14 +93,13 @@ export function buildStageReport(stageRun, issue, thread, eventCounts) {
94
93
  eventCounts,
95
94
  };
96
95
  }
97
- export function buildFailedStageReport(stageRun, status, options) {
96
+ export function buildFailedStageReport(run, status, options) {
98
97
  return {
99
- stage: stageRun.stage,
98
+ runType: run.runType,
100
99
  status,
101
100
  ...(options?.threadId ? { threadId: options.threadId } : {}),
102
101
  ...(options?.turnId ? { turnId: options.turnId } : {}),
103
- prompt: stageRun.promptText,
104
- workflowFile: stageRun.workflowFile,
102
+ prompt: run.promptText ?? "",
105
103
  assistantMessages: [],
106
104
  plans: [],
107
105
  reasoning: [],
@@ -117,7 +115,7 @@ export function countEventMethods(events) {
117
115
  return counts;
118
116
  }, {});
119
117
  }
120
- export function resolveStageRunStatus(params) {
118
+ export function resolveRunCompletionStatus(params) {
121
119
  const turn = params.turn;
122
120
  if (!turn || typeof turn !== "object") {
123
121
  return "failed";