patchrelay 0.73.3 → 0.74.0

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,7 @@
1
1
  import { buildFollowupStatusActivity, buildNonActionableFollowupActivity, buildPromptDeliveryFailedActivity, buildPromptDeliveredThought, } from "./linear-session-reporting.js";
2
2
  import { deriveIssueStatusNote } from "./status-note.js";
3
3
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
4
+ import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "./git-worktree-status.js";
4
5
  export class AgentInputService {
5
6
  db;
6
7
  codex;
@@ -211,6 +212,9 @@ export class AgentInputService {
211
212
  }) ?? issue;
212
213
  }
213
214
  async stopActiveRun(issue, run, body, source) {
215
+ const worktreeStatus = issue.worktreePath ? inspectGitWorktreeStatus(issue.worktreePath) : undefined;
216
+ const dirtyPayload = worktreeStatus ? dirtyWorktreeEventPayload(worktreeStatus) : undefined;
217
+ const dirtySummary = typeof dirtyPayload?.summary === "string" ? dirtyPayload.summary : undefined;
214
218
  if (run.threadId && run.turnId) {
215
219
  try {
216
220
  await this.codex.steerTurn({
@@ -222,7 +226,12 @@ export class AgentInputService {
222
226
  catch (error) {
223
227
  this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop request");
224
228
  }
225
- this.db.runs.finishRun(run.id, { status: "released", threadId: run.threadId, turnId: run.turnId });
229
+ this.db.runs.finishRun(run.id, {
230
+ status: "released",
231
+ threadId: run.threadId,
232
+ turnId: run.turnId,
233
+ failureReason: dirtySummary ? `Operator stopped run; ${dirtySummary}` : "Operator stopped run",
234
+ });
226
235
  }
227
236
  this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
228
237
  projectId: issue.projectId,
@@ -232,7 +241,7 @@ export class AgentInputService {
232
241
  });
233
242
  this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
234
243
  eventType: "stop_requested",
235
- eventJson: JSON.stringify({ body, source }),
244
+ eventJson: JSON.stringify({ body, source, ...dirtyPayload }),
236
245
  });
237
246
  this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
238
247
  this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.73.3",
4
- "commit": "3342a5c6bbc1",
5
- "builtAt": "2026-05-26T13:58:45.864Z"
3
+ "version": "0.74.0",
4
+ "commit": "d04d91223dec",
5
+ "builtAt": "2026-05-28T18:16:54.428Z"
6
6
  }
@@ -175,6 +175,41 @@ async function readPatchRelayHealth() {
175
175
  };
176
176
  }
177
177
  }
178
+ function getPatchRelayServiceUrl() {
179
+ const config = loadConfig(undefined, { profile: "doctor" });
180
+ const host = config.server.bind === "0.0.0.0" ? "127.0.0.1" : config.server.bind;
181
+ const baseUrl = `http://${host}:${config.server.port}`;
182
+ return {
183
+ baseUrl,
184
+ healthPath: `${baseUrl}${config.server.healthPath}`,
185
+ codexStatusPath: `${baseUrl}/status`,
186
+ };
187
+ }
188
+ async function readPatchRelayCodexStatus() {
189
+ const { codexStatusPath } = getPatchRelayServiceUrl();
190
+ try {
191
+ const response = await fetch(codexStatusPath, { signal: AbortSignal.timeout(2_000) });
192
+ const payload = await response.json();
193
+ return {
194
+ reachable: true,
195
+ status: response.status,
196
+ payload: {
197
+ ...payload,
198
+ output: typeof payload.output === "string" ? payload.output : "",
199
+ ...(typeof payload.account === "string" ? { account: payload.account } : {}),
200
+ exitCode: typeof payload.exitCode === "number" ? payload.exitCode : 1,
201
+ ok: payload.ok === true,
202
+ },
203
+ };
204
+ }
205
+ catch (error) {
206
+ return {
207
+ reachable: false,
208
+ status: 0,
209
+ error: error instanceof Error ? error.message : String(error),
210
+ };
211
+ }
212
+ }
178
213
  export async function handleServiceCommand(params) {
179
214
  if (params.commandArgs.length === 0) {
180
215
  throw new CliUsageError("patchrelay service requires a subcommand.", "service");
@@ -229,6 +264,24 @@ export async function handleServiceCommand(params) {
229
264
  .join("\n") + "\n");
230
265
  return 0;
231
266
  }
267
+ if (subcommand === "codex-status") {
268
+ const result = await readPatchRelayCodexStatus();
269
+ if (!result.reachable) {
270
+ throw new Error(`Unable to read PatchRelay Codex status. ${result.error}`);
271
+ }
272
+ const status = result.payload;
273
+ const output = status.output.trim();
274
+ if (params.json) {
275
+ writeOutput(params.stdout, formatJson(status));
276
+ return status.ok ? 0 : 1;
277
+ }
278
+ const lines = [
279
+ "PatchRelay Codex status",
280
+ output ? output : "No codex status output received.",
281
+ ].filter(Boolean);
282
+ writeOutput(params.stdout, `${lines.join("\n")}\n`);
283
+ return status.ok ? 0 : 1;
284
+ }
232
285
  if (subcommand === "logs") {
233
286
  const lines = parsePositiveIntegerFlag(params.parsed.flags.get("lines"), "--lines") ?? 50;
234
287
  const result = await params.runCommand("sudo", [
package/dist/cli/help.js CHANGED
@@ -43,6 +43,7 @@ export function rootHelpText() {
43
43
  " issue close <issueKey> [--failed] [--reason <text>] [--json]",
44
44
  " Force-close one issue and release any active run",
45
45
  " service status [--json] Show systemd state and local health",
46
+ " service codex-status [--json] Show Codex account and usage snapshot from this service",
46
47
  " cluster [--json] Check service + workflow health across all tracked issues",
47
48
  " service logs [--lines <count>] [--json] Show recent service logs",
48
49
  " serve Run the local PatchRelay service",
@@ -178,6 +179,7 @@ export function serviceHelpText() {
178
179
  " install [--force] [--write-only] [--json] Reinstall the systemd service unit",
179
180
  " restart [--json] Reload-or-restart the service",
180
181
  " status [--json] Show systemd state and local health",
182
+ " codex-status [--json] Show Codex account and usage snapshot from this service",
181
183
  " logs [--lines <count>] [--json] Show recent journal logs",
182
184
  "",
183
185
  "Examples:",
package/dist/cli/index.js CHANGED
@@ -141,6 +141,10 @@ function validateFlags(command, commandArgs, parsed) {
141
141
  assertKnownFlags(parsed, "service", ["json"]);
142
142
  return;
143
143
  }
144
+ if (commandArgs[0] === "codex-status") {
145
+ assertKnownFlags(parsed, "service", ["json"]);
146
+ return;
147
+ }
144
148
  if (commandArgs[0] === "logs") {
145
149
  assertKnownFlags(parsed, "service", ["lines", "json"]);
146
150
  return;
@@ -0,0 +1,41 @@
1
+ import { spawnSync } from "node:child_process";
2
+ const CODEX_STATUS_TIMEOUT_MS = 15_000;
3
+ function stripAnsiCodes(value) {
4
+ return value.replace(/\u001b\[[0-9;]*m/g, "");
5
+ }
6
+ function parseAccountLine(output) {
7
+ const lines = output.split(/\r?\n/);
8
+ for (const line of lines) {
9
+ const clean = stripAnsiCodes(line).trim();
10
+ const match = clean.match(/^Account:\s*(.+)$/i);
11
+ if (match) {
12
+ return match[1].trim();
13
+ }
14
+ }
15
+ return undefined;
16
+ }
17
+ export function getCodexStatusSnapshot(bin = "codex") {
18
+ try {
19
+ const result = spawnSync(bin, ["status"], {
20
+ encoding: "utf8",
21
+ timeout: CODEX_STATUS_TIMEOUT_MS,
22
+ env: { ...process.env },
23
+ });
24
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trimEnd();
25
+ const account = parseAccountLine(output);
26
+ return {
27
+ ok: result.status === 0,
28
+ exitCode: result.status ?? 1,
29
+ output,
30
+ ...(account ? { account } : {}),
31
+ };
32
+ }
33
+ catch (error) {
34
+ return {
35
+ ok: false,
36
+ exitCode: 1,
37
+ output: "",
38
+ error: error instanceof Error ? error.message : String(error),
39
+ };
40
+ }
41
+ }
@@ -1,4 +1,5 @@
1
1
  import { deriveSessionWakePlan, isActionableIssueSessionEventType } from "../issue-session-events.js";
2
+ import { mergeRequestedChangesEventJson, readRequestedChangesCoalesceKey } from "../reactive-wake-keys.js";
2
3
  import { isoNow } from "./shared.js";
3
4
  export class IssueSessionStore {
4
5
  connection;
@@ -33,6 +34,9 @@ export class IssueSessionStore {
33
34
  if (existing)
34
35
  return this.mapIssueSessionEventRow(existing);
35
36
  }
37
+ const coalesced = this.coalescePendingRequestedChangesEvent(params);
38
+ if (coalesced)
39
+ return coalesced;
36
40
  const now = isoNow();
37
41
  const result = this.connection.prepare(`
38
42
  INSERT INTO issue_session_events (
@@ -41,6 +45,27 @@ export class IssueSessionStore {
41
45
  `).run(params.projectId, params.linearIssueId, params.eventType, params.eventJson ?? null, params.dedupeKey ?? null, now);
42
46
  return this.getIssueSessionEvent(Number(result.lastInsertRowid));
43
47
  }
48
+ coalescePendingRequestedChangesEvent(params) {
49
+ if (params.eventType !== "review_changes_requested")
50
+ return undefined;
51
+ const coalesceKey = readRequestedChangesCoalesceKey(params.eventJson);
52
+ if (!coalesceKey)
53
+ return undefined;
54
+ const existing = this.listIssueSessionEvents(params.projectId, params.linearIssueId, { pendingOnly: true })
55
+ .filter((event) => event.eventType === "review_changes_requested")
56
+ .find((event) => readRequestedChangesCoalesceKey(event.eventJson) === coalesceKey);
57
+ if (!existing)
58
+ return undefined;
59
+ const mergedJson = mergeRequestedChangesEventJson(existing.eventJson, params.eventJson);
60
+ if (mergedJson !== existing.eventJson) {
61
+ this.connection.prepare(`
62
+ UPDATE issue_session_events
63
+ SET event_json = ?
64
+ WHERE id = ? AND processed_at IS NULL
65
+ `).run(mergedJson ?? null, existing.id);
66
+ }
67
+ return this.getIssueSessionEvent(existing.id) ?? existing;
68
+ }
44
69
  appendIssueSessionEventWithLease(lease, params) {
45
70
  return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => this.appendIssueSessionEvent(params));
46
71
  }
@@ -80,6 +105,16 @@ export class IssueSessionStore {
80
105
  WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
81
106
  `).run(now, runId, projectId, linearIssueId, ...eventIds);
82
107
  }
108
+ dismissIssueSessionEvents(projectId, linearIssueId, eventIds) {
109
+ if (eventIds.length === 0)
110
+ return;
111
+ const placeholders = eventIds.map(() => "?").join(", ");
112
+ this.connection.prepare(`
113
+ UPDATE issue_session_events
114
+ SET processed_at = ?, consumed_by_run_id = NULL
115
+ WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
116
+ `).run(isoNow(), projectId, linearIssueId, ...eventIds);
117
+ }
83
118
  clearPendingIssueSessionEvents(projectId, linearIssueId) {
84
119
  this.connection.prepare(`
85
120
  UPDATE issue_session_events
@@ -99,7 +134,7 @@ export class IssueSessionStore {
99
134
  const plan = deriveSessionWakePlan(issue, events);
100
135
  if (plan?.runType) {
101
136
  return {
102
- eventIds: events.map((event) => event.id),
137
+ eventIds: plan.eventIds,
103
138
  runType: plan.runType,
104
139
  context: plan.context,
105
140
  ...(plan.wakeReason ? { wakeReason: plan.wakeReason } : {}),
@@ -220,6 +255,12 @@ export class IssueSessionStore {
220
255
  return true;
221
256
  }) ?? false;
222
257
  }
258
+ dismissIssueSessionEventsWithLease(lease, eventIds) {
259
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
260
+ this.dismissIssueSessionEvents(lease.projectId, lease.linearIssueId, eventIds);
261
+ return true;
262
+ }) ?? false;
263
+ }
223
264
  clearPendingIssueSessionEventsWithLease(lease) {
224
265
  return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
225
266
  this.clearPendingIssueSessionEvents(lease.projectId, lease.linearIssueId);
@@ -0,0 +1,69 @@
1
+ import { spawnSync } from "node:child_process";
2
+ const UNMERGED_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
3
+ function parsePorcelainPath(line) {
4
+ const raw = line.slice(3).trim();
5
+ const renameSeparator = " -> ";
6
+ const renamed = raw.includes(renameSeparator) ? raw.slice(raw.indexOf(renameSeparator) + renameSeparator.length) : raw;
7
+ return renamed.replace(/^"|"$/g, "");
8
+ }
9
+ function hasGitPath(worktreePath, pathName) {
10
+ const result = spawnSync("git", ["-C", worktreePath, "rev-parse", "--git-path", pathName], { encoding: "utf8" });
11
+ if (result.status !== 0)
12
+ return false;
13
+ const resolved = result.stdout.trim();
14
+ if (!resolved)
15
+ return false;
16
+ return spawnSync("test", ["-f", resolved]).status === 0;
17
+ }
18
+ export function inspectGitWorktreeStatus(worktreePath) {
19
+ const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain=v1", "-uall"], {
20
+ encoding: "utf8",
21
+ });
22
+ if (result.status !== 0) {
23
+ return {
24
+ dirty: true,
25
+ mergeInProgress: false,
26
+ unmergedPaths: [],
27
+ changedPaths: [],
28
+ summary: `Unable to inspect worktree: ${(result.stderr || result.stdout).trim() || "git status failed"}`,
29
+ };
30
+ }
31
+ const lines = result.stdout.split(/\r?\n/).filter(Boolean);
32
+ const changedPaths = lines.map(parsePorcelainPath);
33
+ const unmergedPaths = lines
34
+ .filter((line) => UNMERGED_CODES.has(line.slice(0, 2)) || line.slice(0, 2).includes("U"))
35
+ .map(parsePorcelainPath);
36
+ const mergeInProgress = hasGitPath(worktreePath, "MERGE_HEAD")
37
+ || hasGitPath(worktreePath, "REBASE_HEAD")
38
+ || hasGitPath(worktreePath, "CHERRY_PICK_HEAD")
39
+ || hasGitPath(worktreePath, "REVERT_HEAD");
40
+ const dirty = lines.length > 0 || mergeInProgress;
41
+ const summary = dirty
42
+ ? unmergedPaths.length > 0
43
+ ? `Worktree has unresolved merge conflicts: ${unmergedPaths.join(", ")}`
44
+ : changedPaths.length > 0
45
+ ? `Worktree has uncommitted changes: ${changedPaths.slice(0, 12).join(", ")}${changedPaths.length > 12 ? ", ..." : ""}`
46
+ : "Worktree has an unfinished git operation"
47
+ : undefined;
48
+ return {
49
+ dirty,
50
+ mergeInProgress,
51
+ unmergedPaths,
52
+ changedPaths,
53
+ ...(summary ? { summary } : {}),
54
+ };
55
+ }
56
+ export function isRepairRunType(runType) {
57
+ return runType === "review_fix" || runType === "branch_upkeep" || runType === "ci_repair" || runType === "queue_repair";
58
+ }
59
+ export function dirtyWorktreeEventPayload(status) {
60
+ if (!status.dirty)
61
+ return undefined;
62
+ return {
63
+ dirtyWorktree: true,
64
+ mergeInProgress: status.mergeInProgress,
65
+ unmergedPaths: status.unmergedPaths,
66
+ changedPaths: status.changedPaths,
67
+ summary: status.summary,
68
+ };
69
+ }
@@ -3,6 +3,7 @@ import { buildQueueRepairContextFromEvent } from "./merge-queue-incident.js";
3
3
  import { isIssueTerminal } from "./pr-state.js";
4
4
  import { buildGitHubQueueFailureContext, getRelevantGitHubCiSnapshot, resolveGitHubBranchFailureContext, resolveGitHubCheckClass, } from "./github-webhook-failure-context.js";
5
5
  import { isQueueEvictionFailure, isSettledBranchFailure } from "./github-webhook-policy.js";
6
+ import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
6
7
  export async function maybeEnqueueGitHubReactiveRun(params) {
7
8
  const { issue, event, project, logger, feed, wakeDispatcher, db, fetchImpl, failureContextResolver } = params;
8
9
  if (isIssueTerminal(issue))
@@ -159,9 +160,18 @@ async function handleRequestedChangesEvent(params) {
159
160
  }, "Failed to fetch inline review comments for requested-changes event");
160
161
  return undefined;
161
162
  });
163
+ const identity = buildRequestedChangesWakeIdentity({
164
+ linearIssueId: issue.linearIssueId,
165
+ headSha: issue.prHeadSha ?? event.headSha,
166
+ reviewCommitId: event.reviewCommitId,
167
+ reviewId: event.reviewId,
168
+ reviewerName: event.reviewerName,
169
+ });
162
170
  const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
163
171
  eventType: "review_changes_requested",
164
172
  eventJson: JSON.stringify({
173
+ requestedChangesCoalesceKey: identity.coalesceKey,
174
+ ...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
165
175
  reviewBody: event.reviewBody,
166
176
  reviewCommitId: event.reviewCommitId,
167
177
  reviewId: event.reviewId,
@@ -169,11 +179,7 @@ async function handleRequestedChangesEvent(params) {
169
179
  reviewerName: event.reviewerName,
170
180
  ...(reviewComments && reviewComments.length > 0 ? { reviewComments } : {}),
171
181
  }),
172
- dedupeKey: [
173
- "review_changes_requested",
174
- issue.prHeadSha ?? event.headSha ?? "unknown-sha",
175
- event.reviewerName ?? "unknown-reviewer",
176
- ].join("::"),
182
+ dedupeKey: identity.dedupeKey,
177
183
  });
178
184
  logger.info({
179
185
  issueKey: issue.issueKey,
package/dist/http.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fastify from "fastify";
2
2
  import rawBody from "fastify-raw-body";
3
3
  import { getBuildInfo } from "./build-info.js";
4
+ import { getCodexStatusSnapshot } from "./codex-status.js";
4
5
  export async function buildHttpServer(config, service, logger) {
5
6
  const buildInfo = getBuildInfo();
6
7
  const loopbackBind = isLoopbackBind(config.server.bind);
@@ -244,6 +245,14 @@ export async function buildHttpServer(config, service, logger) {
244
245
  });
245
246
  }
246
247
  if (managementRoutesEnabled) {
248
+ app.get("/status", async (_request, reply) => {
249
+ const status = getCodexStatusSnapshot(config.runner.codex.bin);
250
+ return reply.code(status.ok ? 200 : 502).send(status);
251
+ });
252
+ app.get("/api/codex/status", async (_request, reply) => {
253
+ const status = getCodexStatusSnapshot(config.runner.codex.bin);
254
+ return reply.code(status.ok ? 200 : 502).send(status);
255
+ });
247
256
  app.get("/api/issues", async (_request, reply) => {
248
257
  return reply.send({ ok: true, issues: service.listTrackedIssues() });
249
258
  });
@@ -9,6 +9,7 @@ import { getReviewFixBudget } from "./run-budgets.js";
9
9
  import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
10
10
  import { fetchPullRequestSnapshot } from "./reconcile-pr-fetch.js";
11
11
  import { buildPrStateUpdates } from "./reconcile-pr-state-updates.js";
12
+ import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
12
13
  import { execCommand } from "./utils.js";
13
14
  export class IdleIssueReconciler {
14
15
  db;
@@ -288,15 +289,34 @@ export class IdleIssueReconciler {
288
289
  }
289
290
  else if (runType === "review_fix" || runType === "branch_upkeep") {
290
291
  eventType = "review_changes_requested";
291
- dedupeKey = `${dedupeScope}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown"}`;
292
+ dedupeKey = buildRequestedChangesWakeIdentity({
293
+ linearIssueId: issue.linearIssueId,
294
+ runType,
295
+ headSha: issue.prHeadSha,
296
+ }).dedupeKey;
292
297
  }
293
298
  else {
294
299
  eventType = "delegated";
295
300
  dedupeKey = `${dedupeScope}:implementation:${issue.linearIssueId}`;
296
301
  }
302
+ const requestedChangesIdentity = eventType === "review_changes_requested"
303
+ ? buildRequestedChangesWakeIdentity({
304
+ linearIssueId: issue.linearIssueId,
305
+ runType: runType === "branch_upkeep" ? "branch_upkeep" : "review_fix",
306
+ headSha: issue.prHeadSha,
307
+ })
308
+ : undefined;
297
309
  this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
298
310
  eventType,
299
- ...(context ? { eventJson: JSON.stringify(context) } : {}),
311
+ ...(context || requestedChangesIdentity ? {
312
+ eventJson: JSON.stringify({
313
+ ...context,
314
+ ...(requestedChangesIdentity ? {
315
+ requestedChangesCoalesceKey: requestedChangesIdentity.coalesceKey,
316
+ ...(requestedChangesIdentity.headSha ? { requestedChangesHeadSha: requestedChangesIdentity.headSha } : {}),
317
+ } : {}),
318
+ }),
319
+ } : {}),
300
320
  dedupeKey,
301
321
  });
302
322
  }
@@ -10,6 +10,7 @@ const TERMINAL_SESSION_EVENTS = new Set([
10
10
  const NON_ACTIONABLE_SESSION_EVENTS = new Set([
11
11
  "delegation_observed",
12
12
  "prompt_delivered",
13
+ "self_comment",
13
14
  "run_released_authority",
14
15
  ]);
15
16
  // "main_repair" was removed as a run type; legacy session-event payloads carrying it
@@ -28,6 +29,7 @@ export function deriveSessionWakePlan(issue, events) {
28
29
  }
29
30
  const context = {};
30
31
  const followUps = [];
32
+ let eventIds = [];
31
33
  let wakeReason;
32
34
  let runType;
33
35
  let resumeThread = false;
@@ -37,12 +39,14 @@ export function deriveSessionWakePlan(issue, events) {
37
39
  case "merge_steward_incident":
38
40
  runType = "queue_repair";
39
41
  wakeReason = "merge_steward_incident";
42
+ eventIds = [event.id];
40
43
  Object.assign(context, payload ?? {});
41
44
  break;
42
45
  case "settled_red_ci":
43
46
  if (runType !== "queue_repair") {
44
47
  runType = "ci_repair";
45
48
  wakeReason = "settled_red_ci";
49
+ eventIds = [event.id];
46
50
  Object.assign(context, payload ?? {});
47
51
  }
48
52
  break;
@@ -50,6 +54,7 @@ export function deriveSessionWakePlan(issue, events) {
50
54
  if (runType !== "queue_repair" && runType !== "ci_repair") {
51
55
  runType = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_fix";
52
56
  wakeReason = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_changes_requested";
57
+ eventIds = [event.id];
53
58
  Object.assign(context, payload ?? {});
54
59
  }
55
60
  break;
@@ -57,6 +62,10 @@ export function deriveSessionWakePlan(issue, events) {
57
62
  if (!runType) {
58
63
  runType = parseRunType(payload?.runType) ?? "implementation";
59
64
  wakeReason = issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
65
+ eventIds = [event.id];
66
+ }
67
+ else {
68
+ eventIds.push(event.id);
60
69
  }
61
70
  Object.assign(context, payload ?? {});
62
71
  break;
@@ -66,6 +75,10 @@ export function deriveSessionWakePlan(issue, events) {
66
75
  if (!runType) {
67
76
  runType = "implementation";
68
77
  wakeReason = event.eventType;
78
+ eventIds = [event.id];
79
+ }
80
+ else {
81
+ eventIds.push(event.id);
69
82
  }
70
83
  Object.assign(context, payload ?? {});
71
84
  resumeThread = true;
@@ -74,6 +87,10 @@ export function deriveSessionWakePlan(issue, events) {
74
87
  if (!runType) {
75
88
  runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
76
89
  wakeReason = "direct_reply";
90
+ eventIds = [event.id];
91
+ }
92
+ else {
93
+ eventIds.push(event.id);
77
94
  }
78
95
  const text = typeof payload?.text === "string"
79
96
  ? payload.text
@@ -94,6 +111,10 @@ export function deriveSessionWakePlan(issue, events) {
94
111
  runType = parseRunType(payload?.runType)
95
112
  ?? (issue.prReviewState === "changes_requested" ? "review_fix" : "implementation");
96
113
  wakeReason = "completion_check_continue";
114
+ eventIds = [event.id];
115
+ }
116
+ else {
117
+ eventIds.push(event.id);
97
118
  }
98
119
  if (typeof payload?.summary === "string" && payload.summary.trim()) {
99
120
  context.completionCheckSummary = payload.summary.trim();
@@ -108,6 +129,10 @@ export function deriveSessionWakePlan(issue, events) {
108
129
  if (!runType) {
109
130
  runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
110
131
  wakeReason = issue.issueClass === "orchestration" ? "human_instruction" : event.eventType;
132
+ eventIds = [event.id];
133
+ }
134
+ else {
135
+ eventIds.push(event.id);
111
136
  }
112
137
  const text = typeof payload?.text === "string"
113
138
  ? payload.text
@@ -151,7 +176,7 @@ export function deriveSessionWakePlan(issue, events) {
151
176
  if (wakeReason) {
152
177
  context.wakeReason = wakeReason;
153
178
  }
154
- return { runType, wakeReason, resumeThread, context };
179
+ return { eventIds, runType, wakeReason, resumeThread, context };
155
180
  }
156
181
  export function isActionableIssueSessionEventType(eventType) {
157
182
  return !NON_ACTIONABLE_SESSION_EVENTS.has(eventType);
@@ -1,3 +1,4 @@
1
+ import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
1
2
  function parseObjectJson(value) {
2
3
  if (!value)
3
4
  return undefined;
@@ -42,16 +43,23 @@ export function buildOperatorRetryEvent(issue, runType, source = "operator_retry
42
43
  };
43
44
  }
44
45
  if (runType === "review_fix" || runType === "branch_upkeep") {
46
+ const identity = buildRequestedChangesWakeIdentity({
47
+ linearIssueId: issue.linearIssueId,
48
+ runType,
49
+ headSha: issue.prHeadSha,
50
+ });
45
51
  return {
46
52
  eventType: "review_changes_requested",
47
53
  eventJson: JSON.stringify({
54
+ requestedChangesCoalesceKey: identity.coalesceKey,
55
+ ...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
48
56
  ...(runType === "branch_upkeep"
49
57
  ? { reviewBody: `${humanizeSource(source)} requested retry of branch upkeep after requested changes.` }
50
58
  : { promptContext: `${humanizeSource(source)} requested retry of review-fix work.` }),
51
59
  ...(runType === "branch_upkeep" ? { branchUpkeepRequired: true, wakeReason: "branch_upkeep" } : {}),
52
60
  source,
53
61
  }),
54
- dedupeKey: `${source}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`,
62
+ dedupeKey: identity.dedupeKey,
55
63
  };
56
64
  }
57
65
  return {
@@ -563,6 +563,10 @@ function buildPublicationContract(runType, issueClass, context) {
563
563
  "Restore and publish on the existing PR branch: commit and push the same branch.",
564
564
  "Do not open a new PR.",
565
565
  "A PR-less stop is not a successful outcome for a repair run unless a genuine external blocker prevents any correct push.",
566
+ "After pushing a new head, stop and report the pushed commit. Do not poll or watch GitHub for CI, review, mergeability, review-quill, merge-steward, approval, or merge completion.",
567
+ "Do not run blocking wait commands such as `gh pr checks --watch`, `gh pr view` polling loops, `review-quill pr status --wait`, `merge-steward pr status --wait`, or `gh pr merge` from the agent turn.",
568
+ "PatchRelay receives GitHub webhooks for check, review, and base-branch changes; those events will re-enter automation if more work is needed.",
569
+ "If the issue text asks you to watch CI, wait for approval, or merge after checks pass, treat that as PatchRelay service responsibility rather than agent-turn work.",
566
570
  "",
567
571
  ...(requiresFreshQueueHead
568
572
  ? [
@@ -0,0 +1,64 @@
1
+ const UNKNOWN_HEAD = "unknown-sha";
2
+ export function buildRequestedChangesWakeIdentity(params) {
3
+ const runType = params.runType ?? "review_fix";
4
+ const headSha = params.reviewCommitId ?? params.headSha;
5
+ const coalesceHead = headSha ?? UNKNOWN_HEAD;
6
+ const coalesceKey = `review_changes_requested:${runType}:issue:${params.linearIssueId}:head:${coalesceHead}`;
7
+ if (params.reviewId !== undefined && params.reviewId !== null) {
8
+ return {
9
+ dedupeKey: `review_changes_requested:${runType}:issue:${params.linearIssueId}:review:${params.reviewId}`,
10
+ coalesceKey,
11
+ ...(headSha ? { headSha } : {}),
12
+ };
13
+ }
14
+ if (headSha && params.reviewerName) {
15
+ return {
16
+ dedupeKey: `review_changes_requested:${runType}:issue:${params.linearIssueId}:head:${headSha}:reviewer:${params.reviewerName}`,
17
+ coalesceKey,
18
+ headSha,
19
+ };
20
+ }
21
+ return {
22
+ dedupeKey: coalesceKey,
23
+ coalesceKey,
24
+ ...(headSha ? { headSha } : {}),
25
+ };
26
+ }
27
+ export function readRequestedChangesCoalesceKey(eventJson) {
28
+ if (!eventJson)
29
+ return undefined;
30
+ try {
31
+ const parsed = JSON.parse(eventJson);
32
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
33
+ return undefined;
34
+ const value = parsed.requestedChangesCoalesceKey;
35
+ return typeof value === "string" && value.trim() ? value : undefined;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ export function mergeRequestedChangesEventJson(existingJson, incomingJson) {
42
+ if (!incomingJson)
43
+ return existingJson;
44
+ const incoming = parseObject(incomingJson);
45
+ if (!incoming)
46
+ return existingJson;
47
+ const existing = parseObject(existingJson);
48
+ if (!existing)
49
+ return incomingJson;
50
+ return JSON.stringify({ ...existing, ...incoming });
51
+ }
52
+ function parseObject(raw) {
53
+ if (!raw)
54
+ return undefined;
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
58
+ ? parsed
59
+ : undefined;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
@@ -3,6 +3,7 @@ import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-ses
3
3
  import { handleNoPrCompletionCheck } from "./no-pr-completion-check.js";
4
4
  import { resolveCompletedRunState } from "./run-completion-policy.js";
5
5
  import { computeChangeIdentityFromWorktree } from "./change-identity.js";
6
+ import { inspectGitWorktreeStatus, isRepairRunType } from "./git-worktree-status.js";
6
7
  function parseEventJson(eventJson) {
7
8
  if (!eventJson)
8
9
  return undefined;
@@ -273,6 +274,16 @@ export class RunFinalizer {
273
274
  // exists. Keeping the parameter would be redundant.
274
275
  this.clearProgressAndRelease(params.run);
275
276
  }
277
+ verifyRepairWorktreeClean(run, issue) {
278
+ if (!isRepairRunType(run.runType) || !issue.worktreePath)
279
+ return undefined;
280
+ const status = inspectGitWorktreeStatus(issue.worktreePath);
281
+ if (!status.dirty)
282
+ return undefined;
283
+ return status.summary
284
+ ? `Repair run finished with a dirty worktree; ${status.summary}`
285
+ : "Repair run finished with a dirty worktree";
286
+ }
276
287
  async finalizeCompletedRun(params) {
277
288
  const { run, issue, thread, threadId } = params;
278
289
  // Plan §4.4: a run flagged shouldNotPublish was deliberately
@@ -291,6 +302,19 @@ export class RunFinalizer {
291
302
  const trackedIssue = this.db.issueToTrackedIssue(issue);
292
303
  const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.runs.listThreadEvents(run.id)));
293
304
  const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
305
+ const dirtyRepairWorktreeError = this.verifyRepairWorktreeClean(run, freshIssue);
306
+ if (dirtyRepairWorktreeError) {
307
+ this.failRunAndClear(run, dirtyRepairWorktreeError, "escalated");
308
+ this.syncFailureOutcome({
309
+ run,
310
+ fallbackIssue: freshIssue,
311
+ message: dirtyRepairWorktreeError,
312
+ level: "error",
313
+ status: "dirty_repair_worktree",
314
+ summary: dirtyRepairWorktreeError,
315
+ });
316
+ return;
317
+ }
294
318
  const verifiedRepairError = await this.completionPolicy.verifyReactiveRunAdvancedBranch(run, freshIssue);
295
319
  if (verifiedRepairError) {
296
320
  const holdState = params.resolveRecoverableRunState(freshIssue) ?? "failed";
@@ -308,6 +308,42 @@ export class RunOrchestrator {
308
308
  const baseContext = isRequestedChangesRunType(runType)
309
309
  ? await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context)
310
310
  : context;
311
+ const launchIssue = this.db.issues.getIssue(item.projectId, item.issueId) ?? issue;
312
+ const inactiveRequestedChangesWakeReason = this.resolveInactiveRequestedChangesWakeReason(launchIssue, runType, baseContext);
313
+ if (inactiveRequestedChangesWakeReason) {
314
+ const lease = { projectId: item.projectId, linearIssueId: item.issueId, leaseId };
315
+ const requestedChangesEventIds = this.db.issueSessions
316
+ .listIssueSessionEvents(item.projectId, item.issueId, { pendingOnly: true })
317
+ .filter((event) => wake.eventIds.includes(event.id) && event.eventType === "review_changes_requested")
318
+ .map((event) => event.id);
319
+ const dismissed = this.db.issueSessions.dismissIssueSessionEventsWithLease(lease, requestedChangesEventIds);
320
+ if (!dismissed) {
321
+ this.releaseIssueSessionLease(item.projectId, item.issueId);
322
+ this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "lease_lost_dismissing_inactive_requested_changes_wake" }, "Skipped issue run: lost lease while dismissing inactive requested-changes wake");
323
+ return;
324
+ }
325
+ this.db.issueSessions.setIssueSessionLastWakeReasonWithLease(lease, wake.wakeReason ?? null);
326
+ this.feed?.publish({
327
+ level: "info",
328
+ kind: "stage",
329
+ issueKey: issue.issueKey,
330
+ projectId: item.projectId,
331
+ stage: runType,
332
+ status: "skipped",
333
+ summary: inactiveRequestedChangesWakeReason,
334
+ });
335
+ this.logger.info({
336
+ issueKey: issue.issueKey,
337
+ projectId: item.projectId,
338
+ runType,
339
+ reason: "inactive_requested_changes_wake",
340
+ prReviewState: launchIssue.prReviewState,
341
+ prState: launchIssue.prState,
342
+ }, "Skipped issue run: requested-changes wake is no longer active");
343
+ this.releaseIssueSessionLease(item.projectId, item.issueId);
344
+ this.wakeDispatcher.dispatchIfWakePending(item.projectId, item.issueId);
345
+ return;
346
+ }
311
347
  const recoveredLinearActivityContext = await recoverLinearAgentActivityContext({
312
348
  linearProvider: this.linearProvider,
313
349
  projectId: issue.projectId,
@@ -506,6 +542,18 @@ export class RunOrchestrator {
506
542
  async resolveRequestedChangesWakeContext(issue, runType, context) {
507
543
  return await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context);
508
544
  }
545
+ resolveInactiveRequestedChangesWakeReason(issue, runType, context) {
546
+ if (runType !== "review_fix" || context?.branchUpkeepRequired === true) {
547
+ return undefined;
548
+ }
549
+ if (issue.prState && issue.prState !== "open") {
550
+ return `Skipping requested-changes run because PR #${issue.prNumber ?? "unknown"} is ${issue.prState}`;
551
+ }
552
+ if (issue.prReviewState && issue.prReviewState !== "changes_requested") {
553
+ return `Skipping requested-changes run because PR #${issue.prNumber ?? "unknown"} review state is ${issue.prReviewState}`;
554
+ }
555
+ return undefined;
556
+ }
509
557
  async readThreadWithRetry(threadId, maxRetries = 3) {
510
558
  let lastError;
511
559
  for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -1,4 +1,5 @@
1
1
  import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
2
+ import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
2
3
  export class RunWakePlanner {
3
4
  db;
4
5
  constructor(db) {
@@ -19,6 +20,7 @@ export class RunWakePlanner {
19
20
  appendWakeEventWithLease(lease, issue, runType, context, dedupeScope) {
20
21
  let eventType;
21
22
  let dedupeKey;
23
+ let eventContext = context;
22
24
  if (runType === "queue_repair") {
23
25
  eventType = "merge_steward_incident";
24
26
  dedupeKey = `${dedupeScope ?? "wake"}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`;
@@ -29,7 +31,17 @@ export class RunWakePlanner {
29
31
  }
30
32
  else if (runType === "review_fix" || runType === "branch_upkeep") {
31
33
  eventType = "review_changes_requested";
32
- dedupeKey = `${dedupeScope ?? "wake"}:${runType}:${issue.linearIssueId}:${issue.prHeadSha ?? "unknown-sha"}`;
34
+ const identity = buildRequestedChangesWakeIdentity({
35
+ linearIssueId: issue.linearIssueId,
36
+ runType,
37
+ headSha: issue.prHeadSha,
38
+ });
39
+ dedupeKey = identity.dedupeKey;
40
+ eventContext = {
41
+ ...context,
42
+ requestedChangesCoalesceKey: identity.coalesceKey,
43
+ ...(identity.headSha ? { requestedChangesHeadSha: identity.headSha } : {}),
44
+ };
33
45
  }
34
46
  else {
35
47
  eventType = "delegated";
@@ -39,7 +51,7 @@ export class RunWakePlanner {
39
51
  projectId: issue.projectId,
40
52
  linearIssueId: issue.linearIssueId,
41
53
  eventType,
42
- ...(context ? { eventJson: JSON.stringify(context) } : {}),
54
+ ...(eventContext ? { eventJson: JSON.stringify(eventContext) } : {}),
43
55
  dedupeKey,
44
56
  }));
45
57
  }
@@ -1,6 +1,7 @@
1
1
  import { appendDelegationObservedEvent } from "./delegation-audit.js";
2
2
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
3
3
  import { isResumablePausedLocalWork } from "./paused-issue-state.js";
4
+ import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
4
5
  export class ServiceStartupRecovery {
5
6
  db;
6
7
  linearProvider;
@@ -178,11 +179,28 @@ export class ServiceStartupRecovery {
178
179
  ? `startup_recovery:queue_repair:${linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`
179
180
  : runType === "ci_repair"
180
181
  ? `startup_recovery:ci_repair:${linearIssueId}:${issue.lastGitHubFailureSignature ?? issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown"}`
181
- : `startup_recovery:${runType}:${linearIssueId}:${issue.prHeadSha ?? "unknown"}`;
182
+ : buildRequestedChangesWakeIdentity({
183
+ linearIssueId,
184
+ runType,
185
+ headSha: issue.prHeadSha,
186
+ }).dedupeKey;
187
+ const requestedChangesIdentity = eventType === "review_changes_requested"
188
+ ? buildRequestedChangesWakeIdentity({
189
+ linearIssueId,
190
+ runType: runType === "branch_upkeep" ? "branch_upkeep" : "review_fix",
191
+ headSha: issue.prHeadSha,
192
+ })
193
+ : undefined;
182
194
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
183
195
  projectId,
184
196
  linearIssueId,
185
197
  eventType,
198
+ ...(requestedChangesIdentity ? {
199
+ eventJson: JSON.stringify({
200
+ requestedChangesCoalesceKey: requestedChangesIdentity.coalesceKey,
201
+ ...(requestedChangesIdentity.headSha ? { requestedChangesHeadSha: requestedChangesIdentity.headSha } : {}),
202
+ }),
203
+ } : {}),
186
204
  dedupeKey,
187
205
  });
188
206
  }
@@ -7,10 +7,18 @@ function clean(value) {
7
7
  function eventStatusNote(event) {
8
8
  if (!event)
9
9
  return undefined;
10
+ const payload = event.eventJson ? parseEventJson(event.eventJson) : undefined;
11
+ const dirtySummary = typeof payload?.summary === "string" && payload.dirtyWorktree === true
12
+ ? payload.summary
13
+ : undefined;
10
14
  switch (event.eventType) {
11
15
  case "stop_requested":
16
+ if (dirtySummary)
17
+ return `Operator stopped the run with dirty worktree: ${dirtySummary}. Use retry or delegate again to resume.`;
12
18
  return "Operator stopped the run. Use retry or delegate again to resume.";
13
19
  case "undelegated":
20
+ if (dirtySummary)
21
+ return `Issue was un-delegated from PatchRelay with dirty worktree: ${dirtySummary}`;
14
22
  return "Issue was un-delegated from PatchRelay. Delegate it again to resume.";
15
23
  case "issue_removed":
16
24
  return "Issue was removed from Linear.";
@@ -22,6 +30,15 @@ function eventStatusNote(event) {
22
30
  return undefined;
23
31
  }
24
32
  }
33
+ function parseEventJson(value) {
34
+ try {
35
+ const parsed = JSON.parse(value);
36
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
25
42
  export function deriveIssueStatusNote(params) {
26
43
  const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
27
44
  if (blockedByKeys.length > 0) {
@@ -61,7 +78,7 @@ export function deriveIssueStatusNote(params) {
61
78
  note = failureSummary ?? sessionSummary ?? latestRunNote;
62
79
  break;
63
80
  default:
64
- note = sessionSummary ?? latestRunNote ?? failureSummary;
81
+ note = latestEventNote ?? sessionSummary ?? latestRunNote ?? failureSummary;
65
82
  break;
66
83
  }
67
84
  }
@@ -1,6 +1,7 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "../agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js";
3
3
  import { buildAlreadyRunningThought, buildAgentSessionAcknowledgementThought, buildBlockedDelegationActivity, buildDelegationThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
4
+ import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "../git-worktree-status.js";
4
5
  import { resolveProject, triggerEventAllowed } from "../project-resolution.js";
5
6
  const PATCHRELAY_AGENT_ACTIVITY_TYPES = new Set([
6
7
  "action",
@@ -154,6 +155,12 @@ export class AgentSessionHandler {
154
155
  async handleStopSignal(params) {
155
156
  const issueId = params.normalized.issue.id;
156
157
  const sessionId = params.normalized.agentSession.id;
158
+ const storedIssue = this.db.issues.getIssue(params.project.id, issueId);
159
+ const worktreeStatus = storedIssue?.worktreePath
160
+ ? inspectGitWorktreeStatus(storedIssue.worktreePath)
161
+ : undefined;
162
+ const dirtyPayload = worktreeStatus ? dirtyWorktreeEventPayload(worktreeStatus) : undefined;
163
+ const dirtySummary = typeof dirtyPayload?.summary === "string" ? dirtyPayload.summary : undefined;
157
164
  if (params.activeRun?.threadId && params.activeRun.turnId) {
158
165
  try {
159
166
  await this.codex.steerTurn({
@@ -165,7 +172,12 @@ export class AgentSessionHandler {
165
172
  catch (error) {
166
173
  this.logger.warn({ issueKey: params.trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop signal");
167
174
  }
168
- this.db.runs.finishRun(params.activeRun.id, { status: "released", threadId: params.activeRun.threadId, turnId: params.activeRun.turnId });
175
+ this.db.runs.finishRun(params.activeRun.id, {
176
+ status: "released",
177
+ threadId: params.activeRun.threadId,
178
+ turnId: params.activeRun.turnId,
179
+ failureReason: dirtySummary ? `Stop signal received; ${dirtySummary}` : "Stop signal received",
180
+ });
169
181
  }
170
182
  this.db.issueSessions.upsertIssueRespectingActiveLease(params.project.id, issueId, {
171
183
  projectId: params.project.id,
@@ -178,6 +190,7 @@ export class AgentSessionHandler {
178
190
  projectId: params.project.id,
179
191
  linearIssueId: issueId,
180
192
  eventType: "stop_requested",
193
+ ...(dirtyPayload ? { eventJson: JSON.stringify(dirtyPayload) } : {}),
181
194
  dedupeKey: `stop_requested:${issueId}`,
182
195
  });
183
196
  this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.project.id, issueId);
@@ -188,7 +201,7 @@ export class AgentSessionHandler {
188
201
  projectId: params.project.id,
189
202
  issueKey: params.trackedIssue?.issueKey,
190
203
  status: "stopped",
191
- summary: "Stop signal received - work halted",
204
+ summary: dirtySummary ? `Stop signal received - work halted with dirty worktree: ${dirtySummary}` : "Stop signal received - work halted",
192
205
  });
193
206
  const updatedIssue = this.db.issues.getIssue(params.project.id, issueId);
194
207
  await this.publishAgentActivity(params.linear, sessionId, buildStopConfirmationActivity());
@@ -6,6 +6,7 @@ import { syncIssueDependencies } from "./issue-dependency-sync.js";
6
6
  import { resolveLinkedPrAdoption } from "./linked-pr-adoption.js";
7
7
  import { buildOperatorRetryEvent } from "../operator-retry-event.js";
8
8
  import { planIssueWebhookWorkflow } from "./issue-webhook-workflow-planner.js";
9
+ import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "../git-worktree-status.js";
9
10
  export class DesiredStageRecorder {
10
11
  db;
11
12
  linearProvider;
@@ -74,6 +75,15 @@ export class DesiredStageRecorder {
74
75
  incomingAgentSessionId,
75
76
  childIssueCount,
76
77
  });
78
+ const releaseWorktreeStatus = workflowPlan.effectiveRunRelease.release && activeRun && existingIssue?.worktreePath
79
+ ? inspectGitWorktreeStatus(existingIssue.worktreePath)
80
+ : undefined;
81
+ const releaseReason = workflowPlan.effectiveRunRelease.reason
82
+ ? releaseWorktreeStatus?.dirty && releaseWorktreeStatus.summary
83
+ ? `${workflowPlan.effectiveRunRelease.reason}; ${releaseWorktreeStatus.summary}`
84
+ : workflowPlan.effectiveRunRelease.reason
85
+ : undefined;
86
+ const dirtyWorktreePayload = releaseWorktreeStatus ? dirtyWorktreeEventPayload(releaseWorktreeStatus) : undefined;
77
87
  const commitIssueUpdate = () => {
78
88
  const record = this.db.issues.upsertIssue({
79
89
  projectId: params.project.id,
@@ -94,8 +104,8 @@ export class DesiredStageRecorder {
94
104
  delegatedToPatchRelay: delegated,
95
105
  ...workflowPlan.resolvedIssueUpdate,
96
106
  });
97
- if (workflowPlan.effectiveRunRelease.release && activeRun && workflowPlan.effectiveRunRelease.reason) {
98
- this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: workflowPlan.effectiveRunRelease.reason });
107
+ if (workflowPlan.effectiveRunRelease.release && activeRun && releaseReason) {
108
+ this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: releaseReason });
99
109
  }
100
110
  return record;
101
111
  };
@@ -119,6 +129,11 @@ export class DesiredStageRecorder {
119
129
  projectId: params.project.id,
120
130
  linearIssueId: normalizedIssue.id,
121
131
  eventType: "undelegated",
132
+ ...(dirtyWorktreePayload
133
+ ? {
134
+ eventJson: JSON.stringify(dirtyWorktreePayload),
135
+ }
136
+ : {}),
122
137
  dedupeKey: `undelegated:${normalizedIssue.id}`,
123
138
  });
124
139
  this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.project.id, normalizedIssue.id);
@@ -130,9 +145,11 @@ export class DesiredStageRecorder {
130
145
  projectId: params.project.id,
131
146
  stage: issue.factoryState,
132
147
  status: "un_delegated",
133
- summary: issue.factoryState === "awaiting_input"
134
- ? "Issue un-delegated from PatchRelay"
135
- : `Issue un-delegated from PatchRelay; ${issue.factoryState} is now paused`,
148
+ summary: releaseWorktreeStatus?.dirty && releaseWorktreeStatus.summary
149
+ ? `Issue un-delegated from PatchRelay with dirty worktree: ${releaseWorktreeStatus.summary}`
150
+ : issue.factoryState === "awaiting_input"
151
+ ? "Issue un-delegated from PatchRelay"
152
+ : `Issue un-delegated from PatchRelay; ${issue.factoryState} is now paused`,
136
153
  });
137
154
  }
138
155
  else if (workflowPlan.blockerPausedImplementation) {
@@ -16,6 +16,7 @@ WorkingDirectory=/home/your-user
16
16
  EnvironmentFile=-/home/your-user/.config/patchrelay/runtime.env
17
17
  EnvironmentFile=-/home/your-user/.config/patchrelay/service.env
18
18
  Environment=NODE_ENV=production
19
+ Environment=CODEX_HOME=/home/alv/.codex-patchrelay
19
20
  Environment=PATCHRELAY_CONFIG=/home/your-user/.config/patchrelay/patchrelay.json
20
21
  Environment=PATH=/home/your-user/.local/share/patchrelay/bin:/home/your-user/.local/bin:/usr/local/bin:/usr/bin:/bin
21
22
 
@@ -43,7 +44,7 @@ PrivateTmp=true
43
44
  PrivateMounts=true
44
45
  ProtectSystem=strict
45
46
  ProtectHome=false
46
- ReadWritePaths=/home/your-user/.config/patchrelay /home/your-user/.local/state/patchrelay /home/your-user/.local/share/patchrelay
47
+ ReadWritePaths=/home/your-user/.config/patchrelay /home/your-user/.local/state/patchrelay /home/your-user/.local/share/patchrelay /home/alv/.codex-patchrelay
47
48
 
48
49
  # PatchRelay runs as your real user so Codex inherits your existing git,
49
50
  # SSH, and local tool permissions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.73.3",
3
+ "version": "0.74.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -31,17 +31,17 @@
31
31
  "dependencies": {
32
32
  "fastify": "^5.8.5",
33
33
  "fastify-raw-body": "^5.0.0",
34
- "ink": "^7.0.1",
34
+ "ink": "^7.0.4",
35
35
  "pino": "^10.3.1",
36
36
  "pino-logfmt": "^1.1.4",
37
- "react": "^19.2.5",
38
- "zod": "^4.3.6"
37
+ "react": "^19.2.6",
38
+ "zod": "^4.4.3"
39
39
  },
40
40
  "devDependencies": {
41
- "@types/node": "^25.6.0",
42
- "@types/react": "^19.2.14",
43
- "@typescript/native-preview": "7.0.0-dev.20260427.1",
44
- "oxlint": "^1.62.0"
41
+ "@types/node": "^25.9.1",
42
+ "@types/react": "^19.2.15",
43
+ "@typescript/native-preview": "7.0.0-dev.20260527.1",
44
+ "oxlint": "^1.67.0"
45
45
  },
46
46
  "scripts": {
47
47
  "dev": "node --watch --experimental-transform-types src/index.ts",