patchrelay 0.83.0 → 0.83.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.83.0",
4
- "commit": "5e17a07e67bc",
5
- "builtAt": "2026-06-14T17:38:15.238Z"
3
+ "version": "0.83.2",
4
+ "commit": "282282c35f6f",
5
+ "builtAt": "2026-06-14T18:34:33.650Z"
6
6
  }
package/dist/db.js CHANGED
@@ -87,6 +87,21 @@ export class PatchRelayDatabase {
87
87
  assertSchemaReady() {
88
88
  assertPatchRelaySchemaReady(this.connection, this.databasePath);
89
89
  }
90
+ describeSchema() {
91
+ const tableRows = this.connection.prepare(`
92
+ SELECT name FROM sqlite_master
93
+ WHERE type = 'table' AND name IN ('issues', 'issue_sessions', 'runs')
94
+ ORDER BY name
95
+ `).all();
96
+ const issueColumns = tableRows.some((row) => row.name === "issues")
97
+ ? this.connection.prepare("PRAGMA table_info(issues)").all().map((row) => row.name)
98
+ : [];
99
+ return {
100
+ databasePath: this.databasePath,
101
+ tables: tableRows.map((row) => row.name),
102
+ issuesVersionColumnPresent: issueColumns.includes("version"),
103
+ };
104
+ }
90
105
  transaction(fn) {
91
106
  return this.connection.transaction(fn)();
92
107
  }
@@ -72,8 +72,10 @@ export function deriveLinkedPrAdoptionOutcome(project, prNumber, remote) {
72
72
  delegatedToPatchRelay: true,
73
73
  prNumber,
74
74
  prState,
75
+ prHeadSha: remote.headRefOid,
75
76
  prReviewState: reviewState,
76
77
  prCheckStatus: gateCheckStatus,
78
+ lastBlockingReviewHeadSha: reviewState === "changes_requested" ? remote.headRefOid : undefined,
77
79
  mergeConflictDetected,
78
80
  downstreamOwned,
79
81
  });
@@ -373,8 +373,10 @@ export class IdleIssueReconciler {
373
373
  const reactiveIntent = deriveIssueSessionReactiveIntent({
374
374
  prNumber: issue.prNumber,
375
375
  prState: issue.prState,
376
+ prHeadSha: issue.prHeadSha,
376
377
  prReviewState: issue.prReviewState,
377
378
  prCheckStatus: issue.prCheckStatus,
379
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
378
380
  latestFailureSource: issue.lastGitHubFailureSource,
379
381
  });
380
382
  if (!reactiveIntent && issue.factoryState === "awaiting_queue") {
@@ -705,8 +707,10 @@ export class IdleIssueReconciler {
705
707
  const reactiveIntent = deriveIssueSessionReactiveIntent({
706
708
  prNumber: refreshedIssue.prNumber,
707
709
  prState: refreshedIssue.prState,
710
+ prHeadSha: refreshedIssue.prHeadSha,
708
711
  prReviewState: refreshedIssue.prReviewState,
709
712
  prCheckStatus: refreshedIssue.prCheckStatus,
713
+ lastBlockingReviewHeadSha: refreshedIssue.lastBlockingReviewHeadSha,
710
714
  latestFailureSource: refreshedIssue.lastGitHubFailureSource,
711
715
  mergeConflictDetected,
712
716
  downstreamOwned,
@@ -133,8 +133,10 @@ export class IssueOverviewQuery {
133
133
  orchestrationSettleUntil: issueRecord?.orchestrationSettleUntil,
134
134
  ...(session.prNumber !== undefined ? { prNumber: session.prNumber } : {}),
135
135
  ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
136
+ ...(issueRecord?.prHeadSha ? { prHeadSha: issueRecord.prHeadSha } : {}),
136
137
  ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
137
138
  ...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
139
+ ...(issueRecord?.lastBlockingReviewHeadSha ? { lastBlockingReviewHeadSha: issueRecord.lastBlockingReviewHeadSha } : {}),
138
140
  ...(issueRecord?.lastGitHubFailureSource ? { latestFailureSource: issueRecord.lastGitHubFailureSource } : {}),
139
141
  }),
140
142
  ...(issueRecord?.lastGitHubFailureSource ? { latestFailureSource: issueRecord.lastGitHubFailureSource } : {}),
@@ -223,6 +223,9 @@ export function deriveSessionWakePlan(issue, events, onPayloadError) {
223
223
  }
224
224
  break;
225
225
  case "review_changes_requested":
226
+ if (isStaleRequestedChangesEvent(issue, typed.payload)) {
227
+ break;
228
+ }
226
229
  if (runType !== "queue_repair" && runType !== "ci_repair") {
227
230
  runType = typed.payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_fix";
228
231
  wakeReason = typed.payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_changes_requested";
@@ -356,6 +359,14 @@ export function deriveSessionWakePlan(issue, events, onPayloadError) {
356
359
  }
357
360
  return { eventIds, runType, wakeReason, resumeThread, context };
358
361
  }
362
+ function isStaleRequestedChangesEvent(issue, payload) {
363
+ if (payload?.branchUpkeepRequired === true)
364
+ return false;
365
+ const requestedChangesHeadSha = payload?.requestedChangesHeadSha;
366
+ if (!requestedChangesHeadSha || !issue.prHeadSha)
367
+ return false;
368
+ return requestedChangesHeadSha !== issue.prHeadSha;
369
+ }
359
370
  export function isActionableIssueSessionEventType(eventType) {
360
371
  return !NON_ACTIONABLE_SESSION_EVENTS.has(eventType);
361
372
  }
@@ -28,8 +28,10 @@ export function deriveIssueSessionWakeReason(params) {
28
28
  delegatedToPatchRelay: params.delegatedToPatchRelay,
29
29
  prNumber: params.prNumber,
30
30
  prState: params.prState,
31
+ prHeadSha: params.prHeadSha,
31
32
  prReviewState: params.prReviewState,
32
33
  prCheckStatus: params.prCheckStatus,
34
+ lastBlockingReviewHeadSha: params.lastBlockingReviewHeadSha,
33
35
  latestFailureSource: params.latestFailureSource,
34
36
  });
35
37
  if (reactiveIntent)
@@ -61,7 +63,11 @@ export function deriveIssueSessionReactiveIntent(params) {
61
63
  compatibilityFactoryState: "repairing_ci",
62
64
  };
63
65
  }
64
- if (params.prReviewState === "changes_requested") {
66
+ if (isCurrentHeadRequestedChanges({
67
+ prReviewState: params.prReviewState,
68
+ prHeadSha: params.prHeadSha,
69
+ lastBlockingReviewHeadSha: params.lastBlockingReviewHeadSha,
70
+ })) {
65
71
  if (params.mergeConflictDetected) {
66
72
  return {
67
73
  runType: "branch_upkeep",
@@ -106,8 +112,10 @@ export function isIssueSessionReadyForExecution(params) {
106
112
  delegatedToPatchRelay: params.delegatedToPatchRelay,
107
113
  prNumber: params.prNumber,
108
114
  prState: params.prState,
115
+ prHeadSha: params.prHeadSha,
109
116
  prReviewState: params.prReviewState,
110
117
  prCheckStatus: params.prCheckStatus,
118
+ lastBlockingReviewHeadSha: params.lastBlockingReviewHeadSha,
111
119
  latestFailureSource: params.latestFailureSource,
112
120
  }) === undefined) {
113
121
  return false;
@@ -121,3 +129,10 @@ export function isIssueSessionReadyForExecution(params) {
121
129
  }
122
130
  return true;
123
131
  }
132
+ export function isCurrentHeadRequestedChanges(params) {
133
+ if (params.prReviewState !== "changes_requested")
134
+ return false;
135
+ if (!params.lastBlockingReviewHeadSha || !params.prHeadSha)
136
+ return true;
137
+ return params.lastBlockingReviewHeadSha === params.prHeadSha;
138
+ }
@@ -1,4 +1,5 @@
1
1
  import { deriveLinearProgressFact } from "./linear-progress-facts.js";
2
+ import { isSqliteSchemaReadError } from "./sqlite-errors.js";
2
3
  export class LinearProgressReporter {
3
4
  db;
4
5
  emitActivity;
@@ -11,7 +12,7 @@ export class LinearProgressReporter {
11
12
  this.options = options;
12
13
  }
13
14
  maybeEmitProgress(notification, run) {
14
- const issue = this.db.getIssue(run.projectId, run.linearIssueId);
15
+ const issue = this.getIssueWithSchemaRetry(run);
15
16
  if (!issue) {
16
17
  return;
17
18
  }
@@ -73,7 +74,7 @@ export class LinearProgressReporter {
73
74
  if (previous?.lastHeartbeatAtMs !== undefined && now - previous.lastHeartbeatAtMs < intervalMs) {
74
75
  return;
75
76
  }
76
- const issue = this.db.getIssue(run.projectId, run.linearIssueId);
77
+ const issue = this.getIssueWithSchemaRetry(run);
77
78
  if (!issue) {
78
79
  return;
79
80
  }
@@ -108,6 +109,18 @@ export class LinearProgressReporter {
108
109
  now() {
109
110
  return this.options.now?.() ?? Date.now();
110
111
  }
112
+ getIssueWithSchemaRetry(run) {
113
+ try {
114
+ return this.db.getIssue(run.projectId, run.linearIssueId);
115
+ }
116
+ catch (error) {
117
+ if (!isSqliteSchemaReadError(error)) {
118
+ throw error;
119
+ }
120
+ this.db.assertSchemaReady();
121
+ return this.db.getIssue(run.projectId, run.linearIssueId);
122
+ }
123
+ }
111
124
  clearFailedPublication(runId, channel, meaningKey, publishedAtMs) {
112
125
  const current = this.publicationsByRun.get(runId);
113
126
  if (!current) {
@@ -208,8 +208,10 @@ function resolveOpenWorkflowState(issue) {
208
208
  delegatedToPatchRelay: issue.delegatedToPatchRelay,
209
209
  prNumber: issue.prNumber,
210
210
  prState: issue.prState,
211
+ prHeadSha: issue.prHeadSha,
211
212
  prReviewState: issue.prReviewState,
212
213
  prCheckStatus: issue.prCheckStatus,
214
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
213
215
  latestFailureSource: issue.lastGitHubFailureSource,
214
216
  });
215
217
  if (reactiveIntent) {
@@ -1,4 +1,5 @@
1
1
  import { classifyIssue } from "./issue-class.js";
2
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
2
3
  const WRITER = "orchestration-parent-wake";
3
4
  export const ORCHESTRATION_SETTLE_WINDOW_MS = 10_000;
4
5
  export function computeOrchestrationSettleUntil(now = Date.now()) {
@@ -23,6 +24,12 @@ function resolveParentIssueIds(db, child) {
23
24
  }
24
25
  return unique(parentIds);
25
26
  }
27
+ function parentHasRunnableWorkflowTask(db, parent) {
28
+ const reconciliation = reconcileWorkflowTasksForIssue(db, parent);
29
+ return reconciliation.result.open.some((task) => (task.taskType === "run"
30
+ && task.runType !== undefined
31
+ && task.gateAction === "start"));
32
+ }
26
33
  export function startOrchestrationSettleWindow(db, issue, now = Date.now()) {
27
34
  const settleUntil = computeOrchestrationSettleUntil(now);
28
35
  db.issueSessions.commitIssueState({
@@ -70,6 +77,10 @@ export function wakeOrchestrationParentsForChildEvent(params) {
70
77
  parentIds.push(parent.linearIssueId);
71
78
  continue;
72
79
  }
80
+ if (!parentHasRunnableWorkflowTask(params.db, parent)) {
81
+ parentIds.push(parent.linearIssueId);
82
+ continue;
83
+ }
73
84
  params.wakeDispatcher.recordEventAndDispatch(parent.projectId, parent.linearIssueId, {
74
85
  eventType: params.eventType,
75
86
  eventJson: JSON.stringify({
@@ -35,8 +35,10 @@ export function resolvePostRunFactoryState(issue, _run, options) {
35
35
  const reactiveIntent = deriveIssueSessionReactiveIntent({
36
36
  prNumber: issue.prNumber,
37
37
  prState: issue.prState,
38
+ prHeadSha: issue.prHeadSha,
38
39
  prReviewState: issue.prReviewState,
39
40
  prCheckStatus: issue.prCheckStatus,
41
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
40
42
  latestFailureSource: issue.lastGitHubFailureSource,
41
43
  });
42
44
  if (reactiveIntent)
@@ -234,7 +234,14 @@ export class RunNotificationHandler {
234
234
  this.linearSync.maybeEmitProgress(notification, run);
235
235
  }
236
236
  catch (error) {
237
- this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear progress reporting failed");
237
+ this.logger.warn({
238
+ runId: run.id,
239
+ projectId: run.projectId,
240
+ issueId: run.linearIssueId,
241
+ method: notification.method,
242
+ error: formatError(error),
243
+ storage: this.safeStorageDiagnostics(),
244
+ }, "Linear progress reporting failed");
238
245
  }
239
246
  }
240
247
  syncCodexPlan(notification, run) {
@@ -257,6 +264,14 @@ export class RunNotificationHandler {
257
264
  this.logger.warn({ runId: run.id, issueKey: issue.issueKey, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear plan sync failed");
258
265
  }
259
266
  }
267
+ safeStorageDiagnostics() {
268
+ try {
269
+ return this.db.describeSchema();
270
+ }
271
+ catch {
272
+ return undefined;
273
+ }
274
+ }
260
275
  }
261
276
  function formatError(error) {
262
277
  return error instanceof Error ? error.message : String(error);
@@ -2,6 +2,7 @@ import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./
2
2
  import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
3
3
  import { parseRunContextOrWarn, serializeRunContext, tryParseRunContextValue } from "./run-context.js";
4
4
  import { assertNever } from "./utils.js";
5
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
5
6
  const WRITER = "run-wake-planner";
6
7
  function parseObjectJson(raw) {
7
8
  if (!raw)
@@ -24,11 +25,22 @@ export class RunWakePlanner {
24
25
  this.logger = logger;
25
26
  }
26
27
  resolveRunWake(issue) {
27
- if (this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId) > 0) {
28
+ const freshIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
29
+ if (this.db.issues.countUnresolvedBlockers(freshIssue.projectId, freshIssue.linearIssueId) > 0) {
28
30
  return undefined;
29
31
  }
30
- const sessionWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
32
+ const existingWorkflowTaskWake = this.resolveWorkflowTaskWake(freshIssue);
33
+ if (existingWorkflowTaskWake)
34
+ return existingWorkflowTaskWake;
35
+ this.reconcileWorkflowTasks(freshIssue);
36
+ const workflowTaskWake = this.resolveWorkflowTaskWake(freshIssue);
37
+ if (workflowTaskWake)
38
+ return workflowTaskWake;
39
+ const sessionWake = this.db.issueSessions.peekIssueSessionWake(freshIssue.projectId, freshIssue.linearIssueId);
31
40
  if (sessionWake) {
41
+ if (this.workflowTasksSuppressSessionWake(freshIssue, sessionWake.wakeReason)) {
42
+ return undefined;
43
+ }
32
44
  return {
33
45
  runType: sessionWake.runType,
34
46
  context: sessionWake.context,
@@ -37,10 +49,10 @@ export class RunWakePlanner {
37
49
  eventIds: sessionWake.eventIds,
38
50
  };
39
51
  }
40
- const workflowTaskWake = this.resolveWorkflowTaskWake(issue);
41
- if (workflowTaskWake)
42
- return workflowTaskWake;
43
- const implicitWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId);
52
+ if (this.workflowTasksSuppressSessionWake(freshIssue, undefined)) {
53
+ return undefined;
54
+ }
55
+ const implicitWake = this.db.workflowWakes.peekIssueWake(freshIssue.projectId, freshIssue.linearIssueId);
44
56
  if (!implicitWake)
45
57
  return undefined;
46
58
  return {
@@ -72,6 +84,56 @@ export class RunWakePlanner {
72
84
  eventIds: [],
73
85
  };
74
86
  }
87
+ reconcileWorkflowTasks(issue) {
88
+ try {
89
+ reconcileWorkflowTasksForIssue(this.db, issue);
90
+ }
91
+ catch (error) {
92
+ this.logger?.warn({
93
+ projectId: issue.projectId,
94
+ linearIssueId: issue.linearIssueId,
95
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
96
+ error: error instanceof Error ? error.message : String(error),
97
+ }, "Workflow task reconciliation failed while planning run wake");
98
+ }
99
+ }
100
+ workflowTasksSuppressSessionWake(issue, wakeReason) {
101
+ const openTasks = this.db.workflowTasks.listOpenTasks(issue.projectId, issue.linearIssueId);
102
+ if (openTasks.length === 0)
103
+ return false;
104
+ if (openTasks.some((task) => task.taskType === "run" && task.gateAction === "start" && task.runType !== undefined)) {
105
+ return false;
106
+ }
107
+ if (!openTasks.some((task) => this.isBlockingWorkflowGate(task)))
108
+ return false;
109
+ if (!openTasks.every((task) => task.taskId === "wait:input")) {
110
+ return true;
111
+ }
112
+ return wakeReason !== "direct_reply"
113
+ && wakeReason !== "followup_prompt"
114
+ && wakeReason !== "followup_comment"
115
+ && wakeReason !== "human_instruction"
116
+ && wakeReason !== "operator_prompt"
117
+ && wakeReason !== "completion_check_continue";
118
+ }
119
+ isBlockingWorkflowGate(task) {
120
+ if (task.taskId === "wait:input")
121
+ return true;
122
+ if (task.taskId === "wait:children" || task.taskId === "wait:blockers" || task.taskId.startsWith("wait:active-run:")) {
123
+ return true;
124
+ }
125
+ if (task.taskId === "wait:authority") {
126
+ return this.workflowAuthorityObserved(task.projectId, task.subjectId);
127
+ }
128
+ return task.taskType === "verify" || task.taskType === "ask" || task.taskType === "escalate" || task.taskType === "publish";
129
+ }
130
+ workflowAuthorityObserved(projectId, linearIssueId) {
131
+ return this.db.workflowObservations
132
+ .listObservations(projectId, linearIssueId)
133
+ .some((observation) => (observation.type === "linear.delegated"
134
+ || observation.type === "linear.undelegated"
135
+ || observation.type === "operator.authority_changed"));
136
+ }
75
137
  appendWakeEventWithLease(lease, issue, runType, context, dedupeScope) {
76
138
  let eventType;
77
139
  let dedupeKey;
@@ -1,5 +1,6 @@
1
1
  import { SerialWorkQueue } from "./service-queue.js";
2
2
  import { retrySqliteLockedQueueFailure } from "./queue-failure-policy.js";
3
+ import { isSqliteSchemaReadError } from "./sqlite-errors.js";
3
4
  const ISSUE_KEY_DELIMITER = "::";
4
5
  const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
5
6
  const DEFAULT_RECONCILE_TIMEOUT_MS = 60_000;
@@ -135,7 +136,7 @@ export class ServiceRuntime {
135
136
  }
136
137
  this.reconcileInProgress = true;
137
138
  try {
138
- await promiseWithTimeout(this.runReconciler.reconcileActiveRuns(), this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, "Background active-run reconciliation");
139
+ await this.reconcileActiveRunsWithSchemaRetry();
139
140
  // Pick up issues that became ready outside the webhook path
140
141
  // (e.g. CLI retry, manual DB edits) without requiring a restart.
141
142
  for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
@@ -143,7 +144,10 @@ export class ServiceRuntime {
143
144
  }
144
145
  }
145
146
  catch (error) {
146
- this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Background active-run reconciliation failed");
147
+ this.logger.warn({
148
+ error: error instanceof Error ? error.message : String(error),
149
+ storage: this.safeStorageDiagnostics(),
150
+ }, "Background active-run reconciliation failed");
147
151
  }
148
152
  finally {
149
153
  this.reconcileInProgress = false;
@@ -152,6 +156,30 @@ export class ServiceRuntime {
152
156
  }
153
157
  }
154
158
  }
159
+ async reconcileActiveRunsWithSchemaRetry() {
160
+ try {
161
+ await this.reconcileActiveRunsOnce();
162
+ }
163
+ catch (error) {
164
+ if (!isSqliteSchemaReadError(error) || !this.options.assertStorageReady) {
165
+ throw error;
166
+ }
167
+ this.options.assertStorageReady();
168
+ await new Promise((resolve) => setTimeout(resolve, 100));
169
+ await this.reconcileActiveRunsOnce();
170
+ }
171
+ }
172
+ async reconcileActiveRunsOnce() {
173
+ await promiseWithTimeout(this.runReconciler.reconcileActiveRuns(), this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, "Background active-run reconciliation");
174
+ }
175
+ safeStorageDiagnostics() {
176
+ try {
177
+ return this.options.describeStorage?.();
178
+ }
179
+ catch {
180
+ return undefined;
181
+ }
182
+ }
155
183
  getMaxActiveIssueRuns() {
156
184
  const configured = this.options.maxActiveIssueRuns ?? DEFAULT_MAX_ACTIVE_ISSUE_RUNS;
157
185
  return Math.max(1, Math.floor(configured));
@@ -125,8 +125,10 @@ export class ServiceStartupRecovery {
125
125
  prNumber: issue.prNumber,
126
126
  prState: issue.prState,
127
127
  prIsDraft: issue.prIsDraft,
128
+ prHeadSha: issue.prHeadSha,
128
129
  prReviewState: issue.prReviewState,
129
130
  prCheckStatus: issue.prCheckStatus,
131
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
130
132
  latestFailureSource: issue.lastGitHubFailureSource,
131
133
  })
132
134
  : undefined;
package/dist/service.js CHANGED
@@ -77,6 +77,9 @@ export class PatchRelayService {
77
77
  processIssue: async (item) => {
78
78
  await this.orchestrator.run(item);
79
79
  },
80
+ }, {
81
+ assertStorageReady: () => db.assertSchemaReady(),
82
+ describeStorage: () => db.describeSchema(),
80
83
  });
81
84
  enqueueIssue = (projectId, issueId) => runtime.enqueueIssue(projectId, issueId);
82
85
  this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
@@ -0,0 +1,5 @@
1
+ export function isSqliteSchemaReadError(error) {
2
+ const message = error instanceof Error ? error.message : String(error);
3
+ return message.includes("no such table:")
4
+ || message.includes("no such column:");
5
+ }
@@ -75,8 +75,10 @@ export function buildTrackedIssueRecord(params) {
75
75
  orchestrationSettleUntil: params.issue.orchestrationSettleUntil,
76
76
  ...(params.issue.prNumber !== undefined ? { prNumber: params.issue.prNumber } : {}),
77
77
  ...(params.issue.prState ? { prState: params.issue.prState } : {}),
78
+ ...(params.issue.prHeadSha ? { prHeadSha: params.issue.prHeadSha } : {}),
78
79
  ...(params.issue.prReviewState ? { prReviewState: params.issue.prReviewState } : {}),
79
80
  ...(params.issue.prCheckStatus ? { prCheckStatus: params.issue.prCheckStatus } : {}),
81
+ ...(params.issue.lastBlockingReviewHeadSha ? { lastBlockingReviewHeadSha: params.issue.lastBlockingReviewHeadSha } : {}),
80
82
  ...(params.issue.lastGitHubFailureSource ? { latestFailureSource: params.issue.lastGitHubFailureSource } : {}),
81
83
  }),
82
84
  ...(params.issue.lastGitHubFailureSource ? { latestFailureSource: params.issue.lastGitHubFailureSource } : {}),
@@ -1,4 +1,5 @@
1
1
  import { emitTelemetry, noopTelemetry } from "./telemetry.js";
2
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
2
3
  // Single owner of "append a session event and tell the orchestrator
3
4
  // something might be runnable", and of "release a finished run so the
4
5
  // next wake fires." Until this existed, 8+ call sites each made their
@@ -32,22 +33,82 @@ export class WakeDispatcher {
32
33
  this.feed = feed;
33
34
  this.telemetry = telemetry;
34
35
  }
35
- peekRunnableWorkflowTask(projectId, linearIssueId) {
36
- return this.db.workflowTasks
37
- .listOpenRunnableTasks(projectId)
38
- .find((task) => task.subjectId === linearIssueId && task.runType !== undefined);
36
+ listOpenWorkflowTasks(projectId, linearIssueId) {
37
+ return this.db.workflowTasks.listOpenTasks(projectId, linearIssueId);
39
38
  }
40
- resolveDispatchableWake(projectId, linearIssueId, issue) {
41
- const sessionWake = this.db.issueSessions.peekIssueSessionWake(projectId, linearIssueId);
42
- if (sessionWake) {
39
+ reconcileOpenWorkflowTasks(issue, options) {
40
+ try {
41
+ return reconcileWorkflowTasksForIssue(this.db, issue, options).result.open;
42
+ }
43
+ catch (error) {
44
+ this.logger.warn({
45
+ projectId: issue.projectId,
46
+ linearIssueId: issue.linearIssueId,
47
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
48
+ error: error instanceof Error ? error.message : String(error),
49
+ }, "Workflow task reconciliation failed while resolving wake");
50
+ return this.listOpenWorkflowTasks(issue.projectId, issue.linearIssueId);
51
+ }
52
+ }
53
+ peekRunnableWorkflowTask(projectId, linearIssueId, openTasks) {
54
+ return (openTasks ?? this.db.workflowTasks.listOpenRunnableTasks(projectId))
55
+ .find((task) => (task.subjectId === linearIssueId
56
+ && task.taskType === "run"
57
+ && task.gateAction === "start"
58
+ && task.runType !== undefined));
59
+ }
60
+ workflowAuthorityObserved(projectId, linearIssueId) {
61
+ return this.db.workflowObservations
62
+ .listObservations(projectId, linearIssueId)
63
+ .some((observation) => (observation.type === "linear.delegated"
64
+ || observation.type === "linear.undelegated"
65
+ || observation.type === "operator.authority_changed"));
66
+ }
67
+ sessionWakeCanAnswerInputWait(openTasks, wakeReason) {
68
+ if (openTasks.length === 0 || !openTasks.every((task) => task.taskId === "wait:input")) {
69
+ return false;
70
+ }
71
+ return wakeReason === "direct_reply"
72
+ || wakeReason === "followup_prompt"
73
+ || wakeReason === "followup_comment"
74
+ || wakeReason === "human_instruction"
75
+ || wakeReason === "operator_prompt"
76
+ || wakeReason === "completion_check_continue";
77
+ }
78
+ workflowTasksSuppressSessionWake(openTasks, wakeReason) {
79
+ if (openTasks.length === 0)
80
+ return false;
81
+ if (this.peekRunnableWorkflowTask(openTasks[0].projectId, openTasks[0].subjectId, openTasks))
82
+ return false;
83
+ if (!openTasks.some((task) => this.isBlockingWorkflowGate(task)))
84
+ return false;
85
+ return !this.sessionWakeCanAnswerInputWait(openTasks, wakeReason);
86
+ }
87
+ isBlockingWorkflowGate(task) {
88
+ if (task.taskId === "wait:input")
89
+ return true;
90
+ if (task.taskId === "wait:children" || task.taskId === "wait:blockers" || task.taskId.startsWith("wait:active-run:")) {
91
+ return true;
92
+ }
93
+ if (task.taskId === "wait:authority") {
94
+ return this.workflowAuthorityObserved(task.projectId, task.subjectId);
95
+ }
96
+ return task.taskType === "verify" || task.taskType === "ask" || task.taskType === "escalate" || task.taskType === "publish";
97
+ }
98
+ resolveDispatchableWake(projectId, linearIssueId, issue, options) {
99
+ const existingWorkflowTasks = this.listOpenWorkflowTasks(projectId, linearIssueId);
100
+ const existingWorkflowTask = this.peekRunnableWorkflowTask(projectId, linearIssueId, existingWorkflowTasks);
101
+ if (existingWorkflowTask?.runType) {
43
102
  return {
44
- runType: sessionWake.runType,
45
- ...(sessionWake.wakeReason ? { wakeReason: sessionWake.wakeReason } : {}),
46
- eventIds: sessionWake.eventIds,
47
- source: "session_event",
103
+ runType: existingWorkflowTask.runType,
104
+ wakeReason: existingWorkflowTask.taskId,
105
+ eventIds: [],
106
+ source: "workflow_task",
48
107
  };
49
108
  }
50
- const workflowTask = this.peekRunnableWorkflowTask(projectId, linearIssueId);
109
+ const freshIssue = this.db.issues.getIssue(projectId, linearIssueId) ?? issue;
110
+ const openWorkflowTasks = this.reconcileOpenWorkflowTasks(freshIssue, options);
111
+ const workflowTask = this.peekRunnableWorkflowTask(projectId, linearIssueId, openWorkflowTasks);
51
112
  if (workflowTask?.runType) {
52
113
  return {
53
114
  runType: workflowTask.runType,
@@ -56,6 +117,21 @@ export class WakeDispatcher {
56
117
  source: "workflow_task",
57
118
  };
58
119
  }
120
+ const sessionWake = this.db.issueSessions.peekIssueSessionWake(projectId, linearIssueId);
121
+ if (sessionWake) {
122
+ if (this.workflowTasksSuppressSessionWake(openWorkflowTasks, sessionWake.wakeReason)) {
123
+ return undefined;
124
+ }
125
+ return {
126
+ runType: sessionWake.runType,
127
+ ...(sessionWake.wakeReason ? { wakeReason: sessionWake.wakeReason } : {}),
128
+ eventIds: sessionWake.eventIds,
129
+ source: "session_event",
130
+ };
131
+ }
132
+ if (this.workflowTasksSuppressSessionWake(openWorkflowTasks, undefined)) {
133
+ return undefined;
134
+ }
59
135
  if (issue.pendingRunType) {
60
136
  return {
61
137
  runType: issue.pendingRunType,
@@ -279,7 +355,17 @@ export class WakeDispatcher {
279
355
  runType: params.run.runType,
280
356
  });
281
357
  const issue = this.db.issues.getIssue(params.run.projectId, params.run.linearIssueId);
282
- const wake = issue ? this.resolveDispatchableWake(params.run.projectId, params.run.linearIssueId, issue) : undefined;
358
+ if (issue?.factoryState === "done" || issue?.factoryState === "failed" || issue?.factoryState === "escalated" || issue?.prState === "merged") {
359
+ emitTelemetry(this.telemetry, {
360
+ type: "wake.suppressed",
361
+ projectId: params.run.projectId,
362
+ linearIssueId: params.run.linearIssueId,
363
+ ...(params.issueKey ? { issueKey: params.issueKey } : {}),
364
+ reason: "terminal_event",
365
+ });
366
+ return undefined;
367
+ }
368
+ const wake = issue ? this.resolveDispatchableWake(params.run.projectId, params.run.linearIssueId, issue, { ignoreDetachedActiveRuns: true }) : undefined;
283
369
  if (!wake) {
284
370
  emitTelemetry(this.telemetry, {
285
371
  type: "wake.suppressed",
@@ -49,8 +49,10 @@ export function resolveReDelegationResume(p) {
49
49
  prNumber: p.prNumber,
50
50
  prState: p.prState,
51
51
  prIsDraft: p.prIsDraft,
52
+ prHeadSha: p.prHeadSha,
52
53
  prReviewState: p.prReviewState,
53
54
  prCheckStatus: p.prCheckStatus,
55
+ lastBlockingReviewHeadSha: p.lastBlockingReviewHeadSha,
54
56
  latestFailureSource: p.latestFailureSource,
55
57
  });
56
58
  if (reactiveIntent) {
@@ -1,4 +1,5 @@
1
1
  import { buildFailureContext } from "./idle-reconciliation-helpers.js";
2
+ import { isCurrentHeadRequestedChanges } from "./issue-session.js";
2
3
  import { tryParseRunContextValue } from "./run-context.js";
3
4
  function parseObservationPayload(observation) {
4
5
  if (!observation.payloadJson)
@@ -197,6 +198,9 @@ export function deriveWorkflowTasks(snapshot) {
197
198
  if (snapshot.status === "done") {
198
199
  return [];
199
200
  }
201
+ if (snapshot.status === "failed") {
202
+ return [];
203
+ }
200
204
  if (snapshot.activeRun) {
201
205
  return [{
202
206
  id: `wait:active-run:${snapshot.activeRun.id}`,
@@ -242,7 +246,11 @@ export function deriveWorkflowTasks(snapshot) {
242
246
  requirements: { childCount: snapshot.childCount },
243
247
  }];
244
248
  }
245
- if (prState === "open" && prReviewState === "changes_requested") {
249
+ if (prState === "open" && isCurrentHeadRequestedChanges({
250
+ prReviewState: typeof prReviewState === "string" ? prReviewState : undefined,
251
+ prHeadSha: typeof prHeadSha === "string" ? prHeadSha : undefined,
252
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
253
+ })) {
246
254
  tasks.push({
247
255
  id: "run:review_fix",
248
256
  type: "run",
@@ -2,13 +2,21 @@ import { evaluateTaskStart, projectWorkflowSnapshot, } from "./workflow-runtime.
2
2
  function isActiveRun(run) {
3
3
  return run.status === "queued" || run.status === "running";
4
4
  }
5
- function resolveActiveRunSnapshot(db, issue) {
5
+ function resolveActiveRunSnapshot(db, issue, options) {
6
6
  const pinnedRun = issue.activeRunId !== undefined ? db.runs.getRunById(issue.activeRunId) : undefined;
7
- const run = pinnedRun && isActiveRun(pinnedRun)
8
- ? pinnedRun
9
- : db.runs.listRunsForIssue(issue.projectId, issue.linearIssueId)
10
- .filter(isActiveRun)
11
- .at(-1);
7
+ if (pinnedRun && isActiveRun(pinnedRun)) {
8
+ return {
9
+ id: pinnedRun.id,
10
+ runType: pinnedRun.runType,
11
+ authorityEpoch: pinnedRun.authorityEpoch,
12
+ status: pinnedRun.status,
13
+ };
14
+ }
15
+ if (options?.ignoreDetachedActiveRuns)
16
+ return undefined;
17
+ const run = db.runs.listRunsForIssue(issue.projectId, issue.linearIssueId)
18
+ .filter(isActiveRun)
19
+ .at(-1);
12
20
  if (!run)
13
21
  return undefined;
14
22
  return {
@@ -34,8 +42,8 @@ function readinessForTask(snapshot, task) {
34
42
  }
35
43
  return evaluateTaskStart(snapshot, task);
36
44
  }
37
- export function buildWorkflowSnapshotForIssue(db, issue) {
38
- const activeRun = resolveActiveRunSnapshot(db, issue);
45
+ export function buildWorkflowSnapshotForIssue(db, issue, options) {
46
+ const activeRun = resolveActiveRunSnapshot(db, issue, options);
39
47
  return projectWorkflowSnapshot({
40
48
  issue,
41
49
  observations: db.workflowObservations.listObservations(issue.projectId, issue.linearIssueId),
@@ -45,8 +53,8 @@ export function buildWorkflowSnapshotForIssue(db, issue) {
45
53
  ...(activeRun ? { activeRun } : {}),
46
54
  });
47
55
  }
48
- export function reconcileWorkflowTasksForIssue(db, issue) {
49
- const snapshot = buildWorkflowSnapshotForIssue(db, issue);
56
+ export function reconcileWorkflowTasksForIssue(db, issue, options) {
57
+ const snapshot = buildWorkflowSnapshotForIssue(db, issue, options);
50
58
  const result = db.workflowTasks.reconcileTasks({
51
59
  projectId: issue.projectId,
52
60
  subjectId: issue.linearIssueId,
@@ -38,8 +38,10 @@ export function deriveImplicitReactiveWake(issue) {
38
38
  activeRunId: issue.activeRunId,
39
39
  prNumber: issue.prNumber,
40
40
  prState: issue.prState,
41
+ prHeadSha: issue.prHeadSha,
41
42
  prReviewState: issue.prReviewState,
42
43
  prCheckStatus: issue.prCheckStatus,
44
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
43
45
  latestFailureSource: issue.lastGitHubFailureSource,
44
46
  });
45
47
  if (!reactiveIntent)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.83.0",
3
+ "version": "0.83.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {