patchrelay 0.39.0 → 0.40.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/issues.js +41 -1
- package/dist/cli/data.js +67 -0
- package/dist/cli/formatters/text.js +55 -0
- package/dist/cli/help.js +5 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/watch/state-visualization.js +1 -1
- package/dist/codex-session-source.js +85 -0
- package/dist/db/issue-session-store.js +3 -8
- package/dist/delegation-audit.js +39 -0
- package/dist/issue-overview-query.js +2 -1
- package/dist/issue-session-events.js +11 -3
- package/dist/merged-linear-completion-reconciler.js +84 -2
- package/dist/run-reconciler.js +131 -22
- package/dist/service-runtime.js +1 -2
- package/dist/service-startup-recovery.js +19 -0
- package/dist/service.js +4 -1
- package/dist/waiting-reason.js +1 -1
- package/dist/webhooks/desired-stage-recorder.js +56 -3
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
2
2
|
import { getRunTypeFlag } from "../args.js";
|
|
3
3
|
import { CliUsageError } from "../errors.js";
|
|
4
4
|
import { formatJson } from "../formatters/json.js";
|
|
5
|
-
import { formatClose, formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatWorktree } from "../formatters/text.js";
|
|
5
|
+
import { formatAudit, formatClose, formatInspect, formatList, formatLive, formatOpen, formatRetry, formatSessionHistory, formatTranscriptSource, 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) {
|
|
@@ -36,6 +36,10 @@ export async function handleIssueCommand(params) {
|
|
|
36
36
|
return await handleOpenCommand(nested);
|
|
37
37
|
case "sessions":
|
|
38
38
|
return await handleSessionsCommand(nested);
|
|
39
|
+
case "audit":
|
|
40
|
+
return await handleAuditCommand(nested);
|
|
41
|
+
case "transcript-source":
|
|
42
|
+
return await handleTranscriptSourceCommand(nested);
|
|
39
43
|
case "retry":
|
|
40
44
|
return await handleRetryCommand(nested);
|
|
41
45
|
case "close":
|
|
@@ -116,6 +120,30 @@ export async function handleOpenCommand(params) {
|
|
|
116
120
|
const openCommand = buildOpenCommand(params.config, result.worktreePath, result.resumeThreadId);
|
|
117
121
|
return await params.runInteractive(openCommand.command, openCommand.args);
|
|
118
122
|
}
|
|
123
|
+
export async function handleTranscriptSourceCommand(params) {
|
|
124
|
+
const issueKey = params.commandArgs[0];
|
|
125
|
+
if (!issueKey) {
|
|
126
|
+
throw new Error("transcript-source requires <issueKey>.");
|
|
127
|
+
}
|
|
128
|
+
const runFlag = params.parsed.flags.get("run");
|
|
129
|
+
let runId;
|
|
130
|
+
if (typeof runFlag === "string") {
|
|
131
|
+
const parsedRunId = Number(runFlag);
|
|
132
|
+
if (!Number.isSafeInteger(parsedRunId) || parsedRunId <= 0) {
|
|
133
|
+
throw new Error("--run must be a positive integer.");
|
|
134
|
+
}
|
|
135
|
+
runId = parsedRunId;
|
|
136
|
+
}
|
|
137
|
+
const result = params.data.transcriptSource(issueKey, runId);
|
|
138
|
+
if (!result) {
|
|
139
|
+
throw new Error(`Issue not found: ${issueKey}`);
|
|
140
|
+
}
|
|
141
|
+
if (runId !== undefined && result.runId !== runId) {
|
|
142
|
+
throw new Error(`Run not found for ${issueKey}: ${runId}`);
|
|
143
|
+
}
|
|
144
|
+
writeOutput(params.stdout, params.json ? formatJson(result) : formatTranscriptSource(result));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
119
147
|
export async function handleSessionsCommand(params) {
|
|
120
148
|
const issueKey = params.commandArgs[0];
|
|
121
149
|
if (!issueKey) {
|
|
@@ -130,6 +158,18 @@ export async function handleSessionsCommand(params) {
|
|
|
130
158
|
: formatSessionHistory(result, (threadId) => buildOpenCommand(params.config, result.worktreePath ?? "", threadId)));
|
|
131
159
|
return 0;
|
|
132
160
|
}
|
|
161
|
+
export async function handleAuditCommand(params) {
|
|
162
|
+
const issueKey = params.commandArgs[0];
|
|
163
|
+
if (!issueKey) {
|
|
164
|
+
throw new Error("audit requires <issueKey>.");
|
|
165
|
+
}
|
|
166
|
+
const result = params.data.audit(issueKey);
|
|
167
|
+
if (!result) {
|
|
168
|
+
throw new Error(`Issue not found: ${issueKey}`);
|
|
169
|
+
}
|
|
170
|
+
writeOutput(params.stdout, params.json ? formatJson(result) : formatAudit(result));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
133
173
|
export async function handleRetryCommand(params) {
|
|
134
174
|
const issueKey = params.commandArgs[0];
|
|
135
175
|
if (!issueKey) {
|
package/dist/cli/data.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import pino from "pino";
|
|
3
3
|
import { CodexAppServerClient } from "../codex-app-server.js";
|
|
4
|
+
import { resolveCodexSessionSource } from "../codex-session-source.js";
|
|
4
5
|
import { extractCompletionCheck } from "../completion-check.js";
|
|
5
6
|
import { getThreadTurns } from "../codex-thread-utils.js";
|
|
6
7
|
import { PatchRelayDatabase } from "../db.js";
|
|
7
8
|
import { buildManualRetryAttemptReset, resolveRetryTarget } from "../manual-issue-actions.js";
|
|
8
9
|
import { WorktreeManager } from "../worktree-manager.js";
|
|
10
|
+
import { parseDelegationObservedPayload, parseRunReleasedAuthorityPayload } from "../delegation-audit.js";
|
|
9
11
|
import { CliOperatorApiClient } from "./operator-client.js";
|
|
10
12
|
function safeJsonParse(value) {
|
|
11
13
|
if (!value)
|
|
@@ -272,6 +274,69 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
272
274
|
...(run ? { releasedRunId: run.id } : {}),
|
|
273
275
|
};
|
|
274
276
|
}
|
|
277
|
+
audit(issueKey) {
|
|
278
|
+
const issue = this.db.getTrackedIssueByKey(issueKey);
|
|
279
|
+
if (!issue)
|
|
280
|
+
return undefined;
|
|
281
|
+
const events = this.db.issueSessions
|
|
282
|
+
.listIssueSessionEvents(issue.projectId, issue.linearIssueId)
|
|
283
|
+
.flatMap((event) => {
|
|
284
|
+
const delegationObserved = parseDelegationObservedPayload(event);
|
|
285
|
+
if (delegationObserved) {
|
|
286
|
+
return [{
|
|
287
|
+
createdAt: event.createdAt,
|
|
288
|
+
eventType: event.eventType,
|
|
289
|
+
summary: [
|
|
290
|
+
delegationObserved.source,
|
|
291
|
+
`observed=${delegationObserved.observedDelegatedToPatchRelay ? "delegated" : "undelegated"}`,
|
|
292
|
+
`applied=${delegationObserved.appliedDelegatedToPatchRelay ? "delegated" : "undelegated"}`,
|
|
293
|
+
`hydration=${delegationObserved.hydration}`,
|
|
294
|
+
delegationObserved.reason ? `reason=${delegationObserved.reason}` : undefined,
|
|
295
|
+
].filter(Boolean).join(" "),
|
|
296
|
+
details: delegationObserved,
|
|
297
|
+
}];
|
|
298
|
+
}
|
|
299
|
+
const authorityRelease = parseRunReleasedAuthorityPayload(event);
|
|
300
|
+
if (authorityRelease) {
|
|
301
|
+
return [{
|
|
302
|
+
createdAt: event.createdAt,
|
|
303
|
+
eventType: event.eventType,
|
|
304
|
+
summary: `released run #${authorityRelease.runId} (${authorityRelease.runType}) via ${authorityRelease.source}: ${authorityRelease.reason}`,
|
|
305
|
+
details: authorityRelease,
|
|
306
|
+
}];
|
|
307
|
+
}
|
|
308
|
+
if (event.eventType === "delegated" || event.eventType === "undelegated") {
|
|
309
|
+
return [{
|
|
310
|
+
createdAt: event.createdAt,
|
|
311
|
+
eventType: event.eventType,
|
|
312
|
+
summary: event.eventType === "delegated"
|
|
313
|
+
? "PatchRelay accepted delegation"
|
|
314
|
+
: "PatchRelay recorded undelegation",
|
|
315
|
+
}];
|
|
316
|
+
}
|
|
317
|
+
return [];
|
|
318
|
+
});
|
|
319
|
+
return { issue, events };
|
|
320
|
+
}
|
|
321
|
+
transcriptSource(issueKey, runId) {
|
|
322
|
+
const issue = this.db.getTrackedIssueByKey(issueKey);
|
|
323
|
+
if (!issue)
|
|
324
|
+
return undefined;
|
|
325
|
+
const dbIssue = this.db.issues.getIssueByKey(issueKey);
|
|
326
|
+
const runs = this.db.runs.listRunsForIssue(issue.projectId, issue.linearIssueId);
|
|
327
|
+
const selectedRun = runId !== undefined
|
|
328
|
+
? runs.find((run) => run.id === runId)
|
|
329
|
+
: runs.slice().reverse().find((run) => run.threadId);
|
|
330
|
+
const threadId = selectedRun?.threadId ?? dbIssue.threadId;
|
|
331
|
+
return {
|
|
332
|
+
issue,
|
|
333
|
+
...(selectedRun ? { runId: selectedRun.id, runType: selectedRun.runType, runStatus: selectedRun.status } : {}),
|
|
334
|
+
...(threadId ? { threadId } : {}),
|
|
335
|
+
...(selectedRun?.turnId ? { turnId: selectedRun.turnId } : {}),
|
|
336
|
+
...(dbIssue.worktreePath ? { worktreePath: dbIssue.worktreePath } : {}),
|
|
337
|
+
...(threadId ? { sessionSource: resolveCodexSessionSource(threadId) } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
275
340
|
sessions(issueKey) {
|
|
276
341
|
const issue = this.db.getTrackedIssueByKey(issueKey);
|
|
277
342
|
if (!issue)
|
|
@@ -284,6 +349,7 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
284
349
|
.map((run) => {
|
|
285
350
|
const summary = summarizeRun(run);
|
|
286
351
|
const eventCount = this.db.runs.listThreadEvents(run.id).length;
|
|
352
|
+
const sessionSource = run.threadId ? resolveCodexSessionSource(run.threadId) : undefined;
|
|
287
353
|
return {
|
|
288
354
|
runId: run.id,
|
|
289
355
|
runType: run.runType,
|
|
@@ -293,6 +359,7 @@ export class CliDataAccess extends CliOperatorApiClient {
|
|
|
293
359
|
...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
|
|
294
360
|
...(summary ? { summary } : {}),
|
|
295
361
|
...(run.failureReason ? { failureReason: run.failureReason } : {}),
|
|
362
|
+
...(sessionSource ? { sessionSource } : {}),
|
|
296
363
|
eventCount,
|
|
297
364
|
eventCountAvailable: this.config.runner.codex.persistExtendedHistory || eventCount > 0,
|
|
298
365
|
startedAt: run.startedAt,
|
|
@@ -84,6 +84,26 @@ export function formatRetry(result) {
|
|
|
84
84
|
.filter(Boolean)
|
|
85
85
|
.join("\n")}\n`;
|
|
86
86
|
}
|
|
87
|
+
export function formatAudit(result) {
|
|
88
|
+
const lines = [
|
|
89
|
+
`${result.issue.issueKey ?? result.issue.linearIssueId}${result.issue.currentLinearState ? ` ${result.issue.currentLinearState}` : ""}`,
|
|
90
|
+
];
|
|
91
|
+
if (result.events.length === 0) {
|
|
92
|
+
lines.push("No delegation audit events recorded.");
|
|
93
|
+
return `${lines.join("\n")}\n`;
|
|
94
|
+
}
|
|
95
|
+
for (const event of result.events) {
|
|
96
|
+
lines.push("");
|
|
97
|
+
lines.push([event.createdAt, event.eventType].join(" "));
|
|
98
|
+
lines.push(event.summary);
|
|
99
|
+
if (event.details && Object.keys(event.details).length > 0) {
|
|
100
|
+
lines.push(Object.entries(event.details)
|
|
101
|
+
.map(([key, value]) => `${key}=${value === undefined ? "-" : typeof value === "string" ? value : JSON.stringify(value)}`)
|
|
102
|
+
.join(" "));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return `${lines.join("\n")}\n`;
|
|
106
|
+
}
|
|
87
107
|
export function formatClose(result) {
|
|
88
108
|
return `${[
|
|
89
109
|
value("Issue", result.issue.issueKey ?? result.issue.linearIssueId),
|
|
@@ -97,6 +117,29 @@ export function formatClose(result) {
|
|
|
97
117
|
function formatTimestampRange(startedAt, endedAt) {
|
|
98
118
|
return endedAt ? `${startedAt} -> ${endedAt}` : `${startedAt} -> running`;
|
|
99
119
|
}
|
|
120
|
+
function formatSessionSource(sessionSource) {
|
|
121
|
+
if (!sessionSource)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (sessionSource.exists)
|
|
124
|
+
return sessionSource.path;
|
|
125
|
+
return sessionSource.error ?? "not found";
|
|
126
|
+
}
|
|
127
|
+
export function formatTranscriptSource(result) {
|
|
128
|
+
const lines = [
|
|
129
|
+
value("Issue", result.issue.issueKey ?? result.issue.linearIssueId),
|
|
130
|
+
value("Worktree", result.worktreePath),
|
|
131
|
+
value("Run", result.runId !== undefined
|
|
132
|
+
? `#${result.runId}${result.runType ? ` ${result.runType}` : ""}${result.runStatus ? ` (${result.runStatus})` : ""}`
|
|
133
|
+
: undefined),
|
|
134
|
+
value("Thread", result.threadId),
|
|
135
|
+
value("Turn", result.turnId),
|
|
136
|
+
value("Session source", formatSessionSource(result.sessionSource)),
|
|
137
|
+
result.sessionSource?.startedAt ? value("Started", result.sessionSource.startedAt) : undefined,
|
|
138
|
+
result.sessionSource?.originator ? value("Originator", result.sessionSource.originator) : undefined,
|
|
139
|
+
result.sessionSource?.cwd ? value("Working directory", result.sessionSource.cwd) : undefined,
|
|
140
|
+
].filter(Boolean);
|
|
141
|
+
return `${lines.join("\n")}\n`;
|
|
142
|
+
}
|
|
100
143
|
export function formatSessionHistory(result, buildOpenForThread) {
|
|
101
144
|
const lines = [
|
|
102
145
|
`${result.issue.issueKey ?? result.issue.linearIssueId}${result.issue.currentLinearState ? ` ${result.issue.currentLinearState}` : ""}`,
|
|
@@ -134,6 +177,18 @@ export function formatSessionHistory(result, buildOpenForThread) {
|
|
|
134
177
|
else if (session.failureReason) {
|
|
135
178
|
lines.push(value("Failure", truncateLine(session.failureReason)));
|
|
136
179
|
}
|
|
180
|
+
if (session.sessionSource) {
|
|
181
|
+
lines.push(value("Session source", formatSessionSource(session.sessionSource)));
|
|
182
|
+
if (session.sessionSource.startedAt) {
|
|
183
|
+
lines.push(value("Started", session.sessionSource.startedAt));
|
|
184
|
+
}
|
|
185
|
+
if (session.sessionSource.originator) {
|
|
186
|
+
lines.push(value("Originator", session.sessionSource.originator));
|
|
187
|
+
}
|
|
188
|
+
if (session.sessionSource.cwd) {
|
|
189
|
+
lines.push(value("Working directory", session.sessionSource.cwd));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
137
192
|
if (session.threadId && result.worktreePath && buildOpenForThread) {
|
|
138
193
|
const command = buildOpenForThread(session.threadId);
|
|
139
194
|
lines.push(value("Open", formatCommand(command.command, command.args)));
|
package/dist/cli/help.js
CHANGED
|
@@ -34,9 +34,11 @@ export function rootHelpText() {
|
|
|
34
34
|
" issue list [--active] [--failed] [--repo <id>] [--json]",
|
|
35
35
|
" List tracked issues",
|
|
36
36
|
" issue show <issueKey> [--json] Show the latest known issue state",
|
|
37
|
+
" issue audit <issueKey> [--json] Show delegation/release audit events for one issue",
|
|
37
38
|
" issue watch <issueKey> [--json] Follow the active run until it settles",
|
|
38
39
|
" issue open <issueKey> [--print] [--json] Open Codex in the issue worktree",
|
|
39
40
|
" issue sessions <issueKey> [--json] Show recorded Codex app-server sessions for one issue",
|
|
41
|
+
" issue transcript-source <issueKey> [--run <id>] [--json] Show the raw Codex session file for one issue run",
|
|
40
42
|
" issue close <issueKey> [--failed] [--reason <text>] [--json]",
|
|
41
43
|
" Force-close one issue and release any active run",
|
|
42
44
|
" service status [--json] Show systemd state and local health",
|
|
@@ -145,11 +147,13 @@ export function issueHelpText() {
|
|
|
145
147
|
"",
|
|
146
148
|
"Commands:",
|
|
147
149
|
" show <issueKey> Show the latest known issue state",
|
|
150
|
+
" audit <issueKey> Show delegation/release audit events",
|
|
148
151
|
" list List tracked issues",
|
|
149
152
|
" watch <issueKey> Follow issue activity until it settles",
|
|
150
153
|
" path <issueKey> Print the issue worktree path",
|
|
151
154
|
" open <issueKey> Open Codex in the issue worktree",
|
|
152
155
|
" sessions <issueKey> Show recorded Codex app-server sessions",
|
|
156
|
+
" transcript-source <issueKey> Show the raw Codex session file for one issue run",
|
|
153
157
|
" retry <issueKey> Requeue a run",
|
|
154
158
|
" close <issueKey> Force-close a stuck issue",
|
|
155
159
|
"",
|
|
@@ -158,6 +162,7 @@ export function issueHelpText() {
|
|
|
158
162
|
" patchrelay issue show USE-54",
|
|
159
163
|
" patchrelay issue watch USE-54",
|
|
160
164
|
" patchrelay issue sessions USE-54",
|
|
165
|
+
" patchrelay issue transcript-source USE-54",
|
|
161
166
|
" patchrelay close USE-54 --reason \"already handled manually\"",
|
|
162
167
|
].join("\n");
|
|
163
168
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -64,6 +64,9 @@ function validateFlags(command, commandArgs, parsed) {
|
|
|
64
64
|
case "sessions":
|
|
65
65
|
assertKnownFlags(parsed, "issue", ["json"]);
|
|
66
66
|
return;
|
|
67
|
+
case "transcript-source":
|
|
68
|
+
assertKnownFlags(parsed, "issue", ["run", "json"]);
|
|
69
|
+
return;
|
|
67
70
|
case "retry":
|
|
68
71
|
assertKnownFlags(parsed, "issue", ["run-type", "reason", "json"]);
|
|
69
72
|
return;
|
|
@@ -127,7 +127,7 @@ export function buildPatchRelayQueueObservations(issue, feedEvents) {
|
|
|
127
127
|
case "awaiting_queue":
|
|
128
128
|
observations.push({
|
|
129
129
|
tone: "info",
|
|
130
|
-
text: "PatchRelay has finished active work
|
|
130
|
+
text: "PatchRelay has finished active work. Delivery now depends on downstream review and merge automation.",
|
|
131
131
|
});
|
|
132
132
|
break;
|
|
133
133
|
case "repairing_queue":
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function resolveCodexHome(explicit) {
|
|
5
|
+
return explicit
|
|
6
|
+
?? process.env.CODEX_HOME
|
|
7
|
+
?? path.join(homedir(), ".codex");
|
|
8
|
+
}
|
|
9
|
+
function walkSessionFiles(directory, threadId, matches) {
|
|
10
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
const fullPath = path.join(directory, entry.name);
|
|
13
|
+
if (entry.isDirectory()) {
|
|
14
|
+
walkSessionFiles(fullPath, threadId, matches);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (entry.name.includes(threadId)) {
|
|
21
|
+
matches.push(fullPath);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function readSessionMeta(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
const firstLine = readFileSync(filePath, "utf8").split(/\r?\n/, 1)[0];
|
|
28
|
+
if (!firstLine) {
|
|
29
|
+
return { error: "session file is empty" };
|
|
30
|
+
}
|
|
31
|
+
const parsed = JSON.parse(firstLine);
|
|
32
|
+
if (parsed.type !== "session_meta" || !parsed.payload || typeof parsed.payload !== "object" || Array.isArray(parsed.payload)) {
|
|
33
|
+
return { error: "session file does not start with session_meta" };
|
|
34
|
+
}
|
|
35
|
+
return { payload: parsed.payload };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return {
|
|
39
|
+
error: error instanceof Error ? error.message : String(error),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function resolveCodexSessionSource(threadId, options) {
|
|
44
|
+
const codexHome = resolveCodexHome(options?.codexHome);
|
|
45
|
+
const sessionsDir = path.join(codexHome, "sessions");
|
|
46
|
+
if (!existsSync(sessionsDir)) {
|
|
47
|
+
return {
|
|
48
|
+
threadId,
|
|
49
|
+
codexHome,
|
|
50
|
+
sessionsDir,
|
|
51
|
+
exists: false,
|
|
52
|
+
error: `sessions directory not found: ${sessionsDir}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const matches = [];
|
|
56
|
+
walkSessionFiles(sessionsDir, threadId, matches);
|
|
57
|
+
matches.sort().reverse();
|
|
58
|
+
let firstError;
|
|
59
|
+
for (const candidate of matches) {
|
|
60
|
+
const { payload, error } = readSessionMeta(candidate);
|
|
61
|
+
if (payload && payload.id === threadId) {
|
|
62
|
+
return {
|
|
63
|
+
threadId,
|
|
64
|
+
codexHome,
|
|
65
|
+
sessionsDir,
|
|
66
|
+
exists: true,
|
|
67
|
+
path: candidate,
|
|
68
|
+
...(typeof payload.id === "string" ? { sessionId: payload.id } : {}),
|
|
69
|
+
...(typeof payload.timestamp === "string" ? { startedAt: payload.timestamp } : {}),
|
|
70
|
+
...(typeof payload.cwd === "string" ? { cwd: payload.cwd } : {}),
|
|
71
|
+
...(typeof payload.originator === "string" ? { originator: payload.originator } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!firstError && error) {
|
|
75
|
+
firstError = `${candidate}: ${error}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
threadId,
|
|
80
|
+
codexHome,
|
|
81
|
+
sessionsDir,
|
|
82
|
+
exists: false,
|
|
83
|
+
...(firstError ? { error: firstError } : { error: `session file not found for thread ${threadId}` }),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { deriveSessionWakePlan } from "../issue-session-events.js";
|
|
1
|
+
import { deriveSessionWakePlan, isActionableIssueSessionEventType } from "../issue-session-events.js";
|
|
2
2
|
import { isoNow } from "./shared.js";
|
|
3
3
|
export class IssueSessionStore {
|
|
4
4
|
connection;
|
|
@@ -90,13 +90,8 @@ export class IssueSessionStore {
|
|
|
90
90
|
`).run(isoNow(), projectId, linearIssueId);
|
|
91
91
|
}
|
|
92
92
|
hasPendingIssueSessionEvents(projectId, linearIssueId) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
FROM issue_session_events
|
|
96
|
-
WHERE project_id = ? AND linear_issue_id = ? AND processed_at IS NULL
|
|
97
|
-
LIMIT 1
|
|
98
|
-
`).get(projectId, linearIssueId);
|
|
99
|
-
return row !== undefined;
|
|
93
|
+
return this.listIssueSessionEvents(projectId, linearIssueId, { pendingOnly: true })
|
|
94
|
+
.some((event) => isActionableIssueSessionEventType(event.eventType));
|
|
100
95
|
}
|
|
101
96
|
peekIssueSessionWake(projectId, linearIssueId) {
|
|
102
97
|
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function appendDelegationObservedEvent(db, params) {
|
|
2
|
+
db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.projectId, params.linearIssueId, {
|
|
3
|
+
projectId: params.projectId,
|
|
4
|
+
linearIssueId: params.linearIssueId,
|
|
5
|
+
eventType: "delegation_observed",
|
|
6
|
+
eventJson: JSON.stringify(params.payload),
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export function appendRunReleasedAuthorityEvent(db, params) {
|
|
10
|
+
db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.projectId, params.linearIssueId, {
|
|
11
|
+
projectId: params.projectId,
|
|
12
|
+
linearIssueId: params.linearIssueId,
|
|
13
|
+
eventType: "run_released_authority",
|
|
14
|
+
eventJson: JSON.stringify(params.payload),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function parseDelegationObservedPayload(event) {
|
|
18
|
+
if (event.eventType !== "delegation_observed" || !event.eventJson) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return parseObject(event.eventJson);
|
|
22
|
+
}
|
|
23
|
+
export function parseRunReleasedAuthorityPayload(event) {
|
|
24
|
+
if (event.eventType !== "run_released_authority" || !event.eventJson) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return parseObject(event.eventJson);
|
|
28
|
+
}
|
|
29
|
+
function parseObject(raw) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
33
|
+
? parsed
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -88,7 +88,7 @@ export class IssueOverviewQuery {
|
|
|
88
88
|
const runCount = runs.length;
|
|
89
89
|
const liveThread = await this.readLiveThread(activeRun);
|
|
90
90
|
const failureContext = parseGitHubFailureContext(issueRecord?.lastGitHubFailureContextJson);
|
|
91
|
-
const
|
|
91
|
+
const derivedWaitingReason = derivePatchRelayWaitingReason({
|
|
92
92
|
delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
|
|
93
93
|
...(activeRun ? { activeRunType: activeRun.runType } : {}),
|
|
94
94
|
blockedByKeys,
|
|
@@ -102,6 +102,7 @@ export class IssueOverviewQuery {
|
|
|
102
102
|
lastBlockingReviewHeadSha: issueRecord?.lastBlockingReviewHeadSha,
|
|
103
103
|
latestFailureCheckName: issueRecord?.lastGitHubFailureCheckName,
|
|
104
104
|
});
|
|
105
|
+
const waitingReason = derivedWaitingReason ?? session.waitingReason;
|
|
105
106
|
const issue = {
|
|
106
107
|
id: issueRecord?.id ?? session.id,
|
|
107
108
|
projectId: session.projectId,
|
|
@@ -7,14 +7,19 @@ const TERMINAL_SESSION_EVENTS = new Set([
|
|
|
7
7
|
"pr_closed",
|
|
8
8
|
"pr_merged",
|
|
9
9
|
]);
|
|
10
|
+
const NON_ACTIONABLE_SESSION_EVENTS = new Set([
|
|
11
|
+
"delegation_observed",
|
|
12
|
+
"run_released_authority",
|
|
13
|
+
]);
|
|
10
14
|
const RUN_TYPES = new Set(["implementation", "review_fix", "branch_upkeep", "ci_repair", "queue_repair"]);
|
|
11
15
|
function parseRunType(value) {
|
|
12
16
|
return typeof value === "string" && RUN_TYPES.has(value) ? value : undefined;
|
|
13
17
|
}
|
|
14
18
|
export function deriveSessionWakePlan(issue, events) {
|
|
15
|
-
|
|
19
|
+
const actionableEvents = events.filter((event) => !NON_ACTIONABLE_SESSION_EVENTS.has(event.eventType));
|
|
20
|
+
if (actionableEvents.length === 0)
|
|
16
21
|
return undefined;
|
|
17
|
-
if (
|
|
22
|
+
if (actionableEvents.some((event) => TERMINAL_SESSION_EVENTS.has(event.eventType))) {
|
|
18
23
|
return undefined;
|
|
19
24
|
}
|
|
20
25
|
const context = {};
|
|
@@ -22,7 +27,7 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
22
27
|
let wakeReason;
|
|
23
28
|
let runType;
|
|
24
29
|
let resumeThread = false;
|
|
25
|
-
for (const event of
|
|
30
|
+
for (const event of actionableEvents) {
|
|
26
31
|
const payload = parseEventJson(event.eventJson);
|
|
27
32
|
switch (event.eventType) {
|
|
28
33
|
case "merge_steward_incident":
|
|
@@ -128,6 +133,9 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
128
133
|
}
|
|
129
134
|
return { runType, wakeReason, resumeThread, context };
|
|
130
135
|
}
|
|
136
|
+
export function isActionableIssueSessionEventType(eventType) {
|
|
137
|
+
return !NON_ACTIONABLE_SESSION_EVENTS.has(eventType);
|
|
138
|
+
}
|
|
131
139
|
export function extractLatestAssistantSummary(run) {
|
|
132
140
|
if (!run)
|
|
133
141
|
return undefined;
|
|
@@ -2,24 +2,47 @@ import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
|
2
2
|
import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
3
3
|
import { isCompletedLinearState } from "./pr-state.js";
|
|
4
4
|
import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
|
|
5
|
+
const COMPLETION_RECONCILE_WINDOW_MS = 60 * 60 * 1000;
|
|
6
|
+
const COMPLETION_RECONCILE_SUCCESS_BACKOFF_MS = 60 * 60 * 1000;
|
|
7
|
+
const COMPLETION_RECONCILE_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
|
|
8
|
+
const COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS = 30 * 60 * 1000;
|
|
9
|
+
const COMPLETION_RECONCILE_MAX_ISSUES_PER_PASS = 10;
|
|
5
10
|
export class MergedLinearCompletionReconciler {
|
|
6
11
|
db;
|
|
7
12
|
linearProvider;
|
|
8
13
|
logger;
|
|
14
|
+
retryAfterByIssueKey = new Map();
|
|
15
|
+
globalRetryAfter;
|
|
9
16
|
constructor(db, linearProvider, logger) {
|
|
10
17
|
this.db = db;
|
|
11
18
|
this.linearProvider = linearProvider;
|
|
12
19
|
this.logger = logger;
|
|
13
20
|
}
|
|
14
21
|
async reconcile() {
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
if (this.globalRetryAfter !== undefined) {
|
|
24
|
+
if (this.globalRetryAfter > now) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.globalRetryAfter = undefined;
|
|
28
|
+
}
|
|
29
|
+
const candidates = this.db.issues.listIssues()
|
|
30
|
+
.filter((issue) => this.isRecentCompletionCandidate(issue, now))
|
|
31
|
+
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
32
|
+
this.pruneRetryBackoff(candidates, now);
|
|
33
|
+
let attemptedIssues = 0;
|
|
34
|
+
for (const issue of candidates) {
|
|
35
|
+
if (attemptedIssues >= COMPLETION_RECONCILE_MAX_ISSUES_PER_PASS) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (!this.shouldAttemptIssue(issue, now)) {
|
|
17
39
|
continue;
|
|
18
40
|
}
|
|
19
41
|
const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
|
|
20
42
|
if (!linear) {
|
|
21
43
|
continue;
|
|
22
44
|
}
|
|
45
|
+
attemptedIssues += 1;
|
|
23
46
|
try {
|
|
24
47
|
const liveIssue = await linear.getIssue(issue.linearIssueId);
|
|
25
48
|
this.db.issues.replaceIssueDependencies({
|
|
@@ -37,6 +60,7 @@ export class MergedLinearCompletionReconciler {
|
|
|
37
60
|
const trustedNoPrDone = hasTrustedNoPrCompletion(issue, latestRun);
|
|
38
61
|
if (issue.prState === "merged" || trustedNoPrDone) {
|
|
39
62
|
await this.reconcileCompletedLinearState(issue, liveIssue, linear);
|
|
63
|
+
this.settleIssue(issue, now);
|
|
40
64
|
continue;
|
|
41
65
|
}
|
|
42
66
|
if (issue.factoryState === "done" && !isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
|
|
@@ -45,9 +69,15 @@ export class MergedLinearCompletionReconciler {
|
|
|
45
69
|
else {
|
|
46
70
|
this.refreshCachedLinearState(issue, liveIssue);
|
|
47
71
|
}
|
|
72
|
+
this.settleIssue(issue, now);
|
|
48
73
|
}
|
|
49
74
|
catch (error) {
|
|
75
|
+
this.deferIssue(issue, error, now);
|
|
50
76
|
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged or stale completed issue state");
|
|
77
|
+
if (isRateLimitedError(error)) {
|
|
78
|
+
this.globalRetryAfter = now + COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
51
81
|
}
|
|
52
82
|
}
|
|
53
83
|
}
|
|
@@ -92,6 +122,9 @@ export class MergedLinearCompletionReconciler {
|
|
|
92
122
|
}, "Reopened stale local done state from live Linear workflow");
|
|
93
123
|
}
|
|
94
124
|
refreshCachedLinearState(issue, liveIssue) {
|
|
125
|
+
if (issue.currentLinearState === liveIssue.stateName && issue.currentLinearStateType === liveIssue.stateType) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
95
128
|
this.db.issues.upsertIssue({
|
|
96
129
|
projectId: issue.projectId,
|
|
97
130
|
linearIssueId: issue.linearIssueId,
|
|
@@ -99,6 +132,55 @@ export class MergedLinearCompletionReconciler {
|
|
|
99
132
|
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
100
133
|
});
|
|
101
134
|
}
|
|
135
|
+
isRecentCompletionCandidate(issue, now) {
|
|
136
|
+
if (issue.factoryState !== "done" && issue.prState !== "merged") {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const updatedAt = Date.parse(issue.updatedAt);
|
|
140
|
+
return Number.isFinite(updatedAt) && now - updatedAt <= COMPLETION_RECONCILE_WINDOW_MS;
|
|
141
|
+
}
|
|
142
|
+
shouldAttemptIssue(issue, now) {
|
|
143
|
+
const retry = this.retryAfterByIssueKey.get(this.issueKey(issue));
|
|
144
|
+
if (!retry) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (retry.updatedAt !== issue.updatedAt) {
|
|
148
|
+
this.retryAfterByIssueKey.delete(this.issueKey(issue));
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return retry.retryAfter <= now;
|
|
152
|
+
}
|
|
153
|
+
settleIssue(issue, now) {
|
|
154
|
+
this.retryAfterByIssueKey.set(this.issueKey(issue), {
|
|
155
|
+
retryAfter: now + COMPLETION_RECONCILE_SUCCESS_BACKOFF_MS,
|
|
156
|
+
updatedAt: issue.updatedAt,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
deferIssue(issue, error, now) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
+
const backoffMs = /ratelimit|rate limit/i.test(message)
|
|
162
|
+
? COMPLETION_RECONCILE_RATE_LIMIT_BACKOFF_MS
|
|
163
|
+
: COMPLETION_RECONCILE_FAILURE_BACKOFF_MS;
|
|
164
|
+
this.retryAfterByIssueKey.set(this.issueKey(issue), {
|
|
165
|
+
retryAfter: now + backoffMs,
|
|
166
|
+
updatedAt: issue.updatedAt,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
pruneRetryBackoff(candidates, now) {
|
|
170
|
+
const candidateKeys = new Set(candidates.map((issue) => this.issueKey(issue)));
|
|
171
|
+
for (const [key, retry] of this.retryAfterByIssueKey.entries()) {
|
|
172
|
+
if (!candidateKeys.has(key) || retry.retryAfter <= now) {
|
|
173
|
+
this.retryAfterByIssueKey.delete(key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
issueKey(issue) {
|
|
178
|
+
return `${issue.projectId}::${issue.linearIssueId}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function isRateLimitedError(error) {
|
|
182
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
183
|
+
return /ratelimit|rate limit/i.test(message);
|
|
102
184
|
}
|
|
103
185
|
function resolveOpenWorkflowState(issue) {
|
|
104
186
|
const reactiveIntent = deriveIssueSessionReactiveIntent({
|
package/dist/run-reconciler.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appendDelegationObservedEvent, appendRunReleasedAuthorityEvent } from "./delegation-audit.js";
|
|
1
2
|
import { TERMINAL_STATES } from "./factory-state.js";
|
|
2
3
|
import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
|
|
3
4
|
import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
@@ -31,39 +32,40 @@ export class RunReconciler {
|
|
|
31
32
|
async reconcile(params) {
|
|
32
33
|
const { run, issue, recoveryLease } = params;
|
|
33
34
|
const acquiredRecoveryLease = recoveryLease === true;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
let effectiveIssue = issue;
|
|
36
|
+
if (!effectiveIssue.delegatedToPatchRelay) {
|
|
37
|
+
const authority = await this.confirmDelegationAuthorityBeforeRelease(run, effectiveIssue);
|
|
38
|
+
effectiveIssue = authority.issue;
|
|
39
|
+
if (authority.released) {
|
|
40
|
+
const pausedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
41
|
+
void this.linearSync.syncSession(pausedIssue, { activeRunType: run.runType });
|
|
42
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
43
45
|
}
|
|
44
|
-
if (TERMINAL_STATES.has(
|
|
46
|
+
if (TERMINAL_STATES.has(effectiveIssue.factoryState)) {
|
|
45
47
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
46
48
|
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
|
|
47
49
|
this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
48
50
|
});
|
|
49
|
-
this.logger.info({ issueKey:
|
|
50
|
-
const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ??
|
|
51
|
+
this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, factoryState: effectiveIssue.factoryState }, "Reconciliation: released run on terminal issue");
|
|
52
|
+
const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
51
53
|
void this.linearSync.syncSession(releasedIssue, { activeRunType: run.runType });
|
|
52
54
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
53
55
|
return;
|
|
54
56
|
}
|
|
55
57
|
if (!run.threadId) {
|
|
56
58
|
if (recoveryLease === "owned") {
|
|
57
|
-
this.logger.debug({ issueKey:
|
|
59
|
+
this.logger.debug({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
|
|
58
60
|
return;
|
|
59
61
|
}
|
|
60
|
-
this.logger.warn({ issueKey:
|
|
62
|
+
this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
|
|
61
63
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
62
64
|
this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
|
|
63
65
|
this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
64
66
|
});
|
|
65
|
-
this.recoverOrEscalate(
|
|
66
|
-
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ??
|
|
67
|
+
this.recoverOrEscalate(effectiveIssue, run.runType, "zombie");
|
|
68
|
+
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
67
69
|
void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "The Codex turn never started before PatchRelay restarted."));
|
|
68
70
|
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
69
71
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
@@ -74,13 +76,13 @@ export class RunReconciler {
|
|
|
74
76
|
thread = await this.readThreadWithRetry(run.threadId);
|
|
75
77
|
}
|
|
76
78
|
catch {
|
|
77
|
-
this.logger.warn({ issueKey:
|
|
79
|
+
this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
|
|
78
80
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
79
81
|
this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
|
|
80
82
|
this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
81
83
|
});
|
|
82
|
-
this.recoverOrEscalate(
|
|
83
|
-
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ??
|
|
84
|
+
this.recoverOrEscalate(effectiveIssue, run.runType, "stale_thread");
|
|
85
|
+
const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
84
86
|
void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "PatchRelay lost the active Codex thread after restart and needs to recover."));
|
|
85
87
|
void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType });
|
|
86
88
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
@@ -111,7 +113,7 @@ export class RunReconciler {
|
|
|
111
113
|
status: "reconciled",
|
|
112
114
|
summary: `Linear state ${stopState.stateName} -> done`,
|
|
113
115
|
});
|
|
114
|
-
const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ??
|
|
116
|
+
const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue;
|
|
115
117
|
void this.linearSync.syncSession(doneIssue, { activeRunType: run.runType });
|
|
116
118
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
117
119
|
return;
|
|
@@ -120,14 +122,14 @@ export class RunReconciler {
|
|
|
120
122
|
}
|
|
121
123
|
const latestTurn = getThreadTurns(thread).at(-1);
|
|
122
124
|
if (latestTurn?.status === "interrupted") {
|
|
123
|
-
await this.interruptedRunRecovery.handle(run,
|
|
125
|
+
await this.interruptedRunRecovery.handle(run, effectiveIssue);
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
126
128
|
if (latestTurn?.status === "completed") {
|
|
127
129
|
await this.runFinalizer.finalizeCompletedRun({
|
|
128
130
|
source: "reconciliation",
|
|
129
131
|
run,
|
|
130
|
-
issue,
|
|
132
|
+
issue: effectiveIssue,
|
|
131
133
|
thread,
|
|
132
134
|
threadId: run.threadId,
|
|
133
135
|
...(latestTurn.id ? { completedTurnId: latestTurn.id } : {}),
|
|
@@ -139,4 +141,111 @@ export class RunReconciler {
|
|
|
139
141
|
this.releaseLease(run.projectId, run.linearIssueId);
|
|
140
142
|
}
|
|
141
143
|
}
|
|
144
|
+
async confirmDelegationAuthorityBeforeRelease(run, issue) {
|
|
145
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(run.projectId);
|
|
146
|
+
const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
|
|
147
|
+
if (!installation?.actorId || !linear) {
|
|
148
|
+
appendDelegationObservedEvent(this.db, {
|
|
149
|
+
projectId: run.projectId,
|
|
150
|
+
linearIssueId: run.linearIssueId,
|
|
151
|
+
payload: {
|
|
152
|
+
source: "run_reconciler",
|
|
153
|
+
...(installation?.actorId ? { actorId: installation.actorId } : {}),
|
|
154
|
+
previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
155
|
+
observedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
156
|
+
appliedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
157
|
+
hydration: "live_linear_failed",
|
|
158
|
+
activeRunId: run.id,
|
|
159
|
+
decision: "none",
|
|
160
|
+
reason: "live_linear_unavailable_before_undelegation_release",
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return { issue, released: false };
|
|
164
|
+
}
|
|
165
|
+
const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined);
|
|
166
|
+
if (!linearIssue) {
|
|
167
|
+
appendDelegationObservedEvent(this.db, {
|
|
168
|
+
projectId: run.projectId,
|
|
169
|
+
linearIssueId: run.linearIssueId,
|
|
170
|
+
payload: {
|
|
171
|
+
source: "run_reconciler",
|
|
172
|
+
actorId: installation.actorId,
|
|
173
|
+
previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
174
|
+
observedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
175
|
+
appliedDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
176
|
+
hydration: "live_linear_failed",
|
|
177
|
+
activeRunId: run.id,
|
|
178
|
+
decision: "none",
|
|
179
|
+
reason: "live_linear_refresh_failed_before_undelegation_release",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
return { issue, released: false };
|
|
183
|
+
}
|
|
184
|
+
const delegated = linearIssue.delegateId === installation.actorId;
|
|
185
|
+
appendDelegationObservedEvent(this.db, {
|
|
186
|
+
projectId: run.projectId,
|
|
187
|
+
linearIssueId: run.linearIssueId,
|
|
188
|
+
payload: {
|
|
189
|
+
source: "run_reconciler",
|
|
190
|
+
actorId: installation.actorId,
|
|
191
|
+
...(linearIssue.delegateId ? { observedDelegateId: linearIssue.delegateId } : {}),
|
|
192
|
+
previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
193
|
+
observedDelegatedToPatchRelay: delegated,
|
|
194
|
+
appliedDelegatedToPatchRelay: delegated,
|
|
195
|
+
hydration: "live_linear",
|
|
196
|
+
activeRunId: run.id,
|
|
197
|
+
decision: delegated ? "resume_issue" : "release_run",
|
|
198
|
+
reason: delegated
|
|
199
|
+
? "live_linear_confirmed_issue_is_still_delegated"
|
|
200
|
+
: "live_linear_confirmed_issue_is_no_longer_delegated",
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
if (delegated) {
|
|
204
|
+
const repairedIssue = this.withHeldLease(run.projectId, run.linearIssueId, () => this.db.issues.upsertIssue({
|
|
205
|
+
projectId: run.projectId,
|
|
206
|
+
linearIssueId: run.linearIssueId,
|
|
207
|
+
delegatedToPatchRelay: true,
|
|
208
|
+
...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
|
|
209
|
+
...(linearIssue.title ? { title: linearIssue.title } : {}),
|
|
210
|
+
...(linearIssue.description ? { description: linearIssue.description } : {}),
|
|
211
|
+
...(linearIssue.url ? { url: linearIssue.url } : {}),
|
|
212
|
+
...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
|
|
213
|
+
...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
|
|
214
|
+
...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
|
|
215
|
+
...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
|
|
216
|
+
})) ?? issue;
|
|
217
|
+
return { issue: repairedIssue, released: false };
|
|
218
|
+
}
|
|
219
|
+
appendRunReleasedAuthorityEvent(this.db, {
|
|
220
|
+
projectId: run.projectId,
|
|
221
|
+
linearIssueId: run.linearIssueId,
|
|
222
|
+
payload: {
|
|
223
|
+
runId: run.id,
|
|
224
|
+
runType: run.runType,
|
|
225
|
+
localDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
226
|
+
liveDelegatedToPatchRelay: delegated,
|
|
227
|
+
source: "run_reconciler",
|
|
228
|
+
reason: "Issue was un-delegated during active run",
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
232
|
+
this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
|
|
233
|
+
this.db.issues.upsertIssue({
|
|
234
|
+
projectId: run.projectId,
|
|
235
|
+
linearIssueId: run.linearIssueId,
|
|
236
|
+
activeRunId: null,
|
|
237
|
+
factoryState: issue.factoryState,
|
|
238
|
+
delegatedToPatchRelay: false,
|
|
239
|
+
...(linearIssue.identifier ? { issueKey: linearIssue.identifier } : {}),
|
|
240
|
+
...(linearIssue.title ? { title: linearIssue.title } : {}),
|
|
241
|
+
...(linearIssue.description ? { description: linearIssue.description } : {}),
|
|
242
|
+
...(linearIssue.url ? { url: linearIssue.url } : {}),
|
|
243
|
+
...(linearIssue.priority != null ? { priority: linearIssue.priority } : {}),
|
|
244
|
+
...(linearIssue.estimate != null ? { estimate: linearIssue.estimate } : {}),
|
|
245
|
+
...(linearIssue.stateName ? { currentLinearState: linearIssue.stateName } : {}),
|
|
246
|
+
...(linearIssue.stateType ? { currentLinearStateType: linearIssue.stateType } : {}),
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
return { issue, released: true };
|
|
250
|
+
}
|
|
142
251
|
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -30,13 +30,12 @@ export class ServiceRuntime {
|
|
|
30
30
|
async start() {
|
|
31
31
|
try {
|
|
32
32
|
await this.codex.start();
|
|
33
|
-
await this.runReconciler.reconcileActiveRuns();
|
|
34
33
|
for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
|
|
35
34
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
36
35
|
}
|
|
37
36
|
this.ready = true;
|
|
38
37
|
this.startupError = undefined;
|
|
39
|
-
this.
|
|
38
|
+
void this.runBackgroundReconcile();
|
|
40
39
|
}
|
|
41
40
|
catch (error) {
|
|
42
41
|
this.ready = false;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { appendDelegationObservedEvent } from "./delegation-audit.js";
|
|
1
2
|
import { isResumablePausedLocalWork } from "./paused-issue-state.js";
|
|
2
3
|
export class ServiceStartupRecovery {
|
|
3
4
|
db;
|
|
@@ -65,6 +66,24 @@ export class ServiceStartupRecovery {
|
|
|
65
66
|
})),
|
|
66
67
|
});
|
|
67
68
|
const delegated = liveIssue.delegateId === installation.actorId;
|
|
69
|
+
if (issue.delegatedToPatchRelay !== delegated) {
|
|
70
|
+
appendDelegationObservedEvent(this.db, {
|
|
71
|
+
projectId: issue.projectId,
|
|
72
|
+
linearIssueId: issue.linearIssueId,
|
|
73
|
+
payload: {
|
|
74
|
+
source: "startup_recovery",
|
|
75
|
+
actorId: installation.actorId,
|
|
76
|
+
...(liveIssue.delegateId ? { observedDelegateId: liveIssue.delegateId } : {}),
|
|
77
|
+
previousDelegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
78
|
+
observedDelegatedToPatchRelay: delegated,
|
|
79
|
+
appliedDelegatedToPatchRelay: delegated,
|
|
80
|
+
hydration: "live_linear",
|
|
81
|
+
...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
|
|
82
|
+
decision: delegated ? "resume_issue" : "none",
|
|
83
|
+
reason: "startup_recovery_refreshed_linear_delegation",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
68
87
|
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
69
88
|
const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
70
89
|
const shouldRecoverPausedLocalWork = delegated
|
package/dist/service.js
CHANGED
|
@@ -106,7 +106,10 @@ export class PatchRelayService {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
await this.runtime.start();
|
|
109
|
-
|
|
109
|
+
void this.startupRecovery.recoverDelegatedIssueStateFromLinear().catch((error) => {
|
|
110
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
111
|
+
this.logger.warn({ error: msg }, "Background delegated issue recovery failed");
|
|
112
|
+
});
|
|
110
113
|
void this.startupRecovery.syncKnownAgentSessions().catch((error) => {
|
|
111
114
|
const msg = error instanceof Error ? error.message : String(error);
|
|
112
115
|
this.logger.warn({ error: msg }, "Background agent session sync failed");
|
package/dist/waiting-reason.js
CHANGED
|
@@ -10,7 +10,7 @@ export const PATCHRELAY_WAITING_REASONS = {
|
|
|
10
10
|
waitingForReviewOnNewHead: "Waiting on review of a newer pushed head",
|
|
11
11
|
sameHeadStillBlocked: "Requested changes still block the current head",
|
|
12
12
|
waitingForMergeStewardRepair: "Waiting to repair a merge-steward incident",
|
|
13
|
-
waitingForDownstreamAutomation: "
|
|
13
|
+
waitingForDownstreamAutomation: "PatchRelay work is done; waiting on downstream review/merge automation",
|
|
14
14
|
workComplete: "PatchRelay work is complete",
|
|
15
15
|
waitingForOperatorIntervention: "Waiting on operator intervention",
|
|
16
16
|
waitingForExternalReview: "Waiting on external review",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { triggerEventAllowed } from "../project-resolution.js";
|
|
2
2
|
import { resolveAwaitingInputReason } from "../awaiting-input-reason.js";
|
|
3
|
+
import { appendDelegationObservedEvent } from "../delegation-audit.js";
|
|
3
4
|
import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isTerminalDelegationState, mergeIssueMetadata, resolveReDelegationResume, } from "./decision-helpers.js";
|
|
4
5
|
import { buildOperatorRetryEvent } from "../operator-retry-event.js";
|
|
5
6
|
export class DesiredStageRecorder {
|
|
@@ -25,8 +26,20 @@ export class DesiredStageRecorder {
|
|
|
25
26
|
if (!existingIssue && !this.isDelegatedToPatchRelay(params.project, normalizedIssue) && !incomingAgentSessionId) {
|
|
26
27
|
return { issue: undefined, wakeRunType: undefined, delegated: false };
|
|
27
28
|
}
|
|
28
|
-
const
|
|
29
|
-
const
|
|
29
|
+
const syncResult = await this.syncIssueDependencies(params.project.id, normalizedIssue);
|
|
30
|
+
const hydratedIssue = syncResult.issue;
|
|
31
|
+
const delegation = this.resolveDelegationTruth({
|
|
32
|
+
project: params.project,
|
|
33
|
+
normalizedIssue,
|
|
34
|
+
hydratedIssue,
|
|
35
|
+
existingIssue,
|
|
36
|
+
triggerEvent: params.normalized.triggerEvent,
|
|
37
|
+
webhookId: params.normalized.webhookId,
|
|
38
|
+
actorId: params.normalized.actor?.id,
|
|
39
|
+
hydration: syncResult.hydration,
|
|
40
|
+
activeRunId: activeRun?.id,
|
|
41
|
+
});
|
|
42
|
+
const delegated = delegation.delegated;
|
|
30
43
|
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(params.project.id, normalizedIssue.id);
|
|
31
44
|
const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
|
|
32
45
|
const openPrExists = existingIssue?.prNumber !== undefined
|
|
@@ -200,16 +213,56 @@ export class DesiredStageRecorder {
|
|
|
200
213
|
return false;
|
|
201
214
|
return issue.delegateId === installation.actorId;
|
|
202
215
|
}
|
|
216
|
+
resolveDelegationTruth(params) {
|
|
217
|
+
const previousDelegated = params.existingIssue?.delegatedToPatchRelay;
|
|
218
|
+
const observedDelegated = this.isDelegatedToPatchRelay(params.project, params.hydratedIssue);
|
|
219
|
+
const explicitDelegateSignal = params.triggerEvent === "delegateChanged";
|
|
220
|
+
const hasObservedDelegate = params.hydratedIssue.delegateId !== undefined;
|
|
221
|
+
let delegated = observedDelegated;
|
|
222
|
+
let reason = hasObservedDelegate
|
|
223
|
+
? "delegate_id_present"
|
|
224
|
+
: `missing_delegate_identity_after_${params.hydration}`;
|
|
225
|
+
if (!hasObservedDelegate && !explicitDelegateSignal && previousDelegated !== undefined) {
|
|
226
|
+
delegated = previousDelegated;
|
|
227
|
+
reason = `preserved_previous_delegation_after_${params.hydration}`;
|
|
228
|
+
}
|
|
229
|
+
if (previousDelegated !== delegated
|
|
230
|
+
|| params.hydration === "live_linear_failed"
|
|
231
|
+
|| (!hasObservedDelegate && previousDelegated !== undefined)) {
|
|
232
|
+
appendDelegationObservedEvent(this.db, {
|
|
233
|
+
projectId: params.project.id,
|
|
234
|
+
linearIssueId: params.normalizedIssue.id,
|
|
235
|
+
payload: {
|
|
236
|
+
source: "linear_webhook",
|
|
237
|
+
webhookId: params.webhookId,
|
|
238
|
+
triggerEvent: params.triggerEvent,
|
|
239
|
+
...(params.actorId ? { actorId: params.actorId } : {}),
|
|
240
|
+
...(params.hydratedIssue.delegateId ? { observedDelegateId: params.hydratedIssue.delegateId } : {}),
|
|
241
|
+
...(previousDelegated !== undefined ? { previousDelegatedToPatchRelay: previousDelegated } : {}),
|
|
242
|
+
observedDelegatedToPatchRelay: observedDelegated,
|
|
243
|
+
appliedDelegatedToPatchRelay: delegated,
|
|
244
|
+
hydration: params.hydration,
|
|
245
|
+
...(params.activeRunId !== undefined ? { activeRunId: params.activeRunId } : {}),
|
|
246
|
+
decision: "none",
|
|
247
|
+
reason,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return { delegated };
|
|
252
|
+
}
|
|
203
253
|
async syncIssueDependencies(projectId, issue) {
|
|
204
254
|
let source = issue;
|
|
255
|
+
let hydration = "webhook_only";
|
|
205
256
|
if (!source.relationsKnown) {
|
|
206
257
|
const linear = await this.linearProvider.forProject(projectId);
|
|
207
258
|
if (linear) {
|
|
208
259
|
try {
|
|
209
260
|
source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
|
|
261
|
+
hydration = "live_linear";
|
|
210
262
|
}
|
|
211
263
|
catch {
|
|
212
264
|
// Preserve existing dependency rows when webhook relation data is incomplete.
|
|
265
|
+
hydration = "live_linear_failed";
|
|
213
266
|
}
|
|
214
267
|
}
|
|
215
268
|
}
|
|
@@ -226,6 +279,6 @@ export class DesiredStageRecorder {
|
|
|
226
279
|
})),
|
|
227
280
|
});
|
|
228
281
|
}
|
|
229
|
-
return source;
|
|
282
|
+
return { issue: source, hydration };
|
|
230
283
|
}
|
|
231
284
|
}
|