patchrelay 0.73.4 → 0.74.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli/commands/setup.js +53 -0
- package/dist/cli/data.js +3 -0
- package/dist/cli/help.js +2 -0
- package/dist/cli/index.js +4 -0
- package/dist/codex-status.js +41 -0
- package/dist/db/issue-session-store.js +42 -1
- package/dist/db/schema-guard.js +17 -0
- package/dist/db.js +7 -0
- package/dist/github-cli-auth.js +43 -0
- package/dist/github-webhook-reactive-run.js +11 -5
- package/dist/http.js +9 -0
- package/dist/idle-reconciliation.js +22 -2
- package/dist/issue-session-events.js +26 -1
- package/dist/operator-retry-event.js +9 -1
- package/dist/preflight.js +2 -0
- package/dist/reactive-wake-keys.js +64 -0
- package/dist/run-notification-handler.js +125 -6
- package/dist/run-orchestrator.js +49 -1
- package/dist/run-wake-planner.js +14 -2
- package/dist/service-runtime.js +1 -1
- package/dist/service-startup-recovery.js +19 -1
- package/dist/service.js +11 -1
- package/infra/patchrelay.service +2 -1
- package/package.json +8 -8
package/dist/build-info.json
CHANGED
|
@@ -175,6 +175,41 @@ async function readPatchRelayHealth() {
|
|
|
175
175
|
};
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
function getPatchRelayServiceUrl() {
|
|
179
|
+
const config = loadConfig(undefined, { profile: "doctor" });
|
|
180
|
+
const host = config.server.bind === "0.0.0.0" ? "127.0.0.1" : config.server.bind;
|
|
181
|
+
const baseUrl = `http://${host}:${config.server.port}`;
|
|
182
|
+
return {
|
|
183
|
+
baseUrl,
|
|
184
|
+
healthPath: `${baseUrl}${config.server.healthPath}`,
|
|
185
|
+
codexStatusPath: `${baseUrl}/status`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
async function readPatchRelayCodexStatus() {
|
|
189
|
+
const { codexStatusPath } = getPatchRelayServiceUrl();
|
|
190
|
+
try {
|
|
191
|
+
const response = await fetch(codexStatusPath, { signal: AbortSignal.timeout(2_000) });
|
|
192
|
+
const payload = await response.json();
|
|
193
|
+
return {
|
|
194
|
+
reachable: true,
|
|
195
|
+
status: response.status,
|
|
196
|
+
payload: {
|
|
197
|
+
...payload,
|
|
198
|
+
output: typeof payload.output === "string" ? payload.output : "",
|
|
199
|
+
...(typeof payload.account === "string" ? { account: payload.account } : {}),
|
|
200
|
+
exitCode: typeof payload.exitCode === "number" ? payload.exitCode : 1,
|
|
201
|
+
ok: payload.ok === true,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
reachable: false,
|
|
208
|
+
status: 0,
|
|
209
|
+
error: error instanceof Error ? error.message : String(error),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
178
213
|
export async function handleServiceCommand(params) {
|
|
179
214
|
if (params.commandArgs.length === 0) {
|
|
180
215
|
throw new CliUsageError("patchrelay service requires a subcommand.", "service");
|
|
@@ -229,6 +264,24 @@ export async function handleServiceCommand(params) {
|
|
|
229
264
|
.join("\n") + "\n");
|
|
230
265
|
return 0;
|
|
231
266
|
}
|
|
267
|
+
if (subcommand === "codex-status") {
|
|
268
|
+
const result = await readPatchRelayCodexStatus();
|
|
269
|
+
if (!result.reachable) {
|
|
270
|
+
throw new Error(`Unable to read PatchRelay Codex status. ${result.error}`);
|
|
271
|
+
}
|
|
272
|
+
const status = result.payload;
|
|
273
|
+
const output = status.output.trim();
|
|
274
|
+
if (params.json) {
|
|
275
|
+
writeOutput(params.stdout, formatJson(status));
|
|
276
|
+
return status.ok ? 0 : 1;
|
|
277
|
+
}
|
|
278
|
+
const lines = [
|
|
279
|
+
"PatchRelay Codex status",
|
|
280
|
+
output ? output : "No codex status output received.",
|
|
281
|
+
].filter(Boolean);
|
|
282
|
+
writeOutput(params.stdout, `${lines.join("\n")}\n`);
|
|
283
|
+
return status.ok ? 0 : 1;
|
|
284
|
+
}
|
|
232
285
|
if (subcommand === "logs") {
|
|
233
286
|
const lines = parsePositiveIntegerFlag(params.parsed.flags.get("lines"), "--lines") ?? 50;
|
|
234
287
|
const result = await params.runCommand("sudo", [
|
package/dist/cli/data.js
CHANGED
|
@@ -98,6 +98,9 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
98
98
|
super(config);
|
|
99
99
|
this.config = config;
|
|
100
100
|
this.db = options?.db ?? new PatchRelayDatabase(config.database.path, config.database.wal);
|
|
101
|
+
if (!options?.db) {
|
|
102
|
+
this.db.assertSchemaReady();
|
|
103
|
+
}
|
|
101
104
|
this.codex = options?.codex;
|
|
102
105
|
}
|
|
103
106
|
close() {
|
package/dist/cli/help.js
CHANGED
|
@@ -43,6 +43,7 @@ export function rootHelpText() {
|
|
|
43
43
|
" issue close <issueKey> [--failed] [--reason <text>] [--json]",
|
|
44
44
|
" Force-close one issue and release any active run",
|
|
45
45
|
" service status [--json] Show systemd state and local health",
|
|
46
|
+
" service codex-status [--json] Show Codex account and usage snapshot from this service",
|
|
46
47
|
" cluster [--json] Check service + workflow health across all tracked issues",
|
|
47
48
|
" service logs [--lines <count>] [--json] Show recent service logs",
|
|
48
49
|
" serve Run the local PatchRelay service",
|
|
@@ -178,6 +179,7 @@ export function serviceHelpText() {
|
|
|
178
179
|
" install [--force] [--write-only] [--json] Reinstall the systemd service unit",
|
|
179
180
|
" restart [--json] Reload-or-restart the service",
|
|
180
181
|
" status [--json] Show systemd state and local health",
|
|
182
|
+
" codex-status [--json] Show Codex account and usage snapshot from this service",
|
|
181
183
|
" logs [--lines <count>] [--json] Show recent journal logs",
|
|
182
184
|
"",
|
|
183
185
|
"Examples:",
|
package/dist/cli/index.js
CHANGED
|
@@ -141,6 +141,10 @@ function validateFlags(command, commandArgs, parsed) {
|
|
|
141
141
|
assertKnownFlags(parsed, "service", ["json"]);
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
|
+
if (commandArgs[0] === "codex-status") {
|
|
145
|
+
assertKnownFlags(parsed, "service", ["json"]);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
144
148
|
if (commandArgs[0] === "logs") {
|
|
145
149
|
assertKnownFlags(parsed, "service", ["lines", "json"]);
|
|
146
150
|
return;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
const CODEX_STATUS_TIMEOUT_MS = 15_000;
|
|
3
|
+
function stripAnsiCodes(value) {
|
|
4
|
+
return value.replace(/\u001b\[[0-9;]*m/g, "");
|
|
5
|
+
}
|
|
6
|
+
function parseAccountLine(output) {
|
|
7
|
+
const lines = output.split(/\r?\n/);
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
const clean = stripAnsiCodes(line).trim();
|
|
10
|
+
const match = clean.match(/^Account:\s*(.+)$/i);
|
|
11
|
+
if (match) {
|
|
12
|
+
return match[1].trim();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
export function getCodexStatusSnapshot(bin = "codex") {
|
|
18
|
+
try {
|
|
19
|
+
const result = spawnSync(bin, ["status"], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
timeout: CODEX_STATUS_TIMEOUT_MS,
|
|
22
|
+
env: { ...process.env },
|
|
23
|
+
});
|
|
24
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trimEnd();
|
|
25
|
+
const account = parseAccountLine(output);
|
|
26
|
+
return {
|
|
27
|
+
ok: result.status === 0,
|
|
28
|
+
exitCode: result.status ?? 1,
|
|
29
|
+
output,
|
|
30
|
+
...(account ? { account } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
exitCode: 1,
|
|
37
|
+
output: "",
|
|
38
|
+
error: error instanceof Error ? error.message : String(error),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deriveSessionWakePlan, isActionableIssueSessionEventType } from "../issue-session-events.js";
|
|
2
|
+
import { mergeRequestedChangesEventJson, readRequestedChangesCoalesceKey } from "../reactive-wake-keys.js";
|
|
2
3
|
import { isoNow } from "./shared.js";
|
|
3
4
|
export class IssueSessionStore {
|
|
4
5
|
connection;
|
|
@@ -33,6 +34,9 @@ export class IssueSessionStore {
|
|
|
33
34
|
if (existing)
|
|
34
35
|
return this.mapIssueSessionEventRow(existing);
|
|
35
36
|
}
|
|
37
|
+
const coalesced = this.coalescePendingRequestedChangesEvent(params);
|
|
38
|
+
if (coalesced)
|
|
39
|
+
return coalesced;
|
|
36
40
|
const now = isoNow();
|
|
37
41
|
const result = this.connection.prepare(`
|
|
38
42
|
INSERT INTO issue_session_events (
|
|
@@ -41,6 +45,27 @@ export class IssueSessionStore {
|
|
|
41
45
|
`).run(params.projectId, params.linearIssueId, params.eventType, params.eventJson ?? null, params.dedupeKey ?? null, now);
|
|
42
46
|
return this.getIssueSessionEvent(Number(result.lastInsertRowid));
|
|
43
47
|
}
|
|
48
|
+
coalescePendingRequestedChangesEvent(params) {
|
|
49
|
+
if (params.eventType !== "review_changes_requested")
|
|
50
|
+
return undefined;
|
|
51
|
+
const coalesceKey = readRequestedChangesCoalesceKey(params.eventJson);
|
|
52
|
+
if (!coalesceKey)
|
|
53
|
+
return undefined;
|
|
54
|
+
const existing = this.listIssueSessionEvents(params.projectId, params.linearIssueId, { pendingOnly: true })
|
|
55
|
+
.filter((event) => event.eventType === "review_changes_requested")
|
|
56
|
+
.find((event) => readRequestedChangesCoalesceKey(event.eventJson) === coalesceKey);
|
|
57
|
+
if (!existing)
|
|
58
|
+
return undefined;
|
|
59
|
+
const mergedJson = mergeRequestedChangesEventJson(existing.eventJson, params.eventJson);
|
|
60
|
+
if (mergedJson !== existing.eventJson) {
|
|
61
|
+
this.connection.prepare(`
|
|
62
|
+
UPDATE issue_session_events
|
|
63
|
+
SET event_json = ?
|
|
64
|
+
WHERE id = ? AND processed_at IS NULL
|
|
65
|
+
`).run(mergedJson ?? null, existing.id);
|
|
66
|
+
}
|
|
67
|
+
return this.getIssueSessionEvent(existing.id) ?? existing;
|
|
68
|
+
}
|
|
44
69
|
appendIssueSessionEventWithLease(lease, params) {
|
|
45
70
|
return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => this.appendIssueSessionEvent(params));
|
|
46
71
|
}
|
|
@@ -80,6 +105,16 @@ export class IssueSessionStore {
|
|
|
80
105
|
WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
|
|
81
106
|
`).run(now, runId, projectId, linearIssueId, ...eventIds);
|
|
82
107
|
}
|
|
108
|
+
dismissIssueSessionEvents(projectId, linearIssueId, eventIds) {
|
|
109
|
+
if (eventIds.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
const placeholders = eventIds.map(() => "?").join(", ");
|
|
112
|
+
this.connection.prepare(`
|
|
113
|
+
UPDATE issue_session_events
|
|
114
|
+
SET processed_at = ?, consumed_by_run_id = NULL
|
|
115
|
+
WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
|
|
116
|
+
`).run(isoNow(), projectId, linearIssueId, ...eventIds);
|
|
117
|
+
}
|
|
83
118
|
clearPendingIssueSessionEvents(projectId, linearIssueId) {
|
|
84
119
|
this.connection.prepare(`
|
|
85
120
|
UPDATE issue_session_events
|
|
@@ -99,7 +134,7 @@ export class IssueSessionStore {
|
|
|
99
134
|
const plan = deriveSessionWakePlan(issue, events);
|
|
100
135
|
if (plan?.runType) {
|
|
101
136
|
return {
|
|
102
|
-
eventIds:
|
|
137
|
+
eventIds: plan.eventIds,
|
|
103
138
|
runType: plan.runType,
|
|
104
139
|
context: plan.context,
|
|
105
140
|
...(plan.wakeReason ? { wakeReason: plan.wakeReason } : {}),
|
|
@@ -220,6 +255,12 @@ export class IssueSessionStore {
|
|
|
220
255
|
return true;
|
|
221
256
|
}) ?? false;
|
|
222
257
|
}
|
|
258
|
+
dismissIssueSessionEventsWithLease(lease, eventIds) {
|
|
259
|
+
return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
|
|
260
|
+
this.dismissIssueSessionEvents(lease.projectId, lease.linearIssueId, eventIds);
|
|
261
|
+
return true;
|
|
262
|
+
}) ?? false;
|
|
263
|
+
}
|
|
223
264
|
clearPendingIssueSessionEventsWithLease(lease) {
|
|
224
265
|
return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
|
|
225
266
|
this.clearPendingIssueSessionEvents(lease.projectId, lease.linearIssueId);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const REQUIRED_PATCHRELAY_TABLES = [
|
|
2
|
+
"issues",
|
|
3
|
+
"runs",
|
|
4
|
+
"issue_sessions",
|
|
5
|
+
"issue_session_events",
|
|
6
|
+
];
|
|
7
|
+
export function assertPatchRelaySchemaReady(connection, databasePath) {
|
|
8
|
+
const rows = connection
|
|
9
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
10
|
+
.all();
|
|
11
|
+
const tables = new Set(rows.map((row) => String(row.name)));
|
|
12
|
+
const missing = REQUIRED_PATCHRELAY_TABLES.filter((table) => !tables.has(table));
|
|
13
|
+
if (missing.length === 0) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
throw new Error(`PatchRelay database is uninitialized or points at the wrong path: ${databasePath}. Missing required table(s): ${missing.join(", ")}`);
|
|
17
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -7,6 +7,7 @@ import { RepositoryLinkStore } from "./db/repository-link-store.js";
|
|
|
7
7
|
import { RunStore } from "./db/run-store.js";
|
|
8
8
|
import { WebhookEventStore } from "./db/webhook-event-store.js";
|
|
9
9
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
10
|
+
import { assertPatchRelaySchemaReady } from "./db/schema-guard.js";
|
|
10
11
|
import { SqliteConnection } from "./db/shared.js";
|
|
11
12
|
import { syncIssueSessionFromIssue } from "./issue-session-projector.js";
|
|
12
13
|
import { TrackedIssueQuery } from "./tracked-issue-query.js";
|
|
@@ -23,6 +24,7 @@ export class PatchRelayDatabase {
|
|
|
23
24
|
runs;
|
|
24
25
|
trackedIssues;
|
|
25
26
|
constructor(databasePath, wal) {
|
|
27
|
+
this.databasePath = databasePath;
|
|
26
28
|
this.connection = new SqliteConnection(databasePath);
|
|
27
29
|
this.connection.pragma("foreign_keys = ON");
|
|
28
30
|
if (wal) {
|
|
@@ -45,8 +47,13 @@ export class PatchRelayDatabase {
|
|
|
45
47
|
this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
|
|
46
48
|
this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
|
|
47
49
|
}
|
|
50
|
+
databasePath;
|
|
48
51
|
runMigrations() {
|
|
49
52
|
runPatchRelayMigrations(this.connection);
|
|
53
|
+
this.assertSchemaReady();
|
|
54
|
+
}
|
|
55
|
+
assertSchemaReady() {
|
|
56
|
+
assertPatchRelaySchemaReady(this.connection, this.databasePath);
|
|
50
57
|
}
|
|
51
58
|
transaction(fn) {
|
|
52
59
|
return this.connection.transaction(fn)();
|
package/dist/github-cli-auth.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
/**
|
|
5
6
|
* Unified GitHub App credential delivery for `git` and the `gh` CLI.
|
|
@@ -88,3 +89,45 @@ export function buildAgentChildEnv(parentEnv = process.env) {
|
|
|
88
89
|
delete env.GITHUB_TOKEN;
|
|
89
90
|
return env;
|
|
90
91
|
}
|
|
92
|
+
export async function verifyGitHubCliAuthEnv(env = process.env, options = {}) {
|
|
93
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
94
|
+
await new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawn("git", ["credential", "fill"], {
|
|
96
|
+
env,
|
|
97
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
98
|
+
});
|
|
99
|
+
let stderr = "";
|
|
100
|
+
let settled = false;
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
if (settled)
|
|
103
|
+
return;
|
|
104
|
+
settled = true;
|
|
105
|
+
child.kill("SIGTERM");
|
|
106
|
+
reject(new Error(`GitHub git credential check timed out after ${timeoutMs}ms`));
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
child.stderr.on("data", (chunk) => {
|
|
110
|
+
stderr += String(chunk);
|
|
111
|
+
});
|
|
112
|
+
child.on("error", (error) => {
|
|
113
|
+
if (settled)
|
|
114
|
+
return;
|
|
115
|
+
settled = true;
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
reject(error);
|
|
118
|
+
});
|
|
119
|
+
child.on("close", (code) => {
|
|
120
|
+
if (settled)
|
|
121
|
+
return;
|
|
122
|
+
settled = true;
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (code === 0) {
|
|
125
|
+
resolve();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const detail = stderr.trim();
|
|
129
|
+
reject(new Error(`GitHub git credential check failed${detail ? `: ${detail}` : ""}`));
|
|
130
|
+
});
|
|
131
|
+
child.stdin.end(`protocol=https\nhost=${GITHUB_HOST}\n\n`);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -3,6 +3,7 @@ import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
|
|
|
3
3
|
import { isIssueTerminal } from "./pr-state.js";
|
|
4
4
|
import { buildGitHubQueueFailureContext, getRelevantGitHubCiSnapshot, resolveGitHubBranchFailureContext, resolveGitHubCheckClass, } from "./github-webhook-failure-context.js";
|
|
5
5
|
import { isQueueEvictionFailure, isSettledBranchFailure } from "./github-webhook-policy.js";
|
|
6
|
+
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
6
7
|
export async function maybeEnqueueGitHubReactiveRun(params) {
|
|
7
8
|
const { issue, event, project, logger, feed, wakeDispatcher, db, fetchImpl, failureContextResolver } = params;
|
|
8
9
|
if (isIssueTerminal(issue))
|
|
@@ -159,9 +160,18 @@ async function handleRequestedChangesEvent(params) {
|
|
|
159
160
|
}, "Failed to fetch inline review comments for requested-changes event");
|
|
160
161
|
return undefined;
|
|
161
162
|
});
|
|
163
|
+
const identity = buildRequestedChangesWakeIdentity({
|
|
164
|
+
linearIssueId: issue.linearIssueId,
|
|
165
|
+
headSha: issue.prHeadSha ?? event.headSha,
|
|
166
|
+
reviewCommitId: event.reviewCommitId,
|
|
167
|
+
reviewId: event.reviewId,
|
|
168
|
+
reviewerName: event.reviewerName,
|
|
169
|
+
});
|
|
162
170
|
const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
163
171
|
eventType: "review_changes_requested",
|
|
164
172
|
eventJson: JSON.stringify({
|
|
173
|
+
requestedChangesCoalesceKey: identity.coalesceKey,
|
|
174
|
+
...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
|
|
165
175
|
reviewBody: event.reviewBody,
|
|
166
176
|
reviewCommitId: event.reviewCommitId,
|
|
167
177
|
reviewId: event.reviewId,
|
|
@@ -169,11 +179,7 @@ async function handleRequestedChangesEvent(params) {
|
|
|
169
179
|
reviewerName: event.reviewerName,
|
|
170
180
|
...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
|
|
171
181
|
}),
|
|
172
|
-
dedupeKey:
|
|
173
|
-
"review_changes_requested",
|
|
174
|
-
issue.prHeadSha ?? event.headSha ?? "unknown-sha",
|
|
175
|
-
event.reviewerName ?? "unknown-reviewer",
|
|
176
|
-
].join("::"),
|
|
182
|
+
dedupeKey: identity.dedupeKey,
|
|
177
183
|
});
|
|
178
184
|
logger.info({
|
|
179
185
|
issueKey: issue.issueKey,
|
package/dist/http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fastify from "fastify";
|
|
2
2
|
import rawBody from "fastify-raw-body";
|
|
3
3
|
import { getBuildInfo } from "./build-info.js";
|
|
4
|
+
import { getCodexStatusSnapshot } from "./codex-status.js";
|
|
4
5
|
export async function buildHttpServer(config, service, logger) {
|
|
5
6
|
const buildInfo = getBuildInfo();
|
|
6
7
|
const loopbackBind = isLoopbackBind(config.server.bind);
|
|
@@ -244,6 +245,14 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
244
245
|
});
|
|
245
246
|
}
|
|
246
247
|
if (managementRoutesEnabled) {
|
|
248
|
+
app.get("/status", async (_request, reply) => {
|
|
249
|
+
const status = getCodexStatusSnapshot(config.runner.codex.bin);
|
|
250
|
+
return reply.code(status.ok ? 200 : 502).send(status);
|
|
251
|
+
});
|
|
252
|
+
app.get("/api/codex/status", async (_request, reply) => {
|
|
253
|
+
const status = getCodexStatusSnapshot(config.runner.codex.bin);
|
|
254
|
+
return reply.code(status.ok ? 200 : 502).send(status);
|
|
255
|
+
});
|
|
247
256
|
app.get("/api/issues", async (_request, reply) => {
|
|
248
257
|
return reply.send({ ok: true, issues: service.listTrackedIssues() });
|
|
249
258
|
});
|
|
@@ -9,6 +9,7 @@ import { getReviewFixBudget } from "./run-budgets.js";
|
|
|
9
9
|
import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
|
|
10
10
|
import { fetchPullRequestSnapshot } from "./reconcile-pr-fetch.js";
|
|
11
11
|
import { buildPrStateUpdates } from "./reconcile-pr-state-updates.js";
|
|
12
|
+
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
12
13
|
import { execCommand } from "./utils.js";
|
|
13
14
|
export class IdleIssueReconciler {
|
|
14
15
|
db;
|
|
@@ -288,15 +289,34 @@ export class IdleIssueReconciler {
|
|
|
288
289
|
}
|
|
289
290
|
else if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
290
291
|
eventType = "review_changes_requested";
|
|
291
|
-
dedupeKey =
|
|
292
|
+
dedupeKey = buildRequestedChangesWakeIdentity({
|
|
293
|
+
linearIssueId: issue.linearIssueId,
|
|
294
|
+
runType,
|
|
295
|
+
headSha: issue.prHeadSha,
|
|
296
|
+
}).dedupeKey;
|
|
292
297
|
}
|
|
293
298
|
else {
|
|
294
299
|
eventType = "delegated";
|
|
295
300
|
dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
|
|
296
301
|
}
|
|
302
|
+
const requestedChangesIdentity = eventType === "review_changes_requested"
|
|
303
|
+
? buildRequestedChangesWakeIdentity({
|
|
304
|
+
linearIssueId: issue.linearIssueId,
|
|
305
|
+
runType: runType === "branch_upkeep" ? "branch_upkeep" : "review_fix",
|
|
306
|
+
headSha: issue.prHeadSha,
|
|
307
|
+
})
|
|
308
|
+
: undefined;
|
|
297
309
|
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
298
310
|
eventType,
|
|
299
|
-
...(context
|
|
311
|
+
...(context || requestedChangesIdentity ? {
|
|
312
|
+
eventJson: JSON.stringify({
|
|
313
|
+
...context,
|
|
314
|
+
...(requestedChangesIdentity ? {
|
|
315
|
+
requestedChangesCoalesceKey: requestedChangesIdentity.coalesceKey,
|
|
316
|
+
...(requestedChangesIdentity.headSha ? { requestedChangesHeadSha: requestedChangesIdentity.headSha } : {}),
|
|
317
|
+
} : {}),
|
|
318
|
+
}),
|
|
319
|
+
} : {}),
|
|
300
320
|
dedupeKey,
|
|
301
321
|
});
|
|
302
322
|
}
|
|
@@ -10,6 +10,7 @@ const TERMINAL_SESSION_EVENTS = new Set([
|
|
|
10
10
|
const NON_ACTIONABLE_SESSION_EVENTS = new Set([
|
|
11
11
|
"delegation_observed",
|
|
12
12
|
"prompt_delivered",
|
|
13
|
+
"self_comment",
|
|
13
14
|
"run_released_authority",
|
|
14
15
|
]);
|
|
15
16
|
// "main_repair" was removed as a run type; legacy session-event payloads carrying it
|
|
@@ -28,6 +29,7 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
28
29
|
}
|
|
29
30
|
const context = {};
|
|
30
31
|
const followUps = [];
|
|
32
|
+
let eventIds = [];
|
|
31
33
|
let wakeReason;
|
|
32
34
|
let runType;
|
|
33
35
|
let resumeThread = false;
|
|
@@ -37,12 +39,14 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
37
39
|
case "merge_steward_incident":
|
|
38
40
|
runType = "queue_repair";
|
|
39
41
|
wakeReason = "merge_steward_incident";
|
|
42
|
+
eventIds = [event.id];
|
|
40
43
|
Object.assign(context, payload ?? {});
|
|
41
44
|
break;
|
|
42
45
|
case "settled_red_ci":
|
|
43
46
|
if (runType !== "queue_repair") {
|
|
44
47
|
runType = "ci_repair";
|
|
45
48
|
wakeReason = "settled_red_ci";
|
|
49
|
+
eventIds = [event.id];
|
|
46
50
|
Object.assign(context, payload ?? {});
|
|
47
51
|
}
|
|
48
52
|
break;
|
|
@@ -50,6 +54,7 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
50
54
|
if (runType !== "queue_repair" && runType !== "ci_repair") {
|
|
51
55
|
runType = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_fix";
|
|
52
56
|
wakeReason = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_changes_requested";
|
|
57
|
+
eventIds = [event.id];
|
|
53
58
|
Object.assign(context, payload ?? {});
|
|
54
59
|
}
|
|
55
60
|
break;
|
|
@@ -57,6 +62,10 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
57
62
|
if (!runType) {
|
|
58
63
|
runType = parseRunType(payload?.runType) ?? "implementation";
|
|
59
64
|
wakeReason = issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
|
|
65
|
+
eventIds = [event.id];
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
eventIds.push(event.id);
|
|
60
69
|
}
|
|
61
70
|
Object.assign(context, payload ?? {});
|
|
62
71
|
break;
|
|
@@ -66,6 +75,10 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
66
75
|
if (!runType) {
|
|
67
76
|
runType = "implementation";
|
|
68
77
|
wakeReason = event.eventType;
|
|
78
|
+
eventIds = [event.id];
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
eventIds.push(event.id);
|
|
69
82
|
}
|
|
70
83
|
Object.assign(context, payload ?? {});
|
|
71
84
|
resumeThread = true;
|
|
@@ -74,6 +87,10 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
74
87
|
if (!runType) {
|
|
75
88
|
runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
|
|
76
89
|
wakeReason = "direct_reply";
|
|
90
|
+
eventIds = [event.id];
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
eventIds.push(event.id);
|
|
77
94
|
}
|
|
78
95
|
const text = typeof payload?.text === "string"
|
|
79
96
|
? payload.text
|
|
@@ -94,6 +111,10 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
94
111
|
runType = parseRunType(payload?.runType)
|
|
95
112
|
?? (issue.prReviewState === "changes_requested" ? "review_fix" : "implementation");
|
|
96
113
|
wakeReason = "completion_check_continue";
|
|
114
|
+
eventIds = [event.id];
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
eventIds.push(event.id);
|
|
97
118
|
}
|
|
98
119
|
if (typeof payload?.summary === "string" && payload.summary.trim()) {
|
|
99
120
|
context.completionCheckSummary = payload.summary.trim();
|
|
@@ -108,6 +129,10 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
108
129
|
if (!runType) {
|
|
109
130
|
runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
|
|
110
131
|
wakeReason = issue.issueClass === "orchestration" ? "human_instruction" : event.eventType;
|
|
132
|
+
eventIds = [event.id];
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
eventIds.push(event.id);
|
|
111
136
|
}
|
|
112
137
|
const text = typeof payload?.text === "string"
|
|
113
138
|
? payload.text
|
|
@@ -151,7 +176,7 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
151
176
|
if (wakeReason) {
|
|
152
177
|
context.wakeReason = wakeReason;
|
|
153
178
|
}
|
|
154
|
-
return { runType, wakeReason, resumeThread, context };
|
|
179
|
+
return { eventIds, runType, wakeReason, resumeThread, context };
|
|
155
180
|
}
|
|
156
181
|
export function isActionableIssueSessionEventType(eventType) {
|
|
157
182
|
return !NON_ACTIONABLE_SESSION_EVENTS.has(eventType);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
1
2
|
function parseObjectJson(value) {
|
|
2
3
|
if (!value)
|
|
3
4
|
return undefined;
|
|
@@ -42,16 +43,23 @@ export function buildOperatorRetryEvent(issue, runType, source = "operator_retry
|
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
46
|
+
const identity = buildRequestedChangesWakeIdentity({
|
|
47
|
+
linearIssueId: issue.linearIssueId,
|
|
48
|
+
runType,
|
|
49
|
+
headSha: issue.prHeadSha,
|
|
50
|
+
});
|
|
45
51
|
return {
|
|
46
52
|
eventType: "review_changes_requested",
|
|
47
53
|
eventJson: JSON.stringify({
|
|
54
|
+
requestedChangesCoalesceKey: identity.coalesceKey,
|
|
55
|
+
...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
|
|
48
56
|
...(runType === "branch_upkeep"
|
|
49
57
|
? { reviewBody: `${humanizeSource(source)} requested retry of branch upkeep after requested changes.` }
|
|
50
58
|
: { promptContext: `${humanizeSource(source)} requested retry of review-fix work.` }),
|
|
51
59
|
...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
|
|
52
60
|
source,
|
|
53
61
|
}),
|
|
54
|
-
dedupeKey:
|
|
62
|
+
dedupeKey: identity.dedupeKey,
|
|
55
63
|
};
|
|
56
64
|
}
|
|
57
65
|
return {
|
package/dist/preflight.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
4
|
+
import { assertPatchRelaySchemaReady } from "./db/schema-guard.js";
|
|
4
5
|
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
5
6
|
import { SqliteConnection } from "./db/shared.js";
|
|
6
7
|
import { execCommand } from "./utils.js";
|
|
@@ -132,6 +133,7 @@ function checkDatabaseHealth(config) {
|
|
|
132
133
|
connection.pragma("journal_mode = WAL");
|
|
133
134
|
}
|
|
134
135
|
runPatchRelayMigrations(connection);
|
|
136
|
+
assertPatchRelaySchemaReady(connection, config.database.path);
|
|
135
137
|
const quickCheck = connection.prepare("PRAGMA quick_check").get();
|
|
136
138
|
const quickCheckResult = quickCheck ? Object.values(quickCheck)[0] : undefined;
|
|
137
139
|
if (quickCheckResult !== "ok") {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const UNKNOWN_HEAD = "unknown-sha";
|
|
2
|
+
export function buildRequestedChangesWakeIdentity(params) {
|
|
3
|
+
const runType = params.runType ?? "review_fix";
|
|
4
|
+
const headSha = params.reviewCommitId ?? params.headSha;
|
|
5
|
+
const coalesceHead = headSha ?? UNKNOWN_HEAD;
|
|
6
|
+
const coalesceKey = `review_changes_requested:${runType}:issue:${params.linearIssueId}:head:${coalesceHead}`;
|
|
7
|
+
if (params.reviewId !== undefined && params.reviewId !== null) {
|
|
8
|
+
return {
|
|
9
|
+
dedupeKey: `review_changes_requested:${runType}:issue:${params.linearIssueId}:review:${params.reviewId}`,
|
|
10
|
+
coalesceKey,
|
|
11
|
+
...(headSha ? { headSha } : {}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
if (headSha && params.reviewerName) {
|
|
15
|
+
return {
|
|
16
|
+
dedupeKey: `review_changes_requested:${runType}:issue:${params.linearIssueId}:head:${headSha}:reviewer:${params.reviewerName}`,
|
|
17
|
+
coalesceKey,
|
|
18
|
+
headSha,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
dedupeKey: coalesceKey,
|
|
23
|
+
coalesceKey,
|
|
24
|
+
...(headSha ? { headSha } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function readRequestedChangesCoalesceKey(eventJson) {
|
|
28
|
+
if (!eventJson)
|
|
29
|
+
return undefined;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(eventJson);
|
|
32
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
33
|
+
return undefined;
|
|
34
|
+
const value = parsed.requestedChangesCoalesceKey;
|
|
35
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function mergeRequestedChangesEventJson(existingJson, incomingJson) {
|
|
42
|
+
if (!incomingJson)
|
|
43
|
+
return existingJson;
|
|
44
|
+
const incoming = parseObject(incomingJson);
|
|
45
|
+
if (!incoming)
|
|
46
|
+
return existingJson;
|
|
47
|
+
const existing = parseObject(existingJson);
|
|
48
|
+
if (!existing)
|
|
49
|
+
return incomingJson;
|
|
50
|
+
return JSON.stringify({ ...existing, ...incoming });
|
|
51
|
+
}
|
|
52
|
+
function parseObject(raw) {
|
|
53
|
+
if (!raw)
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
58
|
+
? parsed
|
|
59
|
+
: undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -4,6 +4,7 @@ import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
|
|
|
4
4
|
function isRequestedChangesRunType(runType) {
|
|
5
5
|
return runType === "review_fix" || runType === "branch_upkeep";
|
|
6
6
|
}
|
|
7
|
+
const DEFAULT_PUBLISH_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
8
|
export class RunNotificationHandler {
|
|
8
9
|
config;
|
|
9
10
|
db;
|
|
@@ -15,8 +16,10 @@ export class RunNotificationHandler {
|
|
|
15
16
|
heartbeatIssueSessionLease;
|
|
16
17
|
releaseIssueSessionLease;
|
|
17
18
|
feed;
|
|
19
|
+
options;
|
|
18
20
|
activeThreadId;
|
|
19
|
-
|
|
21
|
+
publishCommandWatchdogs = new Map();
|
|
22
|
+
constructor(config, db, logger, linearSync, runFinalizer, readThreadWithRetry, withHeldIssueSessionLease, heartbeatIssueSessionLease, releaseIssueSessionLease, feed, options = {}) {
|
|
20
23
|
this.config = config;
|
|
21
24
|
this.db = db;
|
|
22
25
|
this.logger = logger;
|
|
@@ -27,6 +30,7 @@ export class RunNotificationHandler {
|
|
|
27
30
|
this.heartbeatIssueSessionLease = heartbeatIssueSessionLease;
|
|
28
31
|
this.releaseIssueSessionLease = releaseIssueSessionLease;
|
|
29
32
|
this.feed = feed;
|
|
33
|
+
this.options = options;
|
|
30
34
|
}
|
|
31
35
|
async handle(notification) {
|
|
32
36
|
let threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
|
|
@@ -50,6 +54,7 @@ export class RunNotificationHandler {
|
|
|
50
54
|
return;
|
|
51
55
|
}
|
|
52
56
|
const turnId = typeof notification.params.turnId === "string" ? notification.params.turnId : undefined;
|
|
57
|
+
this.observePublishCommand(notification, run, threadId, turnId ?? run.turnId);
|
|
53
58
|
if (this.config.runner.codex.persistExtendedHistory) {
|
|
54
59
|
this.db.runs.saveThreadEvent({
|
|
55
60
|
runId: run.id,
|
|
@@ -59,15 +64,13 @@ export class RunNotificationHandler {
|
|
|
59
64
|
eventJson: JSON.stringify(notification.params),
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
|
-
this.
|
|
67
|
+
this.maybeEmitProgress(notification, run);
|
|
63
68
|
if (notification.method === "turn/plan/updated") {
|
|
64
|
-
|
|
65
|
-
if (issue) {
|
|
66
|
-
void this.linearSync.syncCodexPlan(issue, notification.params);
|
|
67
|
-
}
|
|
69
|
+
this.syncCodexPlan(notification, run);
|
|
68
70
|
}
|
|
69
71
|
if (notification.method !== "turn/completed")
|
|
70
72
|
return;
|
|
73
|
+
this.clearPublishWatchdogsForThread(threadId);
|
|
71
74
|
const thread = await this.readThreadWithRetry(threadId);
|
|
72
75
|
const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
|
|
73
76
|
if (!issue)
|
|
@@ -137,4 +140,120 @@ export class RunNotificationHandler {
|
|
|
137
140
|
});
|
|
138
141
|
this.activeThreadId = undefined;
|
|
139
142
|
}
|
|
143
|
+
observePublishCommand(notification, run, threadId, turnId) {
|
|
144
|
+
const item = notification.params.item;
|
|
145
|
+
if (!item || typeof item !== "object") {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const itemRecord = item;
|
|
149
|
+
const itemId = typeof itemRecord.id === "string" ? itemRecord.id : undefined;
|
|
150
|
+
if (!itemId) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (notification.method === "item/completed" || isTerminalItemUpdate(notification.method, itemRecord)) {
|
|
154
|
+
this.clearPublishWatchdog(threadId, itemId);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (notification.method !== "item/started" || itemRecord.type !== "commandExecution" || !turnId || !this.options.interruptTurn) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const command = extractCommandText(itemRecord.command);
|
|
161
|
+
if (!command || !isGitPushCommand(command)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const key = publishWatchdogKey(threadId, itemId);
|
|
165
|
+
if (this.publishCommandWatchdogs.has(key)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const timeoutMs = this.options.publishCommandTimeoutMs ?? DEFAULT_PUBLISH_COMMAND_TIMEOUT_MS;
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
this.publishCommandWatchdogs.delete(key);
|
|
171
|
+
this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, threadId, turnId, timeoutMs }, "Interrupting stuck git push command");
|
|
172
|
+
this.feed?.publish({
|
|
173
|
+
level: "error",
|
|
174
|
+
kind: "turn",
|
|
175
|
+
projectId: run.projectId,
|
|
176
|
+
stage: run.runType,
|
|
177
|
+
status: "interrupted",
|
|
178
|
+
summary: `Interrupted stuck publish command after ${Math.round(timeoutMs / 1000)}s`,
|
|
179
|
+
});
|
|
180
|
+
void this.options.interruptTurn?.({ threadId, turnId }).catch((error) => {
|
|
181
|
+
this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, threadId, turnId, error: formatError(error) }, "Failed to interrupt stuck git push command");
|
|
182
|
+
});
|
|
183
|
+
}, timeoutMs);
|
|
184
|
+
timer.unref?.();
|
|
185
|
+
this.publishCommandWatchdogs.set(key, timer);
|
|
186
|
+
}
|
|
187
|
+
clearPublishWatchdog(threadId, itemId) {
|
|
188
|
+
const key = publishWatchdogKey(threadId, itemId);
|
|
189
|
+
const timer = this.publishCommandWatchdogs.get(key);
|
|
190
|
+
if (!timer) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
this.publishCommandWatchdogs.delete(key);
|
|
195
|
+
}
|
|
196
|
+
clearPublishWatchdogsForThread(threadId) {
|
|
197
|
+
for (const [key, timer] of this.publishCommandWatchdogs) {
|
|
198
|
+
if (!key.startsWith(`${threadId}:`)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
this.publishCommandWatchdogs.delete(key);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
maybeEmitProgress(notification, run) {
|
|
206
|
+
try {
|
|
207
|
+
this.linearSync.maybeEmitProgress(notification, run);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear progress reporting failed");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
syncCodexPlan(notification, run) {
|
|
214
|
+
let issue;
|
|
215
|
+
try {
|
|
216
|
+
issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear plan sync lookup failed");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!issue)
|
|
223
|
+
return;
|
|
224
|
+
try {
|
|
225
|
+
void this.linearSync.syncCodexPlan(issue, notification.params).catch((error) => {
|
|
226
|
+
this.logger.warn({ runId: run.id, issueKey: issue.issueKey, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear plan sync failed");
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
this.logger.warn({ runId: run.id, issueKey: issue.issueKey, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear plan sync failed");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function formatError(error) {
|
|
235
|
+
return error instanceof Error ? error.message : String(error);
|
|
236
|
+
}
|
|
237
|
+
function publishWatchdogKey(threadId, itemId) {
|
|
238
|
+
return `${threadId}:${itemId}`;
|
|
239
|
+
}
|
|
240
|
+
function extractCommandText(command) {
|
|
241
|
+
if (typeof command === "string") {
|
|
242
|
+
return command;
|
|
243
|
+
}
|
|
244
|
+
if (Array.isArray(command)) {
|
|
245
|
+
return command.map((entry) => String(entry)).join(" ");
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
function isGitPushCommand(command) {
|
|
250
|
+
const normalized = command.replace(/\s+/g, " ").trim();
|
|
251
|
+
return /(?:^|[;&|({\s])git(?:\s+-C\s+\S+)?\s+push(?:\s|$)/.test(normalized);
|
|
252
|
+
}
|
|
253
|
+
function isTerminalItemUpdate(method, item) {
|
|
254
|
+
if (method !== "item/updated") {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const status = typeof item.status === "string" ? item.status.toLowerCase() : "";
|
|
258
|
+
return status === "completed" || status === "failed" || status === "cancelled" || status === "canceled";
|
|
140
259
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -129,7 +129,7 @@ export class RunOrchestrator {
|
|
|
129
129
|
this.issueTriage = new IssueTriageService(codex, logger);
|
|
130
130
|
this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.wakeDispatcher, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, this.publicationRecap, feed);
|
|
131
131
|
this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
|
|
132
|
-
this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
|
|
132
|
+
this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed, { interruptTurn: (options) => codex.interruptTurn(options) });
|
|
133
133
|
this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
|
|
134
134
|
this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
|
|
135
135
|
this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, (projectId) => this.config.projects.find((project) => project.id === projectId)?.github?.repoFullName, feed);
|
|
@@ -308,6 +308,42 @@ export class RunOrchestrator {
|
|
|
308
308
|
const baseContext = isRequestedChangesRunType(runType)
|
|
309
309
|
? await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context)
|
|
310
310
|
: context;
|
|
311
|
+
const launchIssue = this.db.issues.getIssue(item.projectId, item.issueId) ?? issue;
|
|
312
|
+
const inactiveRequestedChangesWakeReason = this.resolveInactiveRequestedChangesWakeReason(launchIssue, runType, baseContext);
|
|
313
|
+
if (inactiveRequestedChangesWakeReason) {
|
|
314
|
+
const lease = { projectId: item.projectId, linearIssueId: item.issueId, leaseId };
|
|
315
|
+
const requestedChangesEventIds = this.db.issueSessions
|
|
316
|
+
.listIssueSessionEvents(item.projectId, item.issueId, { pendingOnly: true })
|
|
317
|
+
.filter((event) => wake.eventIds.includes(event.id) && event.eventType === "review_changes_requested")
|
|
318
|
+
.map((event) => event.id);
|
|
319
|
+
const dismissed = this.db.issueSessions.dismissIssueSessionEventsWithLease(lease, requestedChangesEventIds);
|
|
320
|
+
if (!dismissed) {
|
|
321
|
+
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
322
|
+
this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "lease_lost_dismissing_inactive_requested_changes_wake" }, "Skipped issue run: lost lease while dismissing inactive requested-changes wake");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
this.db.issueSessions.setIssueSessionLastWakeReasonWithLease(lease, wake.wakeReason ?? null);
|
|
326
|
+
this.feed?.publish({
|
|
327
|
+
level: "info",
|
|
328
|
+
kind: "stage",
|
|
329
|
+
issueKey: issue.issueKey,
|
|
330
|
+
projectId: item.projectId,
|
|
331
|
+
stage: runType,
|
|
332
|
+
status: "skipped",
|
|
333
|
+
summary: inactiveRequestedChangesWakeReason,
|
|
334
|
+
});
|
|
335
|
+
this.logger.info({
|
|
336
|
+
issueKey: issue.issueKey,
|
|
337
|
+
projectId: item.projectId,
|
|
338
|
+
runType,
|
|
339
|
+
reason: "inactive_requested_changes_wake",
|
|
340
|
+
prReviewState: launchIssue.prReviewState,
|
|
341
|
+
prState: launchIssue.prState,
|
|
342
|
+
}, "Skipped issue run: requested-changes wake is no longer active");
|
|
343
|
+
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
344
|
+
this.wakeDispatcher.dispatchIfWakePending(item.projectId, item.issueId);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
311
347
|
const recoveredLinearActivityContext = await recoverLinearAgentActivityContext({
|
|
312
348
|
linearProvider: this.linearProvider,
|
|
313
349
|
projectId: issue.projectId,
|
|
@@ -506,6 +542,18 @@ export class RunOrchestrator {
|
|
|
506
542
|
async resolveRequestedChangesWakeContext(issue, runType, context) {
|
|
507
543
|
return await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context);
|
|
508
544
|
}
|
|
545
|
+
resolveInactiveRequestedChangesWakeReason(issue, runType, context) {
|
|
546
|
+
if (runType !== "review_fix" || context?.branchUpkeepRequired === true) {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
if (issue.prState && issue.prState !== "open") {
|
|
550
|
+
return `Skipping requested-changes run because PR #${issue.prNumber ?? "unknown"} is ${issue.prState}`;
|
|
551
|
+
}
|
|
552
|
+
if (issue.prReviewState && issue.prReviewState !== "changes_requested") {
|
|
553
|
+
return `Skipping requested-changes run because PR #${issue.prNumber ?? "unknown"} review state is ${issue.prReviewState}`;
|
|
554
|
+
}
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
509
557
|
async readThreadWithRetry(threadId, maxRetries = 3) {
|
|
510
558
|
let lastError;
|
|
511
559
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
package/dist/run-wake-planner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
|
|
2
|
+
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
2
3
|
export class RunWakePlanner {
|
|
3
4
|
db;
|
|
4
5
|
constructor(db) {
|
|
@@ -19,6 +20,7 @@ export class RunWakePlanner {
|
|
|
19
20
|
appendWakeEventWithLease(lease, issue, runType, context, dedupeScope) {
|
|
20
21
|
let eventType;
|
|
21
22
|
let dedupeKey;
|
|
23
|
+
let eventContext = context;
|
|
22
24
|
if (runType === "queue_repair") {
|
|
23
25
|
eventType = "merge_steward_incident";
|
|
24
26
|
dedupeKey = `${dedupeScope ?? "wake"}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`;
|
|
@@ -29,7 +31,17 @@ export class RunWakePlanner {
|
|
|
29
31
|
}
|
|
30
32
|
else if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
31
33
|
eventType = "review_changes_requested";
|
|
32
|
-
|
|
34
|
+
const identity = buildRequestedChangesWakeIdentity({
|
|
35
|
+
linearIssueId: issue.linearIssueId,
|
|
36
|
+
runType,
|
|
37
|
+
headSha: issue.prHeadSha,
|
|
38
|
+
});
|
|
39
|
+
dedupeKey = identity.dedupeKey;
|
|
40
|
+
eventContext = {
|
|
41
|
+
...context,
|
|
42
|
+
requestedChangesCoalesceKey: identity.coalesceKey,
|
|
43
|
+
...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
|
|
44
|
+
};
|
|
33
45
|
}
|
|
34
46
|
else {
|
|
35
47
|
eventType = "delegated";
|
|
@@ -39,7 +51,7 @@ export class RunWakePlanner {
|
|
|
39
51
|
projectId: issue.projectId,
|
|
40
52
|
linearIssueId: issue.linearIssueId,
|
|
41
53
|
eventType,
|
|
42
|
-
...(
|
|
54
|
+
...(eventContext ? { eventJson: JSON.stringify(eventContext) } : {}),
|
|
43
55
|
dedupeKey,
|
|
44
56
|
}));
|
|
45
57
|
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -65,7 +65,7 @@ export class ServiceRuntime {
|
|
|
65
65
|
}
|
|
66
66
|
getReadiness() {
|
|
67
67
|
return {
|
|
68
|
-
ready: this.ready && this.codex.isStarted() && this.linearConnected,
|
|
68
|
+
ready: this.ready && this.codex.isStarted() && this.linearConnected && this.githubAppAuthHealthy,
|
|
69
69
|
codexStarted: this.codex.isStarted(),
|
|
70
70
|
linearConnected: this.linearConnected,
|
|
71
71
|
githubAppAuthHealthy: this.githubAppAuthHealthy,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendDelegationObservedEvent } from "./delegation-audit.js";
|
|
2
2
|
import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
3
3
|
import { isResumablePausedLocalWork } from "./paused-issue-state.js";
|
|
4
|
+
import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
|
|
4
5
|
export class ServiceStartupRecovery {
|
|
5
6
|
db;
|
|
6
7
|
linearProvider;
|
|
@@ -178,11 +179,28 @@ export class ServiceStartupRecovery {
|
|
|
178
179
|
? `startup_recovery:queue_repair:${linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`
|
|
179
180
|
: runType === "ci_repair"
|
|
180
181
|
? `startup_recovery:ci_repair:${linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`
|
|
181
|
-
:
|
|
182
|
+
: buildRequestedChangesWakeIdentity({
|
|
183
|
+
linearIssueId,
|
|
184
|
+
runType,
|
|
185
|
+
headSha: issue.prHeadSha,
|
|
186
|
+
}).dedupeKey;
|
|
187
|
+
const requestedChangesIdentity = eventType === "review_changes_requested"
|
|
188
|
+
? buildRequestedChangesWakeIdentity({
|
|
189
|
+
linearIssueId,
|
|
190
|
+
runType: runType === "branch_upkeep" ? "branch_upkeep" : "review_fix",
|
|
191
|
+
headSha: issue.prHeadSha,
|
|
192
|
+
})
|
|
193
|
+
: undefined;
|
|
182
194
|
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
|
|
183
195
|
projectId,
|
|
184
196
|
linearIssueId,
|
|
185
197
|
eventType,
|
|
198
|
+
...(requestedChangesIdentity ? {
|
|
199
|
+
eventJson: JSON.stringify({
|
|
200
|
+
requestedChangesCoalesceKey: requestedChangesIdentity.coalesceKey,
|
|
201
|
+
...(requestedChangesIdentity.headSha ? { requestedChangesHeadSha: requestedChangesIdentity.headSha } : {}),
|
|
202
|
+
}),
|
|
203
|
+
} : {}),
|
|
186
204
|
dedupeKey,
|
|
187
205
|
});
|
|
188
206
|
}
|
package/dist/service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveGitHubAppCredentials, createGitHubAppTokenManager, getGitHubAppPaths, } from "./github-app-token.js";
|
|
2
|
-
import { applyGitHubCliAuthEnv, resolveGhBin } from "./github-cli-auth.js";
|
|
2
|
+
import { applyGitHubCliAuthEnv, resolveGhBin, verifyGitHubCliAuthEnv } from "./github-cli-auth.js";
|
|
3
3
|
import { remediateLeakedBotAuth } from "./github-auth-remediation.js";
|
|
4
4
|
import { GitHubWebhookHandler } from "./github-webhook-handler.js";
|
|
5
5
|
import { IssueQueryService } from "./issue-query-service.js";
|
|
@@ -149,8 +149,18 @@ export class PatchRelayService {
|
|
|
149
149
|
this.runtime.setGithubAppAuthHealthy(ghAuthStatus.healthy, ghAuthStatus.lastRefreshError ?? undefined);
|
|
150
150
|
if (!ghAuthStatus.healthy) {
|
|
151
151
|
this.logger.error({ ghAuthStatus }, "GitHub App auth is NOT healthy at startup — git/gh operations will fail until a token is minted");
|
|
152
|
+
throw new Error(`GitHub App auth is not healthy at startup: ${ghAuthStatus.lastRefreshError ?? "no fresh installation token"}`);
|
|
152
153
|
}
|
|
153
154
|
else {
|
|
155
|
+
try {
|
|
156
|
+
await verifyGitHubCliAuthEnv(process.env);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
160
|
+
this.runtime.setGithubAppAuthHealthy(false, msg);
|
|
161
|
+
this.logger.error({ error: msg, ghConfigDir }, "GitHub App auth smoke test failed — service will not accept work");
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
154
164
|
this.logger.info({ installationId: ghAuthStatus.installationId, expiresAt: ghAuthStatus.expiresAt }, "GitHub App auth ready — gh + git authenticate as the bot");
|
|
155
165
|
}
|
|
156
166
|
// Clean up credentials older versions persisted into managed repo configs.
|
package/infra/patchrelay.service
CHANGED
|
@@ -16,6 +16,7 @@ WorkingDirectory=/home/your-user
|
|
|
16
16
|
EnvironmentFile=-/home/your-user/.config/patchrelay/runtime.env
|
|
17
17
|
EnvironmentFile=-/home/your-user/.config/patchrelay/service.env
|
|
18
18
|
Environment=NODE_ENV=production
|
|
19
|
+
Environment=CODEX_HOME=/home/alv/.codex-patchrelay
|
|
19
20
|
Environment=PATCHRELAY_CONFIG=/home/your-user/.config/patchrelay/patchrelay.json
|
|
20
21
|
Environment=PATH=/home/your-user/.local/share/patchrelay/bin:/home/your-user/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
21
22
|
|
|
@@ -43,7 +44,7 @@ PrivateTmp=true
|
|
|
43
44
|
PrivateMounts=true
|
|
44
45
|
ProtectSystem=strict
|
|
45
46
|
ProtectHome=false
|
|
46
|
-
ReadWritePaths=/home/your-user/.config/patchrelay /home/your-user/.local/state/patchrelay /home/your-user/.local/share/patchrelay
|
|
47
|
+
ReadWritePaths=/home/your-user/.config/patchrelay /home/your-user/.local/state/patchrelay /home/your-user/.local/share/patchrelay /home/alv/.codex-patchrelay
|
|
47
48
|
|
|
48
49
|
# PatchRelay runs as your real user so Codex inherits your existing git,
|
|
49
50
|
# SSH, and local tool permissions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "patchrelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -31,17 +31,17 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"fastify": "^5.8.5",
|
|
33
33
|
"fastify-raw-body": "^5.0.0",
|
|
34
|
-
"ink": "^7.0.
|
|
34
|
+
"ink": "^7.0.4",
|
|
35
35
|
"pino": "^10.3.1",
|
|
36
36
|
"pino-logfmt": "^1.1.4",
|
|
37
|
-
"react": "^19.2.
|
|
38
|
-
"zod": "^4.3
|
|
37
|
+
"react": "^19.2.6",
|
|
38
|
+
"zod": "^4.4.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@types/node": "^25.
|
|
42
|
-
"@types/react": "^19.2.
|
|
43
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
44
|
-
"oxlint": "^1.
|
|
41
|
+
"@types/node": "^25.9.1",
|
|
42
|
+
"@types/react": "^19.2.15",
|
|
43
|
+
"@typescript/native-preview": "7.0.0-dev.20260527.1",
|
|
44
|
+
"oxlint": "^1.67.0"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"dev": "node --watch --experimental-transform-types src/index.ts",
|