patchrelay 0.7.2 → 0.7.3

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.7.2",
4
- "commit": "ae8c74a3c09a",
5
- "builtAt": "2026-03-14T07:49:19.447Z"
3
+ "version": "0.7.3",
4
+ "commit": "ab85617bb14d",
5
+ "builtAt": "2026-03-14T13:31:07.892Z"
6
6
  }
package/dist/config.js CHANGED
@@ -101,7 +101,7 @@ const configSchema = z.object({
101
101
  });
102
102
  function defaultTriggerEvents(actor) {
103
103
  if (actor === "app") {
104
- return ["agentSessionCreated", "agentPrompted", "statusChanged"];
104
+ return ["issueCreated", "agentSessionCreated", "agentPrompted", "statusChanged"];
105
105
  }
106
106
  return ["statusChanged"];
107
107
  }
@@ -28,10 +28,11 @@ export async function buildReconciliationSnapshot(params) {
28
28
  .catch(() => ({ status: "unknown" }))
29
29
  : ({ status: "unknown" });
30
30
  const liveCodex = runLease.threadId
31
- ? await params.codex
32
- .readThread(runLease.threadId, true)
33
- .then((thread) => ({ status: "found", thread }))
34
- .catch((error) => mapCodexReadFailure(error))
31
+ ? await hydrateLiveCodexState({
32
+ codex: params.codex,
33
+ threadId: runLease.threadId,
34
+ ...(workspaceOwnership?.worktreePath ? { cwd: workspaceOwnership.worktreePath } : {}),
35
+ })
35
36
  : ({ status: "unknown" });
36
37
  const obligations = params.stores.obligations
37
38
  .listPendingObligations({ runLeaseId: runLease.id, includeInProgress: true })
@@ -83,6 +84,39 @@ export async function buildReconciliationSnapshot(params) {
83
84
  },
84
85
  };
85
86
  }
87
+ async function hydrateLiveCodexState(params) {
88
+ try {
89
+ const thread = await params.codex.readThread(params.threadId, true);
90
+ if (latestThreadTurn(thread)?.status === "interrupted" && params.cwd) {
91
+ const resumedThread = await tryResumeThread(params.codex, params.threadId, params.cwd);
92
+ if (resumedThread) {
93
+ return { status: "found", thread: resumedThread };
94
+ }
95
+ }
96
+ return { status: "found", thread };
97
+ }
98
+ catch (error) {
99
+ const mapped = mapCodexReadFailure(error);
100
+ if (mapped.status === "missing" && params.cwd) {
101
+ const resumedThread = await tryResumeThread(params.codex, params.threadId, params.cwd);
102
+ if (resumedThread) {
103
+ return { status: "found", thread: resumedThread };
104
+ }
105
+ }
106
+ return mapped;
107
+ }
108
+ }
109
+ async function tryResumeThread(codex, threadId, cwd) {
110
+ try {
111
+ return await codex.resumeThread(threadId, cwd);
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ }
117
+ function latestThreadTurn(thread) {
118
+ return thread.turns.at(-1);
119
+ }
86
120
  function mapCodexReadFailure(error) {
87
121
  const message = error instanceof Error ? error.message : String(error);
88
122
  const normalized = message.trim().toLowerCase();
@@ -11,22 +11,23 @@ export class ServiceStageFinalizer {
11
11
  codex;
12
12
  linearProvider;
13
13
  enqueueIssue;
14
+ logger;
14
15
  feed;
15
16
  inputDispatcher;
16
17
  lifecyclePublisher;
17
18
  actionApplier;
18
19
  runAtomically;
19
- constructor(config, stores, codex, linearProvider, enqueueIssue, logger, feed, runAtomically = (fn) => fn()) {
20
+ constructor(config, stores, codex, linearProvider, enqueueIssue, logger = consoleLogger(), feed, runAtomically = (fn) => fn()) {
20
21
  this.config = config;
21
22
  this.stores = stores;
22
23
  this.codex = codex;
23
24
  this.linearProvider = linearProvider;
24
25
  this.enqueueIssue = enqueueIssue;
26
+ this.logger = logger;
25
27
  this.feed = feed;
26
28
  this.runAtomically = runAtomically;
27
- const lifecycleLogger = logger ?? consoleLogger();
28
- this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, lifecycleLogger);
29
- this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, lifecycleLogger, feed);
29
+ this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, this.logger);
30
+ this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, this.logger, feed);
30
31
  this.actionApplier = new ReconciliationActionApplier({
31
32
  enqueueIssue,
32
33
  deliverPendingObligations: (projectId, linearIssueId, threadId, turnId) => this.deliverPendingObligations(projectId, linearIssueId, threadId, turnId),
@@ -296,6 +297,9 @@ export class ServiceStageFinalizer {
296
297
  if (!snapshot) {
297
298
  return;
298
299
  }
300
+ if (await this.restartInterruptedRun(snapshot)) {
301
+ return;
302
+ }
299
303
  const decision = reconcileIssue(snapshot.input);
300
304
  if (decision.outcome === "hydrate_live_state") {
301
305
  throw new Error(`Startup reconciliation requires live state hydration for ${snapshot.runLease.projectId}:${snapshot.runLease.linearIssueId}: ${decision.reasons.join("; ")}`);
@@ -305,6 +309,63 @@ export class ServiceStageFinalizer {
305
309
  decision,
306
310
  });
307
311
  }
312
+ async restartInterruptedRun(snapshot) {
313
+ const liveCodex = snapshot.input.live?.codex;
314
+ const latestTurn = liveCodex?.status === "found" ? liveCodex.thread?.turns.at(-1) : undefined;
315
+ if (latestTurn?.status !== "interrupted") {
316
+ return false;
317
+ }
318
+ if (snapshot.runLease.turnId && latestTurn.id !== snapshot.runLease.turnId) {
319
+ return true;
320
+ }
321
+ if (!snapshot.runLease.threadId || !snapshot.workspaceOwnership?.worktreePath) {
322
+ return false;
323
+ }
324
+ const stageRun = this.findStageRunForIssue(snapshot.runLease.projectId, snapshot.runLease.linearIssueId, snapshot.runLease.threadId);
325
+ if (!stageRun) {
326
+ return false;
327
+ }
328
+ const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
329
+ const turn = await this.codex.startTurn({
330
+ threadId: snapshot.runLease.threadId,
331
+ cwd: snapshot.workspaceOwnership.worktreePath,
332
+ input: buildRestartRecoveryPrompt(stageRun.stage),
333
+ });
334
+ this.stores.workflowCoordinator.updateStageRunThread({
335
+ stageRunId: stageRun.id,
336
+ threadId: snapshot.runLease.threadId,
337
+ turnId: turn.turnId,
338
+ });
339
+ this.inputDispatcher.routePendingInputs(stageRun, snapshot.runLease.threadId, turn.turnId);
340
+ await this.inputDispatcher.flush({
341
+ id: stageRun.id,
342
+ projectId: stageRun.projectId,
343
+ linearIssueId: stageRun.linearIssueId,
344
+ threadId: snapshot.runLease.threadId,
345
+ turnId: turn.turnId,
346
+ }, {
347
+ logFailures: true,
348
+ failureMessage: "Failed to deliver queued Linear comment during interrupted-turn recovery",
349
+ ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
350
+ });
351
+ this.logger.info({
352
+ issueKey: issue?.issueKey,
353
+ stage: stageRun.stage,
354
+ threadId: snapshot.runLease.threadId,
355
+ turnId: turn.turnId,
356
+ }, "Restarted interrupted Codex stage run during startup reconciliation");
357
+ this.feed?.publish({
358
+ level: "info",
359
+ kind: "stage",
360
+ issueKey: issue?.issueKey,
361
+ projectId: stageRun.projectId,
362
+ stage: stageRun.stage,
363
+ status: "running",
364
+ summary: `Recovered ${stageRun.stage} workflow after restart`,
365
+ detail: `Turn ${turn.turnId} resumed on the existing thread.`,
366
+ });
367
+ return true;
368
+ }
308
369
  completeReconciledRun(projectId, linearIssueId, thread, params) {
309
370
  const stageRun = this.findStageRunForIssue(projectId, linearIssueId, params.threadId);
310
371
  const issue = this.stores.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
@@ -380,3 +441,12 @@ function consoleLogger() {
380
441
  level: "silent",
381
442
  };
382
443
  }
444
+ function buildRestartRecoveryPrompt(stage) {
445
+ return [
446
+ `PatchRelay restarted while the ${stage} workflow was mid-turn.`,
447
+ "Resume the existing work from the current worktree state on this same thread.",
448
+ "Inspect any uncommitted changes you already made before continuing.",
449
+ "Continue from the interrupted point instead of restarting the task from scratch.",
450
+ "When the work is actually complete, finish the normal workflow handoff for this stage.",
451
+ ].join("\n");
452
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {