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.
- package/README.md +41 -9
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +19 -1
- package/dist/cli/commands/issues.js +18 -56
- package/dist/cli/commands/watch.js +5 -0
- package/dist/cli/data.js +160 -47
- package/dist/cli/formatters/text.js +51 -90
- package/dist/cli/help.js +15 -8
- package/dist/cli/index.js +3 -58
- package/dist/cli/operator-client.js +0 -82
- package/dist/cli/watch/App.js +21 -12
- package/dist/cli/watch/HelpBar.js +3 -3
- package/dist/cli/watch/IssueDetailView.js +63 -130
- package/dist/cli/watch/IssueRow.js +82 -27
- package/dist/cli/watch/StatusBar.js +8 -4
- package/dist/cli/watch/detail-rows.js +589 -0
- package/dist/cli/watch/render-rich-text.js +226 -0
- 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 +129 -56
- 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 +423 -52
- package/dist/github-webhooks.js +7 -0
- package/dist/http.js +12 -264
- package/dist/idle-reconciliation.js +268 -76
- 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 +1364 -147
- 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 +3 -2
- 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
|
@@ -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
|
|
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, 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
|
|
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
|
|
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
|
|
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:
|
|
189
|
-
|
|
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.
|
|
353
|
+
if (options?.failed && item.factoryState !== "failed" && item.factoryState !== "escalated")
|
|
241
354
|
return false;
|
|
242
355
|
return true;
|
|
243
356
|
});
|