patchrelay 0.35.10 → 0.35.12

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 (50) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +0 -1
  4. package/dist/cli/commands/issues.js +2 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +110 -47
  7. package/dist/cli/formatters/text.js +6 -90
  8. package/dist/cli/help.js +3 -8
  9. package/dist/cli/index.js +0 -48
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +1 -12
  12. package/dist/cli/watch/HelpBar.js +2 -2
  13. package/dist/cli/watch/IssueDetailView.js +57 -26
  14. package/dist/cli/watch/IssueRow.js +71 -27
  15. package/dist/cli/watch/StatusBar.js +7 -4
  16. package/dist/cli/watch/state-visualization.js +48 -23
  17. package/dist/cli/watch/timeline-builder.js +2 -1
  18. package/dist/cli/watch/use-detail-stream.js +10 -104
  19. package/dist/cli/watch/use-watch-stream.js +11 -102
  20. package/dist/cli/watch/watch-state.js +18 -50
  21. package/dist/codex-thread-utils.js +3 -0
  22. package/dist/db/migrations.js +239 -2
  23. package/dist/db.js +628 -39
  24. package/dist/github-app-token.js +7 -0
  25. package/dist/github-failure-context.js +44 -1
  26. package/dist/github-rollup.js +47 -0
  27. package/dist/github-webhook-handler.js +248 -51
  28. package/dist/github-webhooks.js +5 -0
  29. package/dist/http.js +12 -264
  30. package/dist/idle-reconciliation.js +275 -74
  31. package/dist/issue-query-service.js +221 -129
  32. package/dist/issue-session-events.js +151 -0
  33. package/dist/issue-session.js +99 -0
  34. package/dist/linear-client.js +39 -25
  35. package/dist/linear-session-reporting.js +12 -0
  36. package/dist/linear-session-sync.js +253 -24
  37. package/dist/linear-workflow.js +33 -0
  38. package/dist/merge-queue-protocol.js +0 -51
  39. package/dist/preflight.js +1 -4
  40. package/dist/queue-health-monitor.js +11 -7
  41. package/dist/run-orchestrator.js +1295 -146
  42. package/dist/run-reporting.js +5 -3
  43. package/dist/service.js +279 -102
  44. package/dist/status-note.js +56 -0
  45. package/dist/waiting-reason.js +65 -0
  46. package/dist/webhook-handler.js +270 -79
  47. package/package.json +1 -1
  48. package/dist/cli/commands/feed.js +0 -60
  49. package/dist/cli/watch/FeedView.js +0 -28
  50. 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.10",
4
- "commit": "3146dd0e4b68",
5
- "builtAt": "2026-04-04T00:02:17.420Z"
3
+ "version": "0.35.12",
4
+ "commit": "d9cef8cb23dd",
5
+ "builtAt": "2026-04-07T10:41:56.287Z"
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) {
@@ -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, 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,10 +30,6 @@ 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":
@@ -75,56 +71,6 @@ export async function handleLiveCommand(params) {
75
71
  } while (true);
76
72
  return 0;
77
73
  }
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
74
  export async function handleWorktreeCommand(params) {
129
75
  const issueKey = params.commandArgs[0];
130
76
  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,17 @@ 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
+ }
35
59
  export class CliDataAccess extends CliOperatorApiClient {
36
60
  config;
37
61
  db;
@@ -56,7 +80,7 @@ export class CliDataAccess extends CliOperatorApiClient {
56
80
  const dbIssue = this.db.getIssueByKey(issueKey);
57
81
  const activeRun = dbIssue.activeRunId ? this.db.getRun(dbIssue.activeRunId) : undefined;
58
82
  const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
59
- const latestReport = latestRun?.reportJson ? JSON.parse(latestRun.reportJson) : undefined;
83
+ const latestReport = normalizeStageReport(latestRun?.reportJson, latestRun?.status);
60
84
  const latestSummary = safeJsonParse(latestRun?.summaryJson);
61
85
  const statusNote = latestReport?.assistantMessages.at(-1) ??
62
86
  (typeof latestSummary?.latestAssistantMessage === "string" ? latestSummary.latestAssistantMessage : undefined) ??
@@ -70,6 +94,8 @@ export class CliDataAccess extends CliOperatorApiClient {
70
94
  ...(latestSummary ? { latestSummary } : {}),
71
95
  ...(dbIssue.prNumber ? { prNumber: dbIssue.prNumber } : {}),
72
96
  ...(dbIssue.prReviewState ? { prReviewState: dbIssue.prReviewState } : {}),
97
+ ...((dbIssue.sessionState) ? { sessionState: dbIssue.sessionState } : {}),
98
+ ...((dbIssue.waitingReason) ? { waitingReason: dbIssue.waitingReason } : {}),
73
99
  ...(statusNote ? { statusNote } : {}),
74
100
  };
75
101
  }
@@ -85,47 +111,6 @@ export class CliDataAccess extends CliOperatorApiClient {
85
111
  (await this.readLiveSummary(run.threadId, latestEventTimestamp(this.db, run.id)).catch(() => undefined));
86
112
  return { issue, run, ...(live ? { live } : {}) };
87
113
  }
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
114
  worktree(issueKey) {
130
115
  const issue = this.db.getTrackedIssueByKey(issueKey);
131
116
  if (!issue)
@@ -181,16 +166,87 @@ export class CliDataAccess extends CliOperatorApiClient {
181
166
  if (dbIssue.activeRunId !== undefined) {
182
167
  throw new Error(`Issue ${issueKey} already has an active run.`);
183
168
  }
184
- const runType = (options?.runType ?? "implementation");
169
+ const runType = (options?.runType
170
+ ?? (issue.latestFailureSource === "queue_eviction" || issue.factoryState === "repairing_queue"
171
+ ? "queue_repair"
172
+ : dbIssue.prCheckStatus === "failed" || dbIssue.prCheckStatus === "failure" || issue.latestFailureSource === "branch_ci" || issue.factoryState === "repairing_ci"
173
+ ? "ci_repair"
174
+ : dbIssue.prReviewState === "changes_requested" || issue.factoryState === "changes_requested"
175
+ ? "review_fix"
176
+ : "implementation"));
177
+ const factoryState = runType === "queue_repair"
178
+ ? "repairing_queue"
179
+ : runType === "ci_repair"
180
+ ? "repairing_ci"
181
+ : runType === "review_fix"
182
+ ? "changes_requested"
183
+ : "delegated";
184
+ this.appendRetryWake(dbIssue, runType);
185
185
  this.db.upsertIssue({
186
186
  projectId: issue.projectId,
187
187
  linearIssueId: issue.linearIssueId,
188
- pendingRunType: runType,
189
- factoryState: "delegated",
188
+ pendingRunType: null,
189
+ pendingRunContextJson: null,
190
+ factoryState,
190
191
  });
191
192
  const updated = this.db.getTrackedIssue(issue.projectId, issue.linearIssueId);
192
193
  return { issue: updated, runType, ...(options?.reason ? { reason: options.reason } : {}) };
193
194
  }
195
+ appendRetryWake(issue, runType) {
196
+ if (runType === "queue_repair") {
197
+ const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
198
+ const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
199
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
200
+ projectId: issue.projectId,
201
+ linearIssueId: issue.linearIssueId,
202
+ eventType: "merge_steward_incident",
203
+ eventJson: JSON.stringify({
204
+ ...(queueIncident ?? {}),
205
+ ...(failureContext ?? {}),
206
+ source: "operator_retry",
207
+ }),
208
+ dedupeKey: `operator_retry:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
209
+ });
210
+ return;
211
+ }
212
+ if (runType === "ci_repair") {
213
+ const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
214
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
215
+ projectId: issue.projectId,
216
+ linearIssueId: issue.linearIssueId,
217
+ eventType: "settled_red_ci",
218
+ eventJson: JSON.stringify({
219
+ ...(failureContext ?? {}),
220
+ source: "operator_retry",
221
+ }),
222
+ dedupeKey: `operator_retry:ci_repair:${issue.linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? "unknown-sha"}`,
223
+ });
224
+ return;
225
+ }
226
+ if (runType === "review_fix") {
227
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
228
+ projectId: issue.projectId,
229
+ linearIssueId: issue.linearIssueId,
230
+ eventType: "review_changes_requested",
231
+ eventJson: JSON.stringify({
232
+ reviewBody: "Operator requested retry of review-fix work.",
233
+ source: "operator_retry",
234
+ }),
235
+ dedupeKey: `operator_retry:review_fix:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
236
+ });
237
+ return;
238
+ }
239
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
240
+ projectId: issue.projectId,
241
+ linearIssueId: issue.linearIssueId,
242
+ eventType: "delegated",
243
+ eventJson: JSON.stringify({
244
+ promptContext: "Operator requested retry of PatchRelay work.",
245
+ source: "operator_retry",
246
+ }),
247
+ dedupeKey: `operator_retry:implementation:${issue.linearIssueId}`,
248
+ });
249
+ }
194
250
  list(options) {
195
251
  const conditions = [];
196
252
  const values = [];
@@ -209,10 +265,15 @@ export class CliDataAccess extends CliOperatorApiClient {
209
265
  i.current_linear_state,
210
266
  i.factory_state,
211
267
  i.updated_at,
268
+ s.session_state,
269
+ s.waiting_reason,
212
270
  active_run.run_type AS active_run_type,
213
271
  latest_run.run_type AS latest_run_type,
214
272
  latest_run.status AS latest_run_status
215
273
  FROM issues i
274
+ LEFT JOIN issue_sessions s
275
+ ON s.project_id = i.project_id
276
+ AND s.linear_issue_id = i.linear_issue_id
216
277
  LEFT JOIN runs active_run ON active_run.id = i.active_run_id
217
278
  LEFT JOIN runs latest_run ON latest_run.id = (
218
279
  SELECT r.id FROM runs r
@@ -228,7 +289,9 @@ export class CliDataAccess extends CliOperatorApiClient {
228
289
  ...(row.title !== null ? { title: String(row.title) } : {}),
229
290
  projectId: String(row.project_id),
230
291
  ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
292
+ ...(row.session_state !== null ? { sessionState: String(row.session_state) } : {}),
231
293
  factoryState: String(row.factory_state ?? "delegated"),
294
+ ...(row.waiting_reason !== null ? { waitingReason: String(row.waiting_reason) } : {}),
232
295
  ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
233
296
  ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
234
297
  ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
@@ -237,7 +300,7 @@ export class CliDataAccess extends CliOperatorApiClient {
237
300
  return items.filter((item) => {
238
301
  if (options?.active && !item.activeRunType)
239
302
  return false;
240
- if (options?.failed && item.latestRunStatus !== "failed")
303
+ if (options?.failed && item.factoryState !== "failed" && item.factoryState !== "escalated")
241
304
  return false;
242
305
  return true;
243
306
  });
@@ -15,7 +15,9 @@ export function formatInspect(result) {
15
15
  const lines = [
16
16
  header,
17
17
  value("Title", result.issue?.title),
18
- value("State", result.issue?.factoryState),
18
+ value("Session", result.issue?.sessionState),
19
+ value("Waiting reason", result.issue?.waitingReason ?? result.issue?.statusNote),
20
+ value("Debug stage", result.issue?.factoryState),
19
21
  result.activeRun ? value("Active run", `${result.activeRun.runType} (${result.activeRun.status})`) : undefined,
20
22
  result.latestRun && !result.activeRun ? value("Latest run", `${result.latestRun.runType} (${result.latestRun.status})`) : undefined,
21
23
  result.prNumber ? value("PR", `#${result.prNumber}${result.prReviewState ? ` [${result.prReviewState}]` : ""}`) : undefined,
@@ -35,39 +37,6 @@ export function formatLive(result) {
35
37
  ].filter(Boolean);
36
38
  return `${lines.join("\n")}\n`;
37
39
  }
38
- export function formatReport(result) {
39
- const sections = result.runs.map(({ run, report, summary }) => {
40
- const changedFiles = report?.fileChanges
41
- .map((entry) => (typeof entry.path === "string" ? entry.path : undefined))
42
- .filter(Boolean)
43
- .join(", ");
44
- const commands = report?.commands.map((command) => command.command).join(" | ");
45
- const tools = report?.toolCalls.map((tool) => `${tool.type}:${tool.name}`).join(", ");
46
- return [
47
- `${run.runType} #${run.id} ${run.status}`,
48
- value("Started", run.startedAt),
49
- value("Ended", run.endedAt),
50
- value("Thread", run.threadId),
51
- summary?.latestAssistantMessage ? value("Summary", truncateLine(String(summary.latestAssistantMessage))) : undefined,
52
- report?.assistantMessages.at(-1) ? value("Assistant conclusion", truncateLine(report.assistantMessages.at(-1))) : undefined,
53
- commands ? value("Commands", commands) : undefined,
54
- changedFiles ? value("Changed files", changedFiles) : undefined,
55
- tools ? value("Tool calls", tools) : undefined,
56
- ]
57
- .filter(Boolean)
58
- .join("\n");
59
- });
60
- return `${sections.join("\n\n")}\n`;
61
- }
62
- export function formatEvents(result) {
63
- const sections = result.events.map((event) => [
64
- `#${event.id} ${event.createdAt} ${event.method}`,
65
- value("Thread", event.threadId),
66
- value("Turn", event.turnId),
67
- event.parsedEvent ? JSON.stringify(event.parsedEvent, null, 2) : event.eventJson,
68
- ].join("\n"));
69
- return `${value("Run", result.run.id)}\n${value("Run type", result.run.runType)}\n\n${sections.join("\n\n")}\n`;
70
- }
71
40
  export function formatWorktree(result, cdOnly) {
72
41
  if (cdOnly) {
73
42
  return `${result.worktreePath}\n`;
@@ -111,65 +80,12 @@ export function formatList(items) {
111
80
  .map((item) => [
112
81
  item.issueKey ?? "-",
113
82
  item.currentLinearState ?? "-",
114
- item.factoryState,
83
+ item.sessionState ?? "-",
84
+ item.waitingReason ?? "-",
115
85
  item.activeRunType ?? "-",
116
86
  item.latestRunType ? `${item.latestRunType}:${item.latestRunStatus ?? "-"}` : "-",
117
87
  item.updatedAt,
88
+ item.factoryState,
118
89
  ].join("\t"))
119
90
  .join("\n")}\n`;
120
91
  }
121
- function colorize(enabled, code, value) {
122
- return enabled ? `\u001B[${code}m${value}\u001B[0m` : value;
123
- }
124
- function formatFeedStatus(event, color) {
125
- const raw = event.status ?? event.kind;
126
- const label = raw.replaceAll("_", " ");
127
- const padded = label.padEnd(15);
128
- if (event.level === "error" || raw === "failed" || raw === "delivery_failed") {
129
- return colorize(color, "31", padded);
130
- }
131
- if (event.level === "warn" || raw === "ignored" || raw === "fallback" || raw === "handoff" || raw === "transition_suppressed") {
132
- return colorize(color, "33", padded);
133
- }
134
- if (raw === "running" || raw === "started" || raw === "delegated" || raw === "transition_chosen" || raw === "completed") {
135
- return colorize(color, "32", padded);
136
- }
137
- if (raw === "queued" || raw === "selected") {
138
- return colorize(color, "36", padded);
139
- }
140
- return colorize(color, "2", padded);
141
- }
142
- function formatFeedMeta(event, color) {
143
- const parts = [
144
- event.workflowId ? `workflow:${event.workflowId}` : undefined,
145
- event.stage ? `stage:${event.stage}` : undefined,
146
- event.nextStage ? `next:${event.nextStage}` : undefined,
147
- ].filter(Boolean);
148
- if (parts.length === 0) {
149
- return undefined;
150
- }
151
- return colorize(color, "2", `[${parts.join(" ")}]`);
152
- }
153
- export function formatOperatorFeedEvent(event, options) {
154
- const color = options?.color === true;
155
- const timestamp = new Date(event.at).toLocaleTimeString("en-GB", { hour12: false });
156
- const issue = event.issueKey ?? event.projectId ?? "-";
157
- const meta = formatFeedMeta(event, color);
158
- const line = [
159
- colorize(color, "2", timestamp),
160
- colorize(color, "1", issue.padEnd(10)),
161
- formatFeedStatus(event, color),
162
- event.summary,
163
- ...(meta ? [meta] : []),
164
- ].join(" ");
165
- if (!event.detail) {
166
- return `${line}\n`;
167
- }
168
- return `${line}\n${colorize(color, "2", ` ${truncateLine(event.detail)}`)}\n`;
169
- }
170
- export function formatOperatorFeed(result, options) {
171
- if (result.events.length === 0) {
172
- return "No feed events yet.\n";
173
- }
174
- return result.events.map((event) => formatOperatorFeedEvent(event, options)).join("");
175
- }
package/dist/cli/help.js CHANGED
@@ -38,12 +38,10 @@ export function rootHelpText() {
38
38
  " issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
39
39
  " service status [--json] Show systemd state and local health",
40
40
  " service logs [--lines <count>] [--json] Show recent service logs",
41
+ " serve Run the local PatchRelay service",
41
42
  "",
42
43
  "Operator commands:",
43
- " feed [--follow] [--limit <count>] [--issue <issueKey>] [--repo <id>] [--kind <kind>] [--stage <stage>] [--status <status>] [--workflow <id>] [--json]",
44
- " Show operator activity from the daemon",
45
- " dashboard [--issue <issueKey>] Open the TUI dashboard of issues and runs",
46
- " serve Run the local PatchRelay service",
44
+ " dashboard [--issue <issueKey>] Open the PatchRelay session dashboard",
47
45
  "",
48
46
  "Environment options:",
49
47
  " --help, -h Show help for the root command or current command group",
@@ -62,7 +60,6 @@ export function rootHelpText() {
62
60
  " patchrelay repo list",
63
61
  " patchrelay issue list --active",
64
62
  " patchrelay issue watch USE-54",
65
- " patchrelay dashboard",
66
63
  " patchrelay service status",
67
64
  " patchrelay version --json",
68
65
  "",
@@ -134,9 +131,7 @@ export function issueHelpText() {
134
131
  "Commands:",
135
132
  " show <issueKey> Show the latest known issue state",
136
133
  " list List tracked issues",
137
- " watch <issueKey> Follow the active run until it settles",
138
- " report <issueKey> Show finished run reports",
139
- " events <issueKey> Show raw thread events",
134
+ " watch <issueKey> Follow PatchRelay-owned activity until it settles",
140
135
  " path <issueKey> Print the issue worktree path",
141
136
  " open <issueKey> Open Codex in the issue worktree",
142
137
  " retry <issueKey> Requeue a run",