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.
- package/README.md +41 -9
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +0 -1
- package/dist/cli/commands/issues.js +2 -56
- package/dist/cli/commands/watch.js +5 -0
- package/dist/cli/data.js +110 -47
- package/dist/cli/formatters/text.js +6 -90
- package/dist/cli/help.js +3 -8
- package/dist/cli/index.js +0 -48
- package/dist/cli/operator-client.js +0 -82
- package/dist/cli/watch/App.js +1 -12
- package/dist/cli/watch/HelpBar.js +2 -2
- package/dist/cli/watch/IssueDetailView.js +57 -26
- package/dist/cli/watch/IssueRow.js +71 -27
- package/dist/cli/watch/StatusBar.js +7 -4
- package/dist/cli/watch/state-visualization.js +48 -23
- package/dist/cli/watch/timeline-builder.js +2 -1
- package/dist/cli/watch/use-detail-stream.js +10 -104
- package/dist/cli/watch/use-watch-stream.js +11 -102
- package/dist/cli/watch/watch-state.js +18 -50
- package/dist/codex-thread-utils.js +3 -0
- package/dist/db/migrations.js +239 -2
- package/dist/db.js +628 -39
- package/dist/github-app-token.js +7 -0
- package/dist/github-failure-context.js +44 -1
- package/dist/github-rollup.js +47 -0
- package/dist/github-webhook-handler.js +248 -51
- package/dist/github-webhooks.js +5 -0
- package/dist/http.js +12 -264
- package/dist/idle-reconciliation.js +275 -74
- package/dist/issue-query-service.js +221 -129
- package/dist/issue-session-events.js +151 -0
- package/dist/issue-session.js +99 -0
- package/dist/linear-client.js +39 -25
- package/dist/linear-session-reporting.js +12 -0
- package/dist/linear-session-sync.js +253 -24
- package/dist/linear-workflow.js +33 -0
- package/dist/merge-queue-protocol.js +0 -51
- package/dist/preflight.js +1 -4
- package/dist/queue-health-monitor.js +11 -7
- package/dist/run-orchestrator.js +1295 -146
- package/dist/run-reporting.js +5 -3
- package/dist/service.js +279 -102
- package/dist/status-note.js +56 -0
- package/dist/waiting-reason.js +65 -0
- package/dist/webhook-handler.js +270 -79
- package/package.json +1 -1
- package/dist/cli/commands/feed.js +0 -60
- package/dist/cli/watch/FeedView.js +0 -28
- 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
|
|
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
|
|
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,
|
|
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.
|
|
85
|
-
7.
|
|
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
|
|
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
|
|
package/dist/build-info.json
CHANGED
package/dist/cli/args.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
-
import { getRunTypeFlag
|
|
2
|
+
import { getRunTypeFlag } from "../args.js";
|
|
3
3
|
import { CliUsageError } from "../errors.js";
|
|
4
4
|
import { formatJson } from "../formatters/json.js";
|
|
5
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
189
|
-
|
|
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.
|
|
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("
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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",
|