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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.39.0",
4
- "commit": "80d285a7e9bd",
5
- "builtAt": "2026-04-11T20:04:29.313Z"
3
+ "version": "0.40.1",
4
+ "commit": "a0d40b1e8b30",
5
+ "builtAt": "2026-04-12T19:50:43.295Z"
6
6
  }
@@ -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 and is waiting for downstream merge flow.",
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
- const row = this.connection.prepare(`
94
- SELECT 1
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 waitingReason = session.waitingReason ?? derivePatchRelayWaitingReason({
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
- if (events.length === 0)
19
+ const actionableEvents = events.filter((event) => !NON_ACTIONABLE_SESSION_EVENTS.has(event.eventType));
20
+ if (actionableEvents.length === 0)
16
21
  return undefined;
17
- if (events.some((event) => TERMINAL_SESSION_EVENTS.has(event.eventType))) {
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 events) {
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
- for (const issue of this.db.issues.listIssues()) {
16
- if (issue.factoryState !== "done" && issue.prState !== "merged") {
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({
@@ -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
- if (!issue.delegatedToPatchRelay) {
35
- this.withHeldLease(run.projectId, run.linearIssueId, () => {
36
- this.db.runs.finishRun(run.id, { status: "released", failureReason: "Issue was un-delegated during active run" });
37
- this.db.issues.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null, factoryState: issue.factoryState });
38
- });
39
- const pausedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
40
- void this.linearSync.syncSession(pausedIssue, { activeRunType: run.runType });
41
- this.releaseLease(run.projectId, run.linearIssueId);
42
- return;
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(issue.factoryState)) {
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: issue.issueKey, runId: run.id, factoryState: issue.factoryState }, "Reconciliation: released run on terminal issue");
50
- const releasedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
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: issue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
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: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
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(issue, run.runType, "zombie");
66
- const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
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: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
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(issue, run.runType, "stale_thread");
83
- const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
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) ?? issue;
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, issue);
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
  }
@@ -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.scheduleBackgroundReconcile();
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
- await this.startupRecovery.recoverDelegatedIssueStateFromLinear();
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");
@@ -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: "Waiting on downstream review/merge automation",
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 hydratedIssue = await this.syncIssueDependencies(params.project.id, normalizedIssue);
29
- const delegated = this.isDelegatedToPatchRelay(params.project, hydratedIssue);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {