patchrelay 0.35.11 → 0.35.13

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 (52) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +19 -1
  4. package/dist/cli/commands/issues.js +18 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +160 -47
  7. package/dist/cli/formatters/text.js +51 -90
  8. package/dist/cli/help.js +15 -8
  9. package/dist/cli/index.js +3 -58
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +21 -12
  12. package/dist/cli/watch/HelpBar.js +3 -3
  13. package/dist/cli/watch/IssueDetailView.js +63 -130
  14. package/dist/cli/watch/IssueRow.js +82 -27
  15. package/dist/cli/watch/StatusBar.js +8 -4
  16. package/dist/cli/watch/detail-rows.js +589 -0
  17. package/dist/cli/watch/render-rich-text.js +226 -0
  18. package/dist/cli/watch/state-visualization.js +48 -23
  19. package/dist/cli/watch/timeline-builder.js +2 -1
  20. package/dist/cli/watch/use-detail-stream.js +10 -104
  21. package/dist/cli/watch/use-watch-stream.js +11 -102
  22. package/dist/cli/watch/watch-state.js +129 -56
  23. package/dist/codex-thread-utils.js +3 -0
  24. package/dist/db/migrations.js +239 -2
  25. package/dist/db.js +628 -39
  26. package/dist/github-app-token.js +7 -0
  27. package/dist/github-failure-context.js +44 -1
  28. package/dist/github-rollup.js +47 -0
  29. package/dist/github-webhook-handler.js +423 -52
  30. package/dist/github-webhooks.js +7 -0
  31. package/dist/http.js +12 -264
  32. package/dist/idle-reconciliation.js +268 -76
  33. package/dist/issue-query-service.js +221 -129
  34. package/dist/issue-session-events.js +151 -0
  35. package/dist/issue-session.js +99 -0
  36. package/dist/linear-client.js +39 -25
  37. package/dist/linear-session-reporting.js +12 -0
  38. package/dist/linear-session-sync.js +253 -24
  39. package/dist/linear-workflow.js +33 -0
  40. package/dist/merge-queue-protocol.js +0 -51
  41. package/dist/preflight.js +1 -4
  42. package/dist/queue-health-monitor.js +11 -7
  43. package/dist/run-orchestrator.js +1364 -147
  44. package/dist/run-reporting.js +5 -3
  45. package/dist/service.js +279 -102
  46. package/dist/status-note.js +56 -0
  47. package/dist/waiting-reason.js +65 -0
  48. package/dist/webhook-handler.js +270 -79
  49. package/package.json +3 -2
  50. package/dist/cli/commands/feed.js +0 -60
  51. package/dist/cli/watch/FeedView.js +0 -28
  52. package/dist/cli/watch/use-feed-stream.js +0 -92
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # PatchRelay
2
2
 
3
- PatchRelay is a self-hosted harness for running a controlled coding loop per Linear issue on your own machine.
3
+ PatchRelay is a self-hosted harness for delegated Linear work and upkeep of PatchRelay-owned pull requests on your own machine.
4
4
 
5
- It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the whole issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair and review fixes. A separate [Merge Steward](./packages/merge-steward) service handles serial queue integration and landing.
5
+ It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge-steward incidents on PatchRelay-owned PRs. Separate downstream services own review automation and merge execution.
6
6
 
7
7
  PatchRelay is the system around the model:
8
8
 
@@ -38,10 +38,14 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
38
38
  - creates and reuses one durable worktree and branch per issue lifecycle
39
39
  - starts Codex threads for implementation runs
40
40
  - triggers reactive runs for CI failures, review feedback, and Merge Steward evictions
41
+ - opens and updates PatchRelay-owned PRs
42
+ - marks its own PRs ready when implementation is complete
41
43
  - persists enough state to correlate the Linear issue, local workspace, run, and Codex thread
42
44
  - reports progress back to Linear and forwards follow-up agent input into active runs
43
45
  - exposes CLI and optional read-only inspection surfaces so operators can understand what happened
44
46
 
47
+ PatchRelay does not own review decisions or queue admission. GitHub is the source of truth for PR readiness, `reviewbot` owns review automation, and [Merge Steward](./packages/merge-steward) owns queueing and merge execution.
48
+
45
49
  ## System Layers
46
50
 
47
51
  PatchRelay works best when read as five layers with clear ownership:
@@ -50,7 +54,7 @@ PatchRelay works best when read as five layers with clear ownership:
50
54
  - coordination layer: issue claiming, run scheduling, retry budgets, and reconciliation
51
55
  - execution layer: durable worktrees, Codex threads, and queued turn input delivery
52
56
  - integration layer: Linear webhooks, GitHub webhooks, OAuth, project routing, and state sync
53
- - observability layer: CLI inspection, reports, event trails, and operator endpoints
57
+ - observability layer: CLI inspection, session status, and operator endpoints
54
58
 
55
59
  That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps context, action, verification, and repair coordinated in a real repository with real operational state.
56
60
 
@@ -81,8 +85,26 @@ You will also need:
81
85
  3. Delegated issues create or reuse the issue worktree and launch an implementation run through `codex app-server`.
82
86
  4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
83
87
  5. GitHub webhooks drive reactive verification and repair loops: CI repair on check failures and review fix on changes requested.
84
- 6. When the PR is approved and CI is green, PatchRelay adds the `queue` label. Merge Steward takes over — rebasing, validating, and merging the PR. If the steward evicts the PR, PatchRelay triggers a queue repair run.
85
- 7. Native agent prompts and Linear comments can steer the active run. An operator can take over from the exact same worktree when needed.
88
+ 6. PatchRelay opens draft PRs while implementation is in progress and marks its own PR ready when implementation is complete.
89
+ 7. Downstream automation reacts to GitHub truth: `reviewbot` reviews ready PRs with green CI, and Merge Steward admits ready PRs with green CI and approval into the merge queue.
90
+ 8. If requested changes, red CI, or a merge-steward incident lands on a PatchRelay-owned PR, PatchRelay resumes work on that same PR branch.
91
+ 9. Native agent prompts and Linear comments can steer the active run. An operator can take over from the exact same worktree when needed.
92
+
93
+ ## Ownership Model
94
+
95
+ PatchRelay tracks two different kinds of ownership:
96
+
97
+ - issue ownership: who may start new delegated implementation work from Linear
98
+ - PR ownership: who is responsible for keeping an existing PR healthy until it merges or closes
99
+
100
+ For PatchRelay, PR ownership is determined by one concrete GitHub fact: a PR is PatchRelay-owned when its author is the PatchRelay GitHub app or service account.
101
+
102
+ That ownership does not change just because:
103
+
104
+ - the issue is undelegated
105
+ - the PR becomes ready for review
106
+ - the PR is approved
107
+ - the PR enters or leaves the merge queue
86
108
 
87
109
  ## Factory State Machine
88
110
 
@@ -106,6 +128,16 @@ Run types:
106
128
 
107
129
  PatchRelay treats these as distinct loop types with different context, entry conditions, and success criteria rather than as one generic "ask the agent again" workflow.
108
130
 
131
+ The long-term runtime model is a small durable `IssueSession`:
132
+
133
+ - `idle`
134
+ - `running`
135
+ - `waiting_input`
136
+ - `done`
137
+ - `failed`
138
+
139
+ Waiting on review or queue should be represented as a waiting reason, not as a large internal control-plane state machine.
140
+
109
141
  ## Restart And Reconciliation
110
142
 
111
143
  PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
@@ -269,14 +301,14 @@ Useful commands:
269
301
  - `patchrelay issue list --active`
270
302
  - `patchrelay issue show APP-123`
271
303
  - `patchrelay issue watch APP-123`
272
- - `patchrelay dashboard`
273
- - `patchrelay issue report APP-123`
274
- - `patchrelay issue events APP-123 --follow`
275
304
  - `patchrelay issue path APP-123 --cd`
276
305
  - `patchrelay issue open APP-123`
277
306
  - `patchrelay issue retry APP-123`
278
307
  - `patchrelay service logs --lines 100`
279
308
 
309
+ PatchRelay's operator surface is being reduced to its own runtime responsibilities: issue status,
310
+ active work, waiting reason, worktree handoff, and retry controls.
311
+
280
312
  `patchrelay issue open` is the handoff bridge: it opens Codex in the issue worktree and resumes the existing thread when PatchRelay has one.
281
313
 
282
314
  Today that takeover path is intentionally YOLO mode: it launches Codex with `--dangerously-bypass-approvals-and-sandbox`.
@@ -297,7 +329,7 @@ PatchRelay keeps enough durable state to answer the questions that matter during
297
329
 
298
330
  [Merge Steward](./packages/merge-steward) is a separate service that owns serial merge queue integration. PatchRelay develops code and produces pull requests. Merge Steward delivers those PRs into production — rebasing onto main, waiting for CI, and merging when green.
299
331
 
300
- The two services communicate through GitHub. PatchRelay adds a `queue` label when a PR is ready. The steward processes the queue. On failure, the steward evicts the PR with a check run report, and PatchRelay can trigger a queue repair run in response.
332
+ The two services communicate through GitHub. PatchRelay makes its own PR ready, and Merge Steward decides queue admission and merge execution from GitHub truth. On failure, the steward reports the incident through GitHub signals, and PatchRelay can trigger a queue repair run in response.
301
333
 
302
334
  The steward now has its own bootstrap flow:
303
335
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.35.11",
4
- "commit": "77fedbcefb07",
5
- "builtAt": "2026-04-04T04:28:17.653Z"
3
+ "version": "0.35.13",
4
+ "commit": "f82366476725",
5
+ "builtAt": "2026-04-07T23:06:53.397Z"
6
6
  }
package/dist/cli/args.js CHANGED
@@ -15,7 +15,6 @@ export const KNOWN_COMMANDS = new Set([
15
15
  "service",
16
16
  "connect",
17
17
  "installations",
18
- "feed",
19
18
  "help",
20
19
  ]);
21
20
  export function parseArgs(argv) {
@@ -56,6 +55,25 @@ export function resolveCommand(parsed) {
56
55
  return { command: "help", commandArgs: [] };
57
56
  }
58
57
  if (KNOWN_COMMANDS.has(requestedCommand)) {
58
+ if (requestedCommand === "attach") {
59
+ return { command: "repo", commandArgs: ["link", ...parsed.positionals.slice(1)] };
60
+ }
61
+ if (requestedCommand === "repos") {
62
+ const rest = parsed.positionals.slice(1);
63
+ if (rest.length === 0) {
64
+ return { command: "repo", commandArgs: ["list"] };
65
+ }
66
+ if (["list", "show", "link", "unlink", "sync"].includes(rest[0])) {
67
+ return { command: "repo", commandArgs: rest };
68
+ }
69
+ return { command: "repo", commandArgs: ["show", ...rest] };
70
+ }
71
+ if (requestedCommand === "connect") {
72
+ return { command: "linear", commandArgs: ["connect", ...parsed.positionals.slice(1)] };
73
+ }
74
+ if (requestedCommand === "installations") {
75
+ return { command: "linear", commandArgs: ["list", ...parsed.positionals.slice(1)] };
76
+ }
59
77
  const command = requestedCommand === "dash" || requestedCommand === "d"
60
78
  ? "dashboard"
61
79
  : requestedCommand;
@@ -1,8 +1,8 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
- import { getRunTypeFlag, parsePositiveIntegerFlag } from "../args.js";
2
+ import { getRunTypeFlag } from "../args.js";
3
3
  import { CliUsageError } from "../errors.js";
4
4
  import { formatJson } from "../formatters/json.js";
5
- import { formatEvents, formatInspect, formatList, formatLive, formatOpen, formatReport, formatRetry, formatWorktree } from "../formatters/text.js";
5
+ import { formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatWorktree } from "../formatters/text.js";
6
6
  import { buildOpenCommand } from "../interactive.js";
7
7
  import { writeOutput } from "../output.js";
8
8
  export async function handleIssueCommand(params) {
@@ -30,14 +30,12 @@ export async function handleIssueCommand(params) {
30
30
  },
31
31
  });
32
32
  }
33
- case "report":
34
- return await handleReportCommand(nested);
35
- case "events":
36
- return await handleEventsCommand(nested);
37
33
  case "path":
38
34
  return await handleWorktreeCommand(nested);
39
35
  case "open":
40
36
  return await handleOpenCommand(nested);
37
+ case "sessions":
38
+ return await handleSessionsCommand(nested);
41
39
  case "retry":
42
40
  return await handleRetryCommand(nested);
43
41
  default:
@@ -75,56 +73,6 @@ export async function handleLiveCommand(params) {
75
73
  } while (true);
76
74
  return 0;
77
75
  }
78
- export async function handleReportCommand(params) {
79
- const issueKey = params.commandArgs[0];
80
- if (!issueKey) {
81
- throw new Error("report requires <issueKey>.");
82
- }
83
- const reportOptions = {};
84
- const runType = getRunTypeFlag(params.parsed.flags.get("run-type"));
85
- if (runType) {
86
- reportOptions.runType = runType;
87
- }
88
- const runId = parsePositiveIntegerFlag(params.parsed.flags.get("run"), "--run");
89
- if (runId !== undefined) {
90
- reportOptions.runId = runId;
91
- }
92
- const result = params.data.report(issueKey, reportOptions);
93
- if (!result) {
94
- throw new Error(`Issue not found: ${issueKey}`);
95
- }
96
- writeOutput(params.stdout, params.json ? formatJson(result) : formatReport(result));
97
- return 0;
98
- }
99
- export async function handleEventsCommand(params) {
100
- const issueKey = params.commandArgs[0];
101
- if (!issueKey) {
102
- throw new Error("events requires <issueKey>.");
103
- }
104
- const follow = params.parsed.flags.get("follow") === true;
105
- let afterId;
106
- let runId = parsePositiveIntegerFlag(params.parsed.flags.get("run"), "--run");
107
- do {
108
- const result = params.data.events(issueKey, {
109
- ...(runId !== undefined ? { runId } : {}),
110
- ...(typeof params.parsed.flags.get("method") === "string" ? { method: String(params.parsed.flags.get("method")) } : {}),
111
- ...(afterId !== undefined ? { afterId } : {}),
112
- });
113
- if (!result) {
114
- throw new Error(`Run not found for ${issueKey}`);
115
- }
116
- runId = result.run.id;
117
- if (result.events.length > 0) {
118
- writeOutput(params.stdout, params.json ? formatJson(result) : formatEvents(result));
119
- afterId = result.events.at(-1)?.id;
120
- }
121
- if (!follow || result.run.status !== "running") {
122
- break;
123
- }
124
- await delay(2000);
125
- } while (true);
126
- return 0;
127
- }
128
76
  export async function handleWorktreeCommand(params) {
129
77
  const issueKey = params.commandArgs[0];
130
78
  if (!issueKey) {
@@ -166,6 +114,20 @@ export async function handleOpenCommand(params) {
166
114
  const openCommand = buildOpenCommand(params.config, result.worktreePath, result.resumeThreadId);
167
115
  return await params.runInteractive(openCommand.command, openCommand.args);
168
116
  }
117
+ export async function handleSessionsCommand(params) {
118
+ const issueKey = params.commandArgs[0];
119
+ if (!issueKey) {
120
+ throw new Error("sessions requires <issueKey>.");
121
+ }
122
+ const result = params.data.sessions(issueKey);
123
+ if (!result) {
124
+ throw new Error(`Issue not found: ${issueKey}`);
125
+ }
126
+ writeOutput(params.stdout, params.json
127
+ ? formatJson(result)
128
+ : formatSessionHistory(result, (threadId) => buildOpenCommand(params.config, result.worktreePath ?? "", threadId)));
129
+ return 0;
130
+ }
169
131
  export async function handleRetryCommand(params) {
170
132
  const issueKey = params.commandArgs[0];
171
133
  if (!issueKey) {
@@ -16,6 +16,11 @@ function resolveBaseUrl(config) {
16
16
  return `http://${host}:${config.server.port}`;
17
17
  }
18
18
  export async function handleWatchCommand(params) {
19
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
20
+ process.stderr.write("patchrelay dashboard requires an interactive TTY.\n");
21
+ process.stderr.write("Use `patchrelay issue list`, `patchrelay issue show <issueKey>`, or run the dashboard from a terminal.\n");
22
+ return 1;
23
+ }
19
24
  const { render } = await import("ink");
20
25
  const { createElement } = await import("react");
21
26
  const { App } = await import("../watch/App.js");
package/dist/cli/data.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import pino from "pino";
3
3
  import { CodexAppServerClient } from "../codex-app-server.js";
4
+ import { getThreadTurns } from "../codex-thread-utils.js";
4
5
  import { PatchRelayDatabase } from "../db.js";
5
6
  import { WorktreeManager } from "../worktree-manager.js";
6
7
  import { CliOperatorApiClient } from "./operator-client.js";
@@ -15,8 +16,20 @@ function safeJsonParse(value) {
15
16
  return undefined;
16
17
  }
17
18
  }
19
+ function normalizeStageReport(reportJson, runStatus) {
20
+ if (!reportJson)
21
+ return undefined;
22
+ try {
23
+ const parsed = JSON.parse(reportJson);
24
+ return { ...parsed, status: runStatus ?? parsed.status };
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
18
30
  function summarizeThread(thread, latestTimestampSeen) {
19
- const latestTurn = thread.turns.at(-1);
31
+ const turns = getThreadTurns(thread);
32
+ const latestTurn = turns.at(-1);
20
33
  const latestAssistantMessage = latestTurn?.items
21
34
  .filter((item) => item.type === "agentMessage")
22
35
  .at(-1)?.text;
@@ -32,6 +45,34 @@ function latestEventTimestamp(db, runId) {
32
45
  const events = db.listThreadEvents(runId);
33
46
  return events.at(-1)?.createdAt;
34
47
  }
48
+ function parseObjectJson(value) {
49
+ if (!value)
50
+ return undefined;
51
+ try {
52
+ const parsed = JSON.parse(value);
53
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ function summarizeRun(run) {
60
+ const summary = parseObjectJson(run.summaryJson);
61
+ if (typeof summary?.latestAssistantMessage === "string" && summary.latestAssistantMessage.trim()) {
62
+ return summary.latestAssistantMessage.trim();
63
+ }
64
+ const report = parseObjectJson(run.reportJson);
65
+ const assistantMessages = report?.assistantMessages;
66
+ if (Array.isArray(assistantMessages)) {
67
+ for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
68
+ const value = assistantMessages[index];
69
+ if (typeof value === "string" && value.trim()) {
70
+ return value.trim();
71
+ }
72
+ }
73
+ }
74
+ return run.failureReason?.trim() || undefined;
75
+ }
35
76
  export class CliDataAccess extends CliOperatorApiClient {
36
77
  config;
37
78
  db;
@@ -56,7 +97,7 @@ export class CliDataAccess extends CliOperatorApiClient {
56
97
  const dbIssue = this.db.getIssueByKey(issueKey);
57
98
  const activeRun = dbIssue.activeRunId ? this.db.getRun(dbIssue.activeRunId) : undefined;
58
99
  const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
59
- const latestReport = latestRun?.reportJson ? JSON.parse(latestRun.reportJson) : undefined;
100
+ const latestReport = normalizeStageReport(latestRun?.reportJson, latestRun?.status);
60
101
  const latestSummary = safeJsonParse(latestRun?.summaryJson);
61
102
  const statusNote = latestReport?.assistantMessages.at(-1) ??
62
103
  (typeof latestSummary?.latestAssistantMessage === "string" ? latestSummary.latestAssistantMessage : undefined) ??
@@ -70,6 +111,8 @@ export class CliDataAccess extends CliOperatorApiClient {
70
111
  ...(latestSummary ? { latestSummary } : {}),
71
112
  ...(dbIssue.prNumber ? { prNumber: dbIssue.prNumber } : {}),
72
113
  ...(dbIssue.prReviewState ? { prReviewState: dbIssue.prReviewState } : {}),
114
+ ...((dbIssue.sessionState) ? { sessionState: dbIssue.sessionState } : {}),
115
+ ...((dbIssue.waitingReason) ? { waitingReason: dbIssue.waitingReason } : {}),
73
116
  ...(statusNote ? { statusNote } : {}),
74
117
  };
75
118
  }
@@ -85,47 +128,6 @@ export class CliDataAccess extends CliOperatorApiClient {
85
128
  (await this.readLiveSummary(run.threadId, latestEventTimestamp(this.db, run.id)).catch(() => undefined));
86
129
  return { issue, run, ...(live ? { live } : {}) };
87
130
  }
88
- report(issueKey, options) {
89
- const issue = this.db.getTrackedIssueByKey(issueKey);
90
- if (!issue)
91
- return undefined;
92
- const runs = this.db
93
- .listRunsForIssue(issue.projectId, issue.linearIssueId)
94
- .filter((run) => {
95
- if (options?.runId !== undefined && run.id !== options.runId)
96
- return false;
97
- if (options?.runType !== undefined && run.runType !== options.runType)
98
- return false;
99
- return true;
100
- })
101
- .reverse()
102
- .map((run) => ({
103
- run,
104
- ...(run.reportJson ? { report: JSON.parse(run.reportJson) } : {}),
105
- ...(safeJsonParse(run.summaryJson) ? { summary: safeJsonParse(run.summaryJson) } : {}),
106
- }));
107
- return { issue, runs };
108
- }
109
- events(issueKey, options) {
110
- const issue = this.db.getTrackedIssueByKey(issueKey);
111
- if (!issue)
112
- return undefined;
113
- const dbIssue = this.db.getIssueByKey(issueKey);
114
- const run = (options?.runId !== undefined ? this.db.getRun(options.runId) : undefined) ??
115
- (dbIssue.activeRunId ? this.db.getRun(dbIssue.activeRunId) : undefined) ??
116
- this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
117
- if (!run || run.projectId !== issue.projectId || run.linearIssueId !== issue.linearIssueId)
118
- return undefined;
119
- const events = this.db
120
- .listThreadEvents(run.id)
121
- .filter((event) => (options?.method ? event.method === options.method : true))
122
- .filter((event) => (options?.afterId !== undefined ? event.id > options.afterId : true))
123
- .map((event) => ({
124
- ...event,
125
- ...(safeJsonParse(event.eventJson) ? { parsedEvent: safeJsonParse(event.eventJson) } : {}),
126
- }));
127
- return { issue, run, events };
128
- }
129
131
  worktree(issueKey) {
130
132
  const issue = this.db.getTrackedIssueByKey(issueKey);
131
133
  if (!issue)
@@ -181,16 +183,120 @@ export class CliDataAccess extends CliOperatorApiClient {
181
183
  if (dbIssue.activeRunId !== undefined) {
182
184
  throw new Error(`Issue ${issueKey} already has an active run.`);
183
185
  }
184
- const runType = (options?.runType ?? "implementation");
186
+ const runType = (options?.runType
187
+ ?? (issue.latestFailureSource === "queue_eviction" || issue.factoryState === "repairing_queue"
188
+ ? "queue_repair"
189
+ : dbIssue.prCheckStatus === "failed" || dbIssue.prCheckStatus === "failure" || issue.latestFailureSource === "branch_ci" || issue.factoryState === "repairing_ci"
190
+ ? "ci_repair"
191
+ : dbIssue.prReviewState === "changes_requested" || issue.factoryState === "changes_requested"
192
+ ? "review_fix"
193
+ : "implementation"));
194
+ const factoryState = runType === "queue_repair"
195
+ ? "repairing_queue"
196
+ : runType === "ci_repair"
197
+ ? "repairing_ci"
198
+ : runType === "review_fix"
199
+ ? "changes_requested"
200
+ : "delegated";
201
+ this.appendRetryWake(dbIssue, runType);
185
202
  this.db.upsertIssue({
186
203
  projectId: issue.projectId,
187
204
  linearIssueId: issue.linearIssueId,
188
- pendingRunType: runType,
189
- factoryState: "delegated",
205
+ pendingRunType: null,
206
+ pendingRunContextJson: null,
207
+ factoryState,
190
208
  });
191
209
  const updated = this.db.getTrackedIssue(issue.projectId, issue.linearIssueId);
192
210
  return { issue: updated, runType, ...(options?.reason ? { reason: options.reason } : {}) };
193
211
  }
212
+ sessions(issueKey) {
213
+ const issue = this.db.getTrackedIssueByKey(issueKey);
214
+ if (!issue)
215
+ return undefined;
216
+ const dbIssue = this.db.getIssueByKey(issueKey);
217
+ const runs = this.db.listRunsForIssue(issue.projectId, issue.linearIssueId);
218
+ const sessions = runs
219
+ .slice()
220
+ .reverse()
221
+ .map((run) => {
222
+ const summary = summarizeRun(run);
223
+ return {
224
+ runId: run.id,
225
+ runType: run.runType,
226
+ status: run.status,
227
+ ...(run.threadId ? { threadId: run.threadId } : {}),
228
+ ...(run.turnId ? { turnId: run.turnId } : {}),
229
+ ...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
230
+ ...(summary ? { summary } : {}),
231
+ ...(run.failureReason ? { failureReason: run.failureReason } : {}),
232
+ eventCount: this.db.listThreadEvents(run.id).length,
233
+ startedAt: run.startedAt,
234
+ ...(run.endedAt ? { endedAt: run.endedAt } : {}),
235
+ isCurrentThread: run.threadId !== undefined && run.threadId === dbIssue.threadId,
236
+ };
237
+ });
238
+ return {
239
+ issue,
240
+ ...(dbIssue.worktreePath ? { worktreePath: dbIssue.worktreePath } : {}),
241
+ ...(dbIssue.threadId ? { currentThreadId: dbIssue.threadId } : {}),
242
+ sessions,
243
+ };
244
+ }
245
+ appendRetryWake(issue, runType) {
246
+ if (runType === "queue_repair") {
247
+ const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
248
+ const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
249
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
250
+ projectId: issue.projectId,
251
+ linearIssueId: issue.linearIssueId,
252
+ eventType: "merge_steward_incident",
253
+ eventJson: JSON.stringify({
254
+ ...(queueIncident ?? {}),
255
+ ...(failureContext ?? {}),
256
+ source: "operator_retry",
257
+ }),
258
+ dedupeKey: `operator_retry:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
259
+ });
260
+ return;
261
+ }
262
+ if (runType === "ci_repair") {
263
+ const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
264
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
265
+ projectId: issue.projectId,
266
+ linearIssueId: issue.linearIssueId,
267
+ eventType: "settled_red_ci",
268
+ eventJson: JSON.stringify({
269
+ ...(failureContext ?? {}),
270
+ source: "operator_retry",
271
+ }),
272
+ dedupeKey: `operator_retry:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
273
+ });
274
+ return;
275
+ }
276
+ if (runType === "review_fix") {
277
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
278
+ projectId: issue.projectId,
279
+ linearIssueId: issue.linearIssueId,
280
+ eventType: "review_changes_requested",
281
+ eventJson: JSON.stringify({
282
+ reviewBody: "Operator requested retry of review-fix work.",
283
+ source: "operator_retry",
284
+ }),
285
+ dedupeKey: `operator_retry:review_fix:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
286
+ });
287
+ return;
288
+ }
289
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
290
+ projectId: issue.projectId,
291
+ linearIssueId: issue.linearIssueId,
292
+ eventType: "delegated",
293
+ eventJson: JSON.stringify({
294
+ promptContext: "Operator requested retry of PatchRelay work.",
295
+ source: "operator_retry",
296
+ }),
297
+ dedupeKey: `operator_retry:implementation:${issue.linearIssueId}`,
298
+ });
299
+ }
194
300
  list(options) {
195
301
  const conditions = [];
196
302
  const values = [];
@@ -209,10 +315,15 @@ export class CliDataAccess extends CliOperatorApiClient {
209
315
  i.current_linear_state,
210
316
  i.factory_state,
211
317
  i.updated_at,
318
+ s.session_state,
319
+ s.waiting_reason,
212
320
  active_run.run_type AS active_run_type,
213
321
  latest_run.run_type AS latest_run_type,
214
322
  latest_run.status AS latest_run_status
215
323
  FROM issues i
324
+ LEFT JOIN issue_sessions s
325
+ ON s.project_id = i.project_id
326
+ AND s.linear_issue_id = i.linear_issue_id
216
327
  LEFT JOIN runs active_run ON active_run.id = i.active_run_id
217
328
  LEFT JOIN runs latest_run ON latest_run.id = (
218
329
  SELECT r.id FROM runs r
@@ -228,7 +339,9 @@ export class CliDataAccess extends CliOperatorApiClient {
228
339
  ...(row.title !== null ? { title: String(row.title) } : {}),
229
340
  projectId: String(row.project_id),
230
341
  ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
342
+ ...(row.session_state !== null ? { sessionState: String(row.session_state) } : {}),
231
343
  factoryState: String(row.factory_state ?? "delegated"),
344
+ ...(row.waiting_reason !== null ? { waitingReason: String(row.waiting_reason) } : {}),
232
345
  ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
233
346
  ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
234
347
  ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
@@ -237,7 +350,7 @@ export class CliDataAccess extends CliOperatorApiClient {
237
350
  return items.filter((item) => {
238
351
  if (options?.active && !item.activeRunType)
239
352
  return false;
240
- if (options?.failed && item.latestRunStatus !== "failed")
353
+ if (options?.failed && item.factoryState !== "failed" && item.factoryState !== "escalated")
241
354
  return false;
242
355
  return true;
243
356
  });