patchrelay 0.8.0 → 0.8.2

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.0",
4
- "commit": "c4fcada0fce7",
5
- "builtAt": "2026-03-18T10:29:36.175Z"
3
+ "version": "0.8.2",
4
+ "commit": "bae4a3fbeb97",
5
+ "builtAt": "2026-03-18T21:53:19.259Z"
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
  };
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"));
@@ -1,5 +1,6 @@
1
1
  import { SerialWorkQueue } from "./service-queue.js";
2
2
  const ISSUE_KEY_DELIMITER = "::";
3
+ const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
3
4
  function toReconciler(value) {
4
5
  if (typeof value === "function") {
5
6
  return {
@@ -40,13 +41,17 @@ function makeIssueQueueKey(item) {
40
41
  export class ServiceRuntime {
41
42
  codex;
42
43
  logger;
44
+ options;
43
45
  webhookQueue;
44
46
  issueQueue;
45
47
  ready = false;
46
48
  startupError;
47
- constructor(codex, logger, stageRunReconciler, readyIssueSource, webhookProcessor, issueProcessor) {
49
+ reconcileTimer;
50
+ reconcileInProgress = false;
51
+ constructor(codex, logger, stageRunReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
48
52
  this.codex = codex;
49
53
  this.logger = logger;
54
+ this.options = options;
50
55
  this.stageRunReconciler = toReconciler(stageRunReconciler);
51
56
  this.readyIssueSource = toReadyIssueSource(readyIssueSource);
52
57
  this.webhookProcessor = toWebhookProcessor(webhookProcessor);
@@ -69,6 +74,7 @@ export class ServiceRuntime {
69
74
  }
70
75
  this.ready = true;
71
76
  this.startupError = undefined;
77
+ this.scheduleBackgroundReconcile();
72
78
  }
73
79
  catch (error) {
74
80
  this.ready = false;
@@ -78,6 +84,7 @@ export class ServiceRuntime {
78
84
  }
79
85
  stop() {
80
86
  this.ready = false;
87
+ this.clearBackgroundReconcile();
81
88
  void this.codex.stop();
82
89
  }
83
90
  enqueueWebhookEvent(eventId, options) {
@@ -93,4 +100,42 @@ export class ServiceRuntime {
93
100
  ...(this.startupError ? { startupError: this.startupError } : {}),
94
101
  };
95
102
  }
103
+ scheduleBackgroundReconcile() {
104
+ this.clearBackgroundReconcile();
105
+ const timer = setTimeout(() => {
106
+ void this.runBackgroundReconcile();
107
+ }, this.options.reconcileIntervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS);
108
+ timer.unref?.();
109
+ this.reconcileTimer = timer;
110
+ }
111
+ clearBackgroundReconcile() {
112
+ if (this.reconcileTimer !== undefined) {
113
+ clearTimeout(this.reconcileTimer);
114
+ this.reconcileTimer = undefined;
115
+ }
116
+ }
117
+ async runBackgroundReconcile() {
118
+ if (!this.ready || !this.codex.isStarted()) {
119
+ return;
120
+ }
121
+ if (this.reconcileInProgress) {
122
+ this.scheduleBackgroundReconcile();
123
+ return;
124
+ }
125
+ this.reconcileInProgress = true;
126
+ try {
127
+ await this.stageRunReconciler.reconcileActiveStageRuns();
128
+ }
129
+ catch (error) {
130
+ this.logger.warn({
131
+ error: error instanceof Error ? error.message : String(error),
132
+ }, "Background active-stage reconciliation failed");
133
+ }
134
+ finally {
135
+ this.reconcileInProgress = false;
136
+ if (this.ready) {
137
+ this.scheduleBackgroundReconcile();
138
+ }
139
+ }
140
+ }
96
141
  }
@@ -3,13 +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
11
  const MAX_AUTOMATIC_TRANSITION_ATTEMPTS = 3;
14
12
  export class ServiceStageFinalizer {
15
13
  config;
@@ -261,7 +259,7 @@ export class ServiceStageFinalizer {
261
259
  if (!linearIssue) {
262
260
  return;
263
261
  }
264
- const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(project, stageRun, refreshedIssue, linear, linearIssue);
262
+ const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(stageRun, refreshedIssue, linear, linearIssue);
265
263
  if (!continuationPrecondition.allowed) {
266
264
  this.feed?.publish({
267
265
  level: "info",
@@ -339,14 +337,7 @@ export class ServiceStageFinalizer {
339
337
  lifecycleStatus: "queued",
340
338
  });
341
339
  }
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
- }
340
+ async checkAutomaticContinuationPreconditions(stageRun, issue, linear, linearIssue) {
350
341
  const actorProfile = await linear.getActorProfile().catch(() => undefined);
351
342
  if (actorProfile?.actorId && linearIssue.delegateId && linearIssue.delegateId !== actorProfile.actorId) {
352
343
  return {
@@ -354,48 +345,8 @@ export class ServiceStageFinalizer {
354
345
  reason: "The issue is no longer delegated to PatchRelay.",
355
346
  };
356
347
  }
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
348
  return { allowed: true };
372
349
  }
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
350
  resolveTransitionTarget(project, stageRun, workflowDefinitionId, handoff) {
400
351
  if (!handoff) {
401
352
  return "human_needed";
@@ -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.0",
3
+ "version": "0.8.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {