patchrelay 0.7.8 → 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.8",
4
- "commit": "ef8346869c35",
5
- "builtAt": "2026-03-17T10:30:51.043Z"
3
+ "version": "0.7.10",
4
+ "commit": "0eab9a037342",
5
+ "builtAt": "2026-03-17T11:47:34.397Z"
6
6
  }
@@ -15,6 +15,10 @@ export class LinearGraphqlClient {
15
15
  identifier
16
16
  title
17
17
  url
18
+ delegate {
19
+ id
20
+ name
21
+ }
18
22
  state {
19
23
  id
20
24
  name
@@ -65,6 +69,10 @@ export class LinearGraphqlClient {
65
69
  identifier
66
70
  title
67
71
  url
72
+ delegate {
73
+ id
74
+ name
75
+ }
68
76
  state {
69
77
  id
70
78
  name
@@ -290,6 +298,8 @@ export class LinearGraphqlClient {
290
298
  ...(issue.state?.name ? { stateName: issue.state.name } : {}),
291
299
  ...(issue.team?.id ? { teamId: issue.team.id } : {}),
292
300
  ...(issue.team?.key ? { teamKey: issue.team.key } : {}),
301
+ ...(issue.delegate?.id ? { delegateId: issue.delegate.id } : {}),
302
+ ...(issue.delegate?.name ? { delegateName: issue.delegate.name } : {}),
293
303
  workflowStates: (issue.team?.states?.nodes ?? []).map((state) => ({
294
304
  id: state.id,
295
305
  name: state.name,
@@ -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) {
@@ -10,6 +10,7 @@ import { normalizeWebhook } from "./webhooks.js";
10
10
  export class ServiceWebhookProcessor {
11
11
  config;
12
12
  stores;
13
+ linearProvider;
13
14
  enqueueIssue;
14
15
  logger;
15
16
  feed;
@@ -20,6 +21,7 @@ export class ServiceWebhookProcessor {
20
21
  constructor(config, stores, linearProvider, codex, enqueueIssue, logger, feed) {
21
22
  this.config = config;
22
23
  this.stores = stores;
24
+ this.linearProvider = linearProvider;
23
25
  this.enqueueIssue = enqueueIssue;
24
26
  this.logger = logger;
25
27
  this.feed = feed;
@@ -43,7 +45,7 @@ export class ServiceWebhookProcessor {
43
45
  this.markEventReceiptProcessed(event.webhookId, "failed");
44
46
  throw new Error(`Stored webhook payload is invalid JSON: event ${webhookEventId}`);
45
47
  }
46
- const normalized = normalizeWebhook({
48
+ let normalized = normalizeWebhook({
47
49
  webhookId: event.webhookId,
48
50
  payload,
49
51
  });
@@ -67,12 +69,25 @@ export class ServiceWebhookProcessor {
67
69
  this.markEventReceiptProcessed(event.webhookId, "processed");
68
70
  return;
69
71
  }
70
- const project = resolveProject(this.config, normalized.issue);
72
+ let project = resolveProject(this.config, normalized.issue);
71
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
+ }
72
87
  this.feed?.publish({
73
88
  level: "warn",
74
89
  kind: "webhook",
75
- issueKey: normalized.issue.identifier,
90
+ issueKey: unresolvedIssue.identifier,
76
91
  status: "ignored",
77
92
  summary: "Ignored webhook with no matching project route",
78
93
  detail: normalized.triggerEvent,
@@ -80,21 +95,27 @@ export class ServiceWebhookProcessor {
80
95
  this.logger.info({
81
96
  webhookEventId,
82
97
  webhookId: event.webhookId,
83
- issueKey: normalized.issue.identifier,
84
- issueId: normalized.issue.id,
85
- teamId: normalized.issue.teamId,
86
- teamKey: normalized.issue.teamKey,
98
+ issueKey: unresolvedIssue.identifier,
99
+ issueId: unresolvedIssue.id,
100
+ teamId: unresolvedIssue.teamId,
101
+ teamKey: unresolvedIssue.teamKey,
87
102
  triggerEvent: normalized.triggerEvent,
88
103
  }, "Ignoring webhook because no project route matched the Linear issue");
89
104
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
90
105
  this.markEventReceiptProcessed(event.webhookId, "processed");
91
106
  return;
92
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
+ }
93
114
  if (!trustedActorAllowed(project, normalized.actor)) {
94
115
  this.feed?.publish({
95
116
  level: "warn",
96
117
  kind: "webhook",
97
- issueKey: normalized.issue.identifier,
118
+ issueKey: routedIssue.identifier,
98
119
  projectId: project.id,
99
120
  status: "ignored",
100
121
  summary: "Ignored webhook from an untrusted actor",
@@ -109,19 +130,21 @@ export class ServiceWebhookProcessor {
109
130
  actorEmail: normalized.actor?.email,
110
131
  }, "Ignoring webhook from untrusted Linear actor");
111
132
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
112
- this.assignEventReceiptContext(event.webhookId, project.id, normalized.issue.id);
133
+ this.assignEventReceiptContext(event.webhookId, project.id, routedIssue.id);
113
134
  this.markEventReceiptProcessed(event.webhookId, "processed");
114
135
  return;
115
136
  }
116
137
  this.stores.webhookEvents.assignWebhookProject(webhookEventId, project.id);
117
- const receipt = this.ensureEventReceipt(event, project.id, normalized.issue.id);
118
- const issueState = this.desiredStageRecorder.record(project, normalized, receipt ? { eventReceiptId: receipt.id } : undefined);
119
- const observation = describeWebhookObservation(normalized, issueState.delegatedToPatchRelay);
138
+ const receipt = this.ensureEventReceipt(event, project.id, routedIssue.id);
139
+ const hydrated = await this.hydrateIssueContext(project.id, normalized);
140
+ const hydratedIssue = hydrated.issue ?? routedIssue;
141
+ const issueState = this.desiredStageRecorder.record(project, hydrated, receipt ? { eventReceiptId: receipt.id } : undefined);
142
+ const observation = describeWebhookObservation(hydrated, issueState.delegatedToPatchRelay);
120
143
  if (observation) {
121
144
  this.feed?.publish({
122
145
  level: "info",
123
146
  kind: observation.kind,
124
- issueKey: normalized.issue.identifier,
147
+ issueKey: hydratedIssue.identifier,
125
148
  projectId: project.id,
126
149
  ...(observation.status ? { status: observation.status } : {}),
127
150
  summary: observation.summary,
@@ -129,45 +152,45 @@ export class ServiceWebhookProcessor {
129
152
  });
130
153
  }
131
154
  await this.agentSessionHandler.handle({
132
- normalized,
155
+ normalized: hydrated,
133
156
  project,
134
157
  issue: issueState.issue,
135
158
  desiredStage: issueState.desiredStage,
136
159
  delegatedToPatchRelay: issueState.delegatedToPatchRelay,
137
160
  });
138
- await this.commentHandler.handle(normalized, project);
161
+ await this.commentHandler.handle(hydrated, project);
139
162
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
140
163
  this.markEventReceiptProcessed(event.webhookId, "processed");
141
164
  if (issueState.desiredStage) {
142
165
  this.feed?.publish({
143
166
  level: "info",
144
167
  kind: "stage",
145
- issueKey: normalized.issue.identifier,
168
+ issueKey: hydratedIssue.identifier,
146
169
  projectId: project.id,
147
170
  stage: issueState.desiredStage,
148
171
  status: "queued",
149
172
  summary: `Queued ${issueState.desiredStage} workflow`,
150
- detail: `Triggered by ${normalized.triggerEvent}${normalized.issue.stateName ? ` from ${normalized.issue.stateName}` : ""}.`,
173
+ detail: `Triggered by ${hydrated.triggerEvent}${hydratedIssue.stateName ? ` from ${hydratedIssue.stateName}` : ""}.`,
151
174
  });
152
175
  this.logger.info({
153
176
  webhookEventId,
154
177
  webhookId: event.webhookId,
155
178
  projectId: project.id,
156
- issueKey: normalized.issue.identifier,
157
- issueId: normalized.issue.id,
179
+ issueKey: hydratedIssue.identifier,
180
+ issueId: hydratedIssue.id,
158
181
  desiredStage: issueState.desiredStage,
159
182
  delegatedToPatchRelay: issueState.delegatedToPatchRelay,
160
183
  }, "Recorded desired stage from webhook and enqueued issue execution");
161
- this.enqueueIssue(project.id, normalized.issue.id);
184
+ this.enqueueIssue(project.id, hydratedIssue.id);
162
185
  return;
163
186
  }
164
187
  this.logger.info({
165
188
  webhookEventId,
166
189
  webhookId: event.webhookId,
167
190
  projectId: project.id,
168
- issueKey: normalized.issue.identifier,
169
- issueId: normalized.issue.id,
170
- triggerEvent: normalized.triggerEvent,
191
+ issueKey: hydratedIssue.identifier,
192
+ issueId: hydratedIssue.id,
193
+ triggerEvent: hydrated.triggerEvent,
171
194
  delegatedToPatchRelay: issueState.delegatedToPatchRelay,
172
195
  }, "Processed webhook without enqueuing a new stage run");
173
196
  }
@@ -194,6 +217,74 @@ export class ServiceWebhookProcessor {
194
217
  throw err;
195
218
  }
196
219
  }
220
+ async hydrateIssueContext(projectId, normalized) {
221
+ if (!normalized.issue) {
222
+ return normalized;
223
+ }
224
+ if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
225
+ return normalized;
226
+ }
227
+ if (hasCompleteIssueContext(normalized.issue)) {
228
+ return normalized;
229
+ }
230
+ const linear = await this.linearProvider.forProject(projectId);
231
+ if (!linear) {
232
+ return normalized;
233
+ }
234
+ try {
235
+ const liveIssue = await linear.getIssue(normalized.issue.id);
236
+ return {
237
+ ...normalized,
238
+ issue: mergeIssueMetadata(normalized.issue, liveIssue),
239
+ };
240
+ }
241
+ catch (error) {
242
+ this.logger.warn({
243
+ projectId,
244
+ issueId: normalized.issue.id,
245
+ triggerEvent: normalized.triggerEvent,
246
+ error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
247
+ }, "Failed to hydrate sparse Linear issue context for agent session webhook");
248
+ return normalized;
249
+ }
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
+ }
197
288
  assignEventReceiptContext(webhookId, projectId, linearIssueId) {
198
289
  const receipt = this.lookupEventReceipt(webhookId);
199
290
  if (!receipt) {
@@ -234,6 +325,24 @@ export class ServiceWebhookProcessor {
234
325
  return this.stores.eventReceipts.getEventReceipt(inserted.id);
235
326
  }
236
327
  }
328
+ function hasCompleteIssueContext(issue) {
329
+ return Boolean(issue.stateName && issue.delegateId && issue.teamId && issue.teamKey);
330
+ }
331
+ function mergeIssueMetadata(issue, liveIssue) {
332
+ return {
333
+ ...issue,
334
+ ...(issue.identifier ? {} : liveIssue.identifier ? { identifier: liveIssue.identifier } : {}),
335
+ ...(issue.title ? {} : liveIssue.title ? { title: liveIssue.title } : {}),
336
+ ...(issue.url ? {} : liveIssue.url ? { url: liveIssue.url } : {}),
337
+ ...(issue.teamId ? {} : liveIssue.teamId ? { teamId: liveIssue.teamId } : {}),
338
+ ...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
339
+ ...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
340
+ ...(issue.stateName ? {} : liveIssue.stateName ? { stateName: liveIssue.stateName } : {}),
341
+ ...(issue.delegateId ? {} : liveIssue.delegateId ? { delegateId: liveIssue.delegateId } : {}),
342
+ ...(issue.delegateName ? {} : liveIssue.delegateName ? { delegateName: liveIssue.delegateName } : {}),
343
+ labelNames: issue.labelNames.length > 0 ? issue.labelNames : (liveIssue.labels ?? []).map((label) => label.name),
344
+ };
345
+ }
237
346
  function describeWebhookObservation(normalized, delegatedToPatchRelay) {
238
347
  switch (normalized.triggerEvent) {
239
348
  case "delegateChanged":
@@ -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.8",
3
+ "version": "0.7.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {