patchrelay 0.8.1 → 0.8.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.8.1",
4
- "commit": "362440acad55",
5
- "builtAt": "2026-03-18T10:38:01.072Z"
3
+ "version": "0.8.3",
4
+ "commit": "ef05604cd432",
5
+ "builtAt": "2026-03-18T23:26:09.415Z"
6
6
  }
package/dist/config.js CHANGED
@@ -128,10 +128,28 @@ const configSchema = z.object({
128
128
  });
129
129
  function defaultTriggerEvents(actor) {
130
130
  if (actor === "app") {
131
- return ["agentSessionCreated", "agentPrompted"];
131
+ return ["delegateChanged", "statusChanged", "agentSessionCreated", "agentPrompted", "commentCreated", "commentUpdated"];
132
132
  }
133
133
  return ["statusChanged"];
134
134
  }
135
+ function normalizeTriggerEvents(actor, configured) {
136
+ if (actor !== "app") {
137
+ return configured ?? defaultTriggerEvents(actor);
138
+ }
139
+ const required = defaultTriggerEvents(actor);
140
+ if (!configured || configured.length === 0) {
141
+ return required;
142
+ }
143
+ const seen = new Set(required);
144
+ const extras = configured.filter((event) => {
145
+ if (seen.has(event)) {
146
+ return false;
147
+ }
148
+ seen.add(event);
149
+ return true;
150
+ });
151
+ return [...required, ...extras];
152
+ }
135
153
  const builtinWorkflows = [
136
154
  {
137
155
  id: "development",
@@ -489,9 +507,8 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
489
507
  issueKeyPrefixes: project.issue_key_prefixes,
490
508
  linearTeamIds: project.linear_team_ids,
491
509
  allowLabels: project.allow_labels,
492
- triggerEvents: repoSettings?.trigger_events ??
493
- project.trigger_events ??
494
- defaultTriggerEvents(parsed.linear.oauth.actor),
510
+ triggerEvents: normalizeTriggerEvents(parsed.linear.oauth.actor, repoSettings?.trigger_events ??
511
+ project.trigger_events),
495
512
  branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
496
513
  ...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
497
514
  };
@@ -200,7 +200,7 @@ export class IssueWorkflowCoordinator {
200
200
  });
201
201
  }
202
202
  const workspace = this.authoritativeLedger.getWorkspaceOwnership(stageRun.workspaceId);
203
- if (workspace) {
203
+ if (workspace && workspace.currentRunLeaseId === params.stageRunId) {
204
204
  this.authoritativeLedger.upsertWorkspaceOwnership({
205
205
  projectId: stageRun.projectId,
206
206
  linearIssueId: stageRun.linearIssueId,
package/dist/preflight.js CHANGED
@@ -40,8 +40,14 @@ export async function runPreflight(config) {
40
40
  checks.push(pass("linear_oauth", "Linear app actor includes assignable and mentionable scopes"));
41
41
  }
42
42
  for (const project of config.projects) {
43
+ if (!project.triggerEvents.includes("delegateChanged")) {
44
+ checks.push(warn(`project:${project.id}:triggers`, "Automatic pipeline pickup works best when trigger_events includes delegateChanged"));
45
+ }
46
+ if (!project.triggerEvents.includes("statusChanged")) {
47
+ checks.push(warn(`project:${project.id}:triggers`, "Automatic stage-to-stage continuation works best when trigger_events includes statusChanged"));
48
+ }
43
49
  if (!project.triggerEvents.includes("agentSessionCreated")) {
44
- checks.push(warn(`project:${project.id}:triggers`, "App-mode delegation works best when trigger_events includes agentSessionCreated"));
50
+ checks.push(warn(`project:${project.id}:triggers`, "Native Linear agent sessions work best when trigger_events includes agentSessionCreated"));
45
51
  }
46
52
  if (!project.triggerEvents.includes("agentPrompted")) {
47
53
  checks.push(warn(`project:${project.id}:triggers`, "Native follow-up agent prompts will not reach an active run unless trigger_events includes agentPrompted"));
@@ -3,14 +3,11 @@ import { reconcileIssue } from "./reconciliation-engine.js";
3
3
  import { buildReconciliationSnapshot } from "./reconciliation-snapshot-builder.js";
4
4
  import { syncFailedStageToLinear } from "./stage-failure.js";
5
5
  import { parseStageHandoff } from "./stage-handoff.js";
6
- import { resolveActiveLinearState, resolveFallbackLinearState } from "./linear-workflow.js";
6
+ import { resolveFallbackLinearState } from "./linear-workflow.js";
7
7
  import { resolveDefaultTransitionTarget, transitionTargetAllowed } from "./workflow-policy.js";
8
8
  import { buildFailedStageReport, buildPendingMaterializationThread, buildStageReport, countEventMethods, extractStageSummary, extractTurnId, resolveStageRunStatus, summarizeCurrentThread, } from "./stage-reporting.js";
9
9
  import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
10
10
  import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
11
- import { safeJsonParse } from "./utils.js";
12
- import { normalizeWebhook } from "./webhooks.js";
13
- const MAX_AUTOMATIC_TRANSITION_ATTEMPTS = 3;
14
11
  export class ServiceStageFinalizer {
15
12
  config;
16
13
  stores;
@@ -155,6 +152,7 @@ export class ServiceStageFinalizer {
155
152
  const report = buildStageReport(finalizedStageRun, issue, thread, countEventMethods(this.stores.stageEvents.listThreadEvents(stageRun.id)));
156
153
  this.runAtomically(() => {
157
154
  this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "completed", {
155
+ stageRunId: stageRun.id,
158
156
  threadId: params.threadId,
159
157
  ...(params.turnId ? { turnId: params.turnId } : {}),
160
158
  nextLifecycleStatus: params.nextLifecycleStatus ?? (issue.desiredStage ? "queued" : "completed"),
@@ -173,6 +171,7 @@ export class ServiceStageFinalizer {
173
171
  failStageRun(stageRun, threadId, message, options) {
174
172
  this.runAtomically(() => {
175
173
  this.finishLedgerRun(stageRun.projectId, stageRun.linearIssueId, "failed", {
174
+ stageRunId: stageRun.id,
176
175
  threadId,
177
176
  ...(options?.turnId ? { turnId: options.turnId } : {}),
178
177
  failureReason: message,
@@ -261,7 +260,7 @@ export class ServiceStageFinalizer {
261
260
  if (!linearIssue) {
262
261
  return;
263
262
  }
264
- const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(project, stageRun, refreshedIssue, linear, linearIssue);
263
+ const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(stageRun, refreshedIssue, linear, linearIssue);
265
264
  if (!continuationPrecondition.allowed) {
266
265
  this.feed?.publish({
267
266
  level: "info",
@@ -317,9 +316,19 @@ export class ServiceStageFinalizer {
317
316
  await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay received ${nextTarget} as the next stage again and needs a human to confirm the intended loop.`);
318
317
  return;
319
318
  }
320
- const priorAttempts = this.countPriorTransitionAttempts(stageRun.projectId, stageRun.linearIssueId, stageRun.stage, nextTarget);
321
- if (priorAttempts >= MAX_AUTOMATIC_TRANSITION_ATTEMPTS) {
322
- await this.routeStageToHumanNeeded(project, stageRun, linearIssue, `PatchRelay hit the automatic continuation limit for ${stageRun.stage} -> ${nextTarget}.`);
319
+ if (this.isTransitionAlreadyInFlight(stageRun, nextTarget)) {
320
+ this.feed?.publish({
321
+ level: "info",
322
+ kind: "workflow",
323
+ issueKey: refreshedIssue.issueKey,
324
+ projectId: stageRun.projectId,
325
+ stage: stageRun.stage,
326
+ ...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
327
+ nextStage: nextTarget,
328
+ status: "transition_in_progress",
329
+ summary: `${nextTarget} is already queued or running`,
330
+ detail: `PatchRelay kept ${stageRun.stage} completion from re-queueing ${nextTarget}.`,
331
+ });
323
332
  return;
324
333
  }
325
334
  this.feed?.publish({
@@ -339,14 +348,7 @@ export class ServiceStageFinalizer {
339
348
  lifecycleStatus: "queued",
340
349
  });
341
350
  }
342
- async checkAutomaticContinuationPreconditions(project, stageRun, issue, linear, linearIssue) {
343
- const activeState = resolveActiveLinearState(project, stageRun.stage, issue.selectedWorkflowId);
344
- if (activeState && normalizeLinearState(linearIssue.stateName) !== normalizeLinearState(activeState)) {
345
- return {
346
- allowed: false,
347
- reason: `Linear moved from ${activeState} to ${linearIssue.stateName ?? "an unknown state"} while the stage was running.`,
348
- };
349
- }
351
+ async checkAutomaticContinuationPreconditions(stageRun, issue, linear, linearIssue) {
350
352
  const actorProfile = await linear.getActorProfile().catch(() => undefined);
351
353
  if (actorProfile?.actorId && linearIssue.delegateId && linearIssue.delegateId !== actorProfile.actorId) {
352
354
  return {
@@ -354,48 +356,8 @@ export class ServiceStageFinalizer {
354
356
  reason: "The issue is no longer delegated to PatchRelay.",
355
357
  };
356
358
  }
357
- const stageSession = stageRun.threadId ? this.stores.issueSessions.getIssueSessionByThreadId(stageRun.threadId) : undefined;
358
- if (stageSession?.linkedAgentSessionId && issue.activeAgentSessionId !== stageSession.linkedAgentSessionId) {
359
- return {
360
- allowed: false,
361
- reason: "The active Linear agent session changed while the stage was running.",
362
- };
363
- }
364
- const recentInterruptions = this.listInterruptingWebhooksSince(stageRun, actorProfile?.actorId);
365
- if (recentInterruptions.length > 0) {
366
- return {
367
- allowed: false,
368
- reason: `A newer human webhook (${recentInterruptions[0]}) arrived while the stage was running.`,
369
- };
370
- }
371
359
  return { allowed: true };
372
360
  }
373
- listInterruptingWebhooksSince(stageRun, patchRelayActorId) {
374
- const events = this.stores.webhookEvents.listWebhookEventsForIssueSince(stageRun.linearIssueId, stageRun.startedAt);
375
- const interrupts = [];
376
- for (const event of events) {
377
- const payload = safeJsonParse(event.payloadJson);
378
- if (!payload) {
379
- continue;
380
- }
381
- const normalized = normalizeWebhook({
382
- webhookId: event.webhookId,
383
- payload: payload,
384
- });
385
- if (patchRelayActorId && normalized.actor?.id === patchRelayActorId) {
386
- continue;
387
- }
388
- if (normalized.triggerEvent === "commentCreated" ||
389
- normalized.triggerEvent === "commentUpdated" ||
390
- normalized.triggerEvent === "agentPrompted" ||
391
- normalized.triggerEvent === "agentSessionCreated" ||
392
- normalized.triggerEvent === "delegateChanged" ||
393
- normalized.triggerEvent === "statusChanged") {
394
- interrupts.push(normalized.triggerEvent);
395
- }
396
- }
397
- return interrupts;
398
- }
399
361
  resolveTransitionTarget(project, stageRun, workflowDefinitionId, handoff) {
400
362
  if (!handoff) {
401
363
  return "human_needed";
@@ -409,19 +371,16 @@ export class ServiceStageFinalizer {
409
371
  }
410
372
  return resolveDefaultTransitionTarget(project, stageRun.stage, workflowDefinitionId) ?? "human_needed";
411
373
  }
412
- countPriorTransitionAttempts(projectId, linearIssueId, currentStage, nextTarget) {
413
- const stageHistory = this.stores.issueWorkflows
414
- .listStageRunsForIssue(projectId, linearIssueId)
415
- .filter((stageRun) => stageRun.status === "completed");
416
- let count = 0;
417
- for (let index = 0; index < stageHistory.length - 1; index += 1) {
418
- const current = stageHistory[index];
419
- const next = stageHistory[index + 1];
420
- if (current?.stage === currentStage && next?.stage === nextTarget) {
421
- count += 1;
422
- }
374
+ isTransitionAlreadyInFlight(stageRun, nextTarget) {
375
+ const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
376
+ if (!refreshedIssue) {
377
+ return false;
378
+ }
379
+ if (refreshedIssue.desiredStage === nextTarget) {
380
+ return true;
423
381
  }
424
- return count;
382
+ const activeStageRun = this.resolveActiveStageRun(refreshedIssue);
383
+ return activeStageRun !== undefined && activeStageRun.id !== stageRun.id && activeStageRun.stage === nextTarget;
425
384
  }
426
385
  async routeStageToHumanNeeded(project, stageRun, linearIssue, reason) {
427
386
  const linear = await this.linearProvider.forProject(stageRun.projectId);
@@ -457,29 +416,41 @@ export class ServiceStageFinalizer {
457
416
  }
458
417
  finishLedgerRun(projectId, linearIssueId, status, params) {
459
418
  const issueControl = this.stores.issueControl.getIssueControl(projectId, linearIssueId);
460
- if (!issueControl?.activeRunLeaseId) {
419
+ const targetRunLeaseId = params.stageRunId ?? issueControl?.activeRunLeaseId;
420
+ if (!targetRunLeaseId) {
421
+ return;
422
+ }
423
+ const targetRunLease = this.stores.runLeases.getRunLease(targetRunLeaseId);
424
+ if (!targetRunLease) {
461
425
  return;
462
426
  }
463
427
  this.stores.runLeases.finishRunLease({
464
- runLeaseId: issueControl.activeRunLeaseId,
428
+ runLeaseId: targetRunLeaseId,
465
429
  status,
466
430
  ...(params.threadId ? { threadId: params.threadId } : {}),
467
431
  ...(params.turnId ? { turnId: params.turnId } : {}),
468
432
  ...(params.failureReason ? { failureReason: params.failureReason } : {}),
469
433
  });
470
- if (issueControl.activeWorkspaceOwnershipId !== undefined) {
471
- const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(issueControl.activeWorkspaceOwnershipId);
434
+ if (targetRunLease.workspaceOwnershipId !== undefined) {
435
+ const workspace = this.stores.workspaceOwnership.getWorkspaceOwnership(targetRunLease.workspaceOwnershipId);
472
436
  if (workspace) {
473
437
  this.stores.workspaceOwnership.upsertWorkspaceOwnership({
474
438
  projectId,
475
439
  linearIssueId,
476
440
  branchName: workspace.branchName,
477
441
  worktreePath: workspace.worktreePath,
478
- status: status === "completed" ? "active" : "paused",
479
- currentRunLeaseId: null,
442
+ status: workspace.currentRunLeaseId === targetRunLeaseId ? (status === "completed" ? "active" : "paused") : workspace.status,
443
+ ...(workspace.currentRunLeaseId === targetRunLeaseId
444
+ ? { currentRunLeaseId: null }
445
+ : workspace.currentRunLeaseId !== undefined
446
+ ? { currentRunLeaseId: workspace.currentRunLeaseId }
447
+ : {}),
480
448
  });
481
449
  }
482
450
  }
451
+ if (!issueControl?.activeRunLeaseId || issueControl.activeRunLeaseId !== targetRunLeaseId) {
452
+ return;
453
+ }
483
454
  this.stores.issueControl.upsertIssueControl({
484
455
  projectId,
485
456
  linearIssueId,
@@ -154,8 +154,7 @@ export class ServiceWebhookProcessor {
154
154
  });
155
155
  }
156
156
  if (issueState.issue?.selectedWorkflowId &&
157
- issueState.issue.selectedWorkflowId !== priorIssue?.selectedWorkflowId &&
158
- (hydrated.triggerEvent === "agentSessionCreated" || hydrated.triggerEvent === "agentPrompted")) {
157
+ issueState.issue.selectedWorkflowId !== priorIssue?.selectedWorkflowId) {
159
158
  this.feed?.publish({
160
159
  level: "info",
161
160
  kind: "workflow",
@@ -78,9 +78,6 @@ export class WebhookDesiredStageRecorder {
78
78
  if (!normalizedIssue) {
79
79
  return undefined;
80
80
  }
81
- if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
82
- return undefined;
83
- }
84
81
  if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent)) {
85
82
  return undefined;
86
83
  }
@@ -102,9 +99,6 @@ export class WebhookDesiredStageRecorder {
102
99
  if (activeStageRun) {
103
100
  return issue?.selectedWorkflowId;
104
101
  }
105
- if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
106
- return issue?.selectedWorkflowId;
107
- }
108
102
  if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent) || !normalized.issue) {
109
103
  return issue?.selectedWorkflowId;
110
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {