patchrelay 0.8.9 → 0.9.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/README.md +64 -62
- package/dist/agent-session-plan.js +17 -17
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +1 -1
- package/dist/cli/commands/issues.js +18 -18
- package/dist/cli/data.js +109 -298
- package/dist/cli/formatters/text.js +22 -28
- package/dist/cli/help.js +7 -7
- package/dist/cli/index.js +3 -3
- package/dist/config.js +13 -166
- package/dist/db/migrations.js +46 -154
- package/dist/db.js +369 -45
- package/dist/factory-state.js +55 -0
- package/dist/github-webhook-handler.js +199 -0
- package/dist/github-webhooks.js +166 -0
- package/dist/hook-runner.js +28 -0
- package/dist/http.js +48 -22
- package/dist/issue-query-service.js +33 -38
- package/dist/linear-workflow.js +5 -118
- package/dist/preflight.js +1 -6
- package/dist/project-resolution.js +12 -1
- package/dist/run-orchestrator.js +446 -0
- package/dist/{stage-reporting.js → run-reporting.js} +11 -13
- package/dist/service-runtime.js +12 -61
- package/dist/service-webhooks.js +7 -52
- package/dist/service.js +39 -61
- package/dist/webhook-handler.js +387 -0
- package/dist/webhook-installation-handler.js +3 -8
- package/package.json +2 -1
- package/dist/db/authoritative-ledger-store.js +0 -536
- package/dist/db/issue-projection-store.js +0 -54
- package/dist/db/issue-workflow-coordinator.js +0 -320
- package/dist/db/issue-workflow-store.js +0 -194
- package/dist/db/run-report-store.js +0 -33
- package/dist/db/stage-event-store.js +0 -33
- package/dist/db/webhook-event-store.js +0 -59
- package/dist/db-ports.js +0 -5
- package/dist/ledger-ports.js +0 -1
- package/dist/reconciliation-action-applier.js +0 -68
- package/dist/reconciliation-actions.js +0 -1
- package/dist/reconciliation-engine.js +0 -350
- package/dist/reconciliation-snapshot-builder.js +0 -135
- package/dist/reconciliation-types.js +0 -1
- package/dist/service-stage-finalizer.js +0 -753
- package/dist/service-stage-runner.js +0 -336
- package/dist/service-webhook-processor.js +0 -411
- package/dist/stage-agent-activity-publisher.js +0 -59
- package/dist/stage-event-ports.js +0 -1
- package/dist/stage-failure.js +0 -92
- package/dist/stage-handoff.js +0 -107
- package/dist/stage-launch.js +0 -84
- package/dist/stage-lifecycle-publisher.js +0 -284
- package/dist/stage-turn-input-dispatcher.js +0 -104
- package/dist/webhook-agent-session-handler.js +0 -228
- package/dist/webhook-comment-handler.js +0 -141
- package/dist/webhook-desired-stage-recorder.js +0 -122
- package/dist/webhook-event-ports.js +0 -1
- package/dist/workflow-policy.js +0 -149
- package/dist/workflow-ports.js +0 -1
- /package/dist/{installation-ports.js → github-types.js} +0 -0
package/dist/linear-workflow.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
82
|
-
status:
|
|
83
|
-
...(
|
|
84
|
-
...(
|
|
85
|
-
...(
|
|
86
|
-
prompt:
|
|
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(
|
|
96
|
+
export function buildFailedStageReport(run, status, options) {
|
|
98
97
|
return {
|
|
99
|
-
|
|
98
|
+
runType: run.runType,
|
|
100
99
|
status,
|
|
101
100
|
...(options?.threadId ? { threadId: options.threadId } : {}),
|
|
102
101
|
...(options?.turnId ? { turnId: options.turnId } : {}),
|
|
103
|
-
prompt:
|
|
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
|
|
118
|
+
export function resolveRunCompletionStatus(params) {
|
|
121
119
|
const turn = params.turn;
|
|
122
120
|
if (!turn || typeof turn !== "object") {
|
|
123
121
|
return "failed";
|