patchrelay 0.7.9 → 0.7.10

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.9",
4
- "commit": "9f2abbe93f17",
5
- "builtAt": "2026-03-17T10:42:10.173Z"
3
+ "version": "0.7.10",
4
+ "commit": "0eab9a037342",
5
+ "builtAt": "2026-03-17T11:47:34.397Z"
6
6
  }
@@ -3,6 +3,7 @@ import { syncFailedStageToLinear } from "./stage-failure.js";
3
3
  import { buildFailedStageReport } from "./stage-reporting.js";
4
4
  import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
5
5
  import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
6
+ import { safeJsonParse } from "./utils.js";
6
7
  import { WorktreeManager } from "./worktree-manager.js";
7
8
  export class ServiceStageRunner {
8
9
  config;
@@ -86,11 +87,16 @@ export class ServiceStageRunner {
86
87
  });
87
88
  await this.lifecyclePublisher.markStageActive(project, claim.issue, claim.stageRun);
88
89
  threadLaunch = await this.launchStageThread(item.projectId, item.issueId, claim.stageRun.id, plan.worktreePath, issue.issueKey);
90
+ const pendingLaunchInput = this.collectPendingLaunchInput(item.projectId, item.issueId);
91
+ const initialTurnInput = pendingLaunchInput.combinedInput
92
+ ? [plan.prompt, "", pendingLaunchInput.combinedInput].join("\n")
93
+ : plan.prompt;
89
94
  turn = await this.codex.startTurn({
90
95
  threadId: threadLaunch.threadId,
91
96
  cwd: plan.worktreePath,
92
- input: plan.prompt,
97
+ input: initialTurnInput,
93
98
  });
99
+ this.completeDeliveredLaunchInput(pendingLaunchInput.obligationIds, claim.stageRun.id, threadLaunch.threadId, turn.turnId);
94
100
  }
95
101
  catch (error) {
96
102
  const err = error instanceof Error ? error : new Error(String(error));
@@ -122,19 +128,8 @@ export class ServiceStageRunner {
122
128
  turnId: turn.turnId,
123
129
  });
124
130
  this.inputDispatcher.routePendingInputs(claim.stageRun, threadLaunch.threadId, turn.turnId);
125
- await this.inputDispatcher.flush({
126
- id: claim.stageRun.id,
127
- projectId: claim.stageRun.projectId,
128
- linearIssueId: claim.stageRun.linearIssueId,
129
- threadId: threadLaunch.threadId,
130
- turnId: turn.turnId,
131
- }, {
132
- logFailures: true,
133
- failureMessage: "Failed to deliver queued Linear comment during stage startup",
134
- ...(claim.issue.issueKey ? { issueKey: claim.issue.issueKey } : {}),
135
- });
136
131
  const deliveredToSession = await this.lifecyclePublisher.publishStageStarted(claim.issue, claim.stageRun.stage);
137
- if (!deliveredToSession) {
132
+ if (!deliveredToSession && !claim.issue.activeAgentSessionId) {
138
133
  await this.lifecyclePublisher.refreshRunningStatusComment(item.projectId, item.issueId, claim.stageRun.id, issue.issueKey);
139
134
  }
140
135
  this.logger.info({
@@ -156,6 +151,39 @@ export class ServiceStageRunner {
156
151
  detail: `Turn ${turn.turnId} is running in ${plan.branchName}.`,
157
152
  });
158
153
  }
154
+ collectPendingLaunchInput(projectId, issueId) {
155
+ const obligationIds = [];
156
+ const bodies = [];
157
+ for (const obligation of this.stores.obligations.listPendingObligations({ kind: "deliver_turn_input" })) {
158
+ if (obligation.projectId !== projectId ||
159
+ obligation.linearIssueId !== issueId ||
160
+ !obligation.source.startsWith("linear-agent-launch:")) {
161
+ continue;
162
+ }
163
+ const payload = safeJsonParse(obligation.payloadJson);
164
+ const body = payload?.body?.trim();
165
+ if (!body) {
166
+ this.stores.obligations.markObligationStatus(obligation.id, "failed", "obligation payload had no deliverable body");
167
+ continue;
168
+ }
169
+ obligationIds.push(obligation.id);
170
+ bodies.push(body);
171
+ }
172
+ return {
173
+ ...(bodies.length > 0 ? { combinedInput: bodies.join("\n\n") } : {}),
174
+ obligationIds,
175
+ };
176
+ }
177
+ completeDeliveredLaunchInput(obligationIds, runLeaseId, threadId, turnId) {
178
+ for (const obligationId of obligationIds) {
179
+ this.stores.obligations.updateObligationRouting(obligationId, {
180
+ runLeaseId,
181
+ threadId,
182
+ turnId,
183
+ });
184
+ this.stores.obligations.markObligationStatus(obligationId, "completed");
185
+ }
186
+ }
159
187
  async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
160
188
  const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
161
189
  if (existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
@@ -45,7 +45,7 @@ export class ServiceWebhookProcessor {
45
45
  this.markEventReceiptProcessed(event.webhookId, "failed");
46
46
  throw new Error(`Stored webhook payload is invalid JSON: event ${webhookEventId}`);
47
47
  }
48
- const normalized = normalizeWebhook({
48
+ let normalized = normalizeWebhook({
49
49
  webhookId: event.webhookId,
50
50
  payload,
51
51
  });
@@ -69,12 +69,25 @@ export class ServiceWebhookProcessor {
69
69
  this.markEventReceiptProcessed(event.webhookId, "processed");
70
70
  return;
71
71
  }
72
- const project = resolveProject(this.config, normalized.issue);
72
+ let project = resolveProject(this.config, normalized.issue);
73
73
  if (!project) {
74
+ const routed = await this.tryHydrateProjectRoute(normalized);
75
+ if (routed) {
76
+ normalized = routed.normalized;
77
+ project = routed.project;
78
+ }
79
+ }
80
+ if (!project) {
81
+ const unresolvedIssue = normalized.issue;
82
+ if (!unresolvedIssue) {
83
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
84
+ this.markEventReceiptProcessed(event.webhookId, "failed");
85
+ throw new Error(`Normalized issue context disappeared before routing webhook ${event.webhookId}`);
86
+ }
74
87
  this.feed?.publish({
75
88
  level: "warn",
76
89
  kind: "webhook",
77
- issueKey: normalized.issue.identifier,
90
+ issueKey: unresolvedIssue.identifier,
78
91
  status: "ignored",
79
92
  summary: "Ignored webhook with no matching project route",
80
93
  detail: normalized.triggerEvent,
@@ -82,21 +95,27 @@ export class ServiceWebhookProcessor {
82
95
  this.logger.info({
83
96
  webhookEventId,
84
97
  webhookId: event.webhookId,
85
- issueKey: normalized.issue.identifier,
86
- issueId: normalized.issue.id,
87
- teamId: normalized.issue.teamId,
88
- teamKey: normalized.issue.teamKey,
98
+ issueKey: unresolvedIssue.identifier,
99
+ issueId: unresolvedIssue.id,
100
+ teamId: unresolvedIssue.teamId,
101
+ teamKey: unresolvedIssue.teamKey,
89
102
  triggerEvent: normalized.triggerEvent,
90
103
  }, "Ignoring webhook because no project route matched the Linear issue");
91
104
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
92
105
  this.markEventReceiptProcessed(event.webhookId, "processed");
93
106
  return;
94
107
  }
108
+ const routedIssue = normalized.issue;
109
+ if (!routedIssue) {
110
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
111
+ this.markEventReceiptProcessed(event.webhookId, "failed");
112
+ throw new Error(`Normalized issue context disappeared while routing webhook ${event.webhookId}`);
113
+ }
95
114
  if (!trustedActorAllowed(project, normalized.actor)) {
96
115
  this.feed?.publish({
97
116
  level: "warn",
98
117
  kind: "webhook",
99
- issueKey: normalized.issue.identifier,
118
+ issueKey: routedIssue.identifier,
100
119
  projectId: project.id,
101
120
  status: "ignored",
102
121
  summary: "Ignored webhook from an untrusted actor",
@@ -111,14 +130,14 @@ export class ServiceWebhookProcessor {
111
130
  actorEmail: normalized.actor?.email,
112
131
  }, "Ignoring webhook from untrusted Linear actor");
113
132
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
114
- this.assignEventReceiptContext(event.webhookId, project.id, normalized.issue.id);
133
+ this.assignEventReceiptContext(event.webhookId, project.id, routedIssue.id);
115
134
  this.markEventReceiptProcessed(event.webhookId, "processed");
116
135
  return;
117
136
  }
118
137
  this.stores.webhookEvents.assignWebhookProject(webhookEventId, project.id);
119
- const receipt = this.ensureEventReceipt(event, project.id, normalized.issue.id);
138
+ const receipt = this.ensureEventReceipt(event, project.id, routedIssue.id);
120
139
  const hydrated = await this.hydrateIssueContext(project.id, normalized);
121
- const hydratedIssue = hydrated.issue ?? normalized.issue;
140
+ const hydratedIssue = hydrated.issue ?? routedIssue;
122
141
  const issueState = this.desiredStageRecorder.record(project, hydrated, receipt ? { eventReceiptId: receipt.id } : undefined);
123
142
  const observation = describeWebhookObservation(hydrated, issueState.delegatedToPatchRelay);
124
143
  if (observation) {
@@ -229,6 +248,43 @@ export class ServiceWebhookProcessor {
229
248
  return normalized;
230
249
  }
231
250
  }
251
+ async tryHydrateProjectRoute(normalized) {
252
+ if (!normalized.issue) {
253
+ return undefined;
254
+ }
255
+ if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
256
+ return undefined;
257
+ }
258
+ for (const candidate of this.config.projects) {
259
+ const linear = await this.linearProvider.forProject(candidate.id);
260
+ if (!linear) {
261
+ continue;
262
+ }
263
+ try {
264
+ const liveIssue = await linear.getIssue(normalized.issue.id);
265
+ const hydrated = {
266
+ ...normalized,
267
+ issue: mergeIssueMetadata(normalized.issue, liveIssue),
268
+ };
269
+ const resolved = resolveProject(this.config, hydrated.issue);
270
+ if (resolved) {
271
+ return {
272
+ project: resolved,
273
+ normalized: hydrated,
274
+ };
275
+ }
276
+ }
277
+ catch (error) {
278
+ this.logger.debug({
279
+ candidateProjectId: candidate.id,
280
+ issueId: normalized.issue.id,
281
+ triggerEvent: normalized.triggerEvent,
282
+ error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
283
+ }, "Failed to hydrate Linear issue context while resolving project route");
284
+ }
285
+ }
286
+ return undefined;
287
+ }
232
288
  assignEventReceiptContext(webhookId, projectId, linearIssueId) {
233
289
  const receipt = this.lookupEventReceipt(webhookId);
234
290
  if (!receipt) {
@@ -71,7 +71,7 @@ export async function syncFailedStageToLinear(params) {
71
71
  .then(() => true)
72
72
  .catch(() => false)) || deliveredToSession;
73
73
  }
74
- if (!deliveredToSession) {
74
+ if (!deliveredToSession && !params.issue.activeAgentSessionId) {
75
75
  const result = await linear
76
76
  .upsertIssueComment({
77
77
  issueId: params.stageRun.linearIssueId,
@@ -84,7 +84,7 @@ export class StageLifecyclePublisher {
84
84
  await linear.createAgentActivity({
85
85
  agentSessionId: issue.activeAgentSessionId,
86
86
  content: {
87
- type: "thought",
87
+ type: "response",
88
88
  body: `PatchRelay started the ${stage} workflow and is working in the background.`,
89
89
  },
90
90
  });
@@ -155,7 +155,7 @@ export class StageLifecyclePublisher {
155
155
  type: "elicitation",
156
156
  body: `PatchRelay finished the ${stageRun.stage} workflow. Move the issue to its next workflow state or leave a follow-up prompt to continue.`,
157
157
  })) || deliveredToSession;
158
- if (!deliveredToSession) {
158
+ if (!deliveredToSession && !refreshedIssue.activeAgentSessionId) {
159
159
  const result = await linear.upsertIssueComment({
160
160
  issueId: stageRun.linearIssueId,
161
161
  ...(refreshedIssue.statusCommentId ? { commentId: refreshedIssue.statusCommentId } : {}),
@@ -66,7 +66,7 @@ export class AgentSessionWebhookHandler {
66
66
  if (desiredStage) {
67
67
  await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
68
68
  await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
69
- type: "thought",
69
+ type: "response",
70
70
  body: `PatchRelay started working on the ${desiredStage} workflow and is preparing the workspace.`,
71
71
  }, { ephemeral: false });
72
72
  return;
@@ -137,7 +137,7 @@ export class AgentSessionWebhookHandler {
137
137
  if (!activeRunLease && desiredStage) {
138
138
  await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
139
139
  await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
140
- type: "thought",
140
+ type: "response",
141
141
  body: `PatchRelay is preparing the ${desiredStage} workflow from your latest prompt.`,
142
142
  }, { ephemeral: false });
143
143
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {