patchrelay 0.82.0 → 0.83.1

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,8 +1,22 @@
1
1
  import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
2
2
  import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
3
- import { parseRunContextOrWarn, serializeRunContext } from "./run-context.js";
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";
7
+ function parseObjectJson(raw) {
8
+ if (!raw)
9
+ return undefined;
10
+ try {
11
+ const parsed = JSON.parse(raw);
12
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
13
+ ? parsed
14
+ : undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
6
20
  export class RunWakePlanner {
7
21
  db;
8
22
  logger;
@@ -11,17 +25,115 @@ export class RunWakePlanner {
11
25
  this.logger = logger;
12
26
  }
13
27
  resolveRunWake(issue) {
14
- const sessionWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId);
15
- if (!sessionWake)
28
+ const freshIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
29
+ if (this.db.issues.countUnresolvedBlockers(freshIssue.projectId, freshIssue.linearIssueId) > 0) {
30
+ return undefined;
31
+ }
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);
40
+ if (sessionWake) {
41
+ if (this.workflowTasksSuppressSessionWake(freshIssue, sessionWake.wakeReason)) {
42
+ return undefined;
43
+ }
44
+ return {
45
+ runType: sessionWake.runType,
46
+ context: sessionWake.context,
47
+ wakeReason: sessionWake.wakeReason,
48
+ resumeThread: sessionWake.resumeThread,
49
+ eventIds: sessionWake.eventIds,
50
+ };
51
+ }
52
+ if (this.workflowTasksSuppressSessionWake(freshIssue, undefined)) {
53
+ return undefined;
54
+ }
55
+ const implicitWake = this.db.workflowWakes.peekIssueWake(freshIssue.projectId, freshIssue.linearIssueId);
56
+ if (!implicitWake)
57
+ return undefined;
58
+ return {
59
+ runType: implicitWake.runType,
60
+ context: implicitWake.context,
61
+ wakeReason: implicitWake.wakeReason,
62
+ resumeThread: implicitWake.resumeThread,
63
+ eventIds: implicitWake.eventIds,
64
+ };
65
+ }
66
+ resolveWorkflowTaskWake(issue) {
67
+ const task = this.db.workflowTasks
68
+ .listOpenRunnableTasks(issue.projectId)
69
+ .find((entry) => entry.subjectId === issue.linearIssueId);
70
+ if (!task?.runType)
16
71
  return undefined;
72
+ const runType = task.runType;
73
+ const rawRequirements = parseObjectJson(task.requirementsJson);
74
+ const context = tryParseRunContextValue({
75
+ ...rawRequirements,
76
+ ...(rawRequirements?.blockingHeadSha ? { requestedChangesHeadSha: rawRequirements.blockingHeadSha } : {}),
77
+ source: "workflow_task",
78
+ }) ?? { source: "workflow_task" };
17
79
  return {
18
- runType: sessionWake.runType,
19
- context: sessionWake.context,
20
- wakeReason: sessionWake.wakeReason,
21
- resumeThread: sessionWake.resumeThread,
22
- eventIds: sessionWake.eventIds,
80
+ runType,
81
+ ...(Object.keys(context).length > 0 ? { context } : {}),
82
+ wakeReason: task.taskId,
83
+ resumeThread: runType !== "implementation",
84
+ eventIds: [],
23
85
  };
24
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
+ }
25
137
  appendWakeEventWithLease(lease, issue, runType, context, dedupeScope) {
26
138
  let eventType;
27
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));
@@ -1,8 +1,8 @@
1
1
  import { appendDelegationObservedEvent } from "./delegation-audit.js";
2
2
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
3
3
  import { isResumablePausedLocalWork } from "./paused-issue-state.js";
4
- import { buildRepairWakeDedupeKey, buildRequestedChangesWakeIdentity, reactiveWakeEventType } from "./reactive-wake-keys.js";
5
4
  import { upsertLinearIssueProjection } from "./linear-issue-projection.js";
5
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
6
6
  const WRITER = "service-startup-recovery";
7
7
  export class ServiceStartupRecovery {
8
8
  config;
@@ -81,6 +81,14 @@ export class ServiceStartupRecovery {
81
81
  issue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
82
82
  const delegated = liveIssue.delegateId === installation.actorId;
83
83
  if (issue.delegatedToPatchRelay !== delegated) {
84
+ this.appendAuthorityObservation({
85
+ projectId: issue.projectId,
86
+ linearIssueId: issue.linearIssueId,
87
+ delegated,
88
+ actorId: installation.actorId,
89
+ observedDelegateId: liveIssue.delegateId,
90
+ reason: "startup_recovery_refreshed_linear_delegation",
91
+ });
84
92
  appendDelegationObservedEvent(this.db, {
85
93
  projectId: issue.projectId,
86
94
  linearIssueId: issue.linearIssueId,
@@ -100,7 +108,8 @@ export class ServiceStartupRecovery {
100
108
  }
101
109
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
102
110
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
103
- const hasPendingWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId) !== undefined;
111
+ const hasPendingWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId) !== undefined
112
+ || this.db.workflowTasks.listOpenRunnableTasks(issue.projectId).some((task) => task.subjectId === issue.linearIssueId);
104
113
  const shouldRecoverPausedLocalWork = delegated
105
114
  && isResumablePausedLocalWork({
106
115
  issue: {
@@ -158,18 +167,7 @@ export class ServiceStartupRecovery {
158
167
  continue;
159
168
  }
160
169
  if (unresolvedBlockers === 0) {
161
- if (shouldRecoverReactivePrWork && reactiveIntent) {
162
- this.appendReactiveWakeEvent(issue.projectId, issue.linearIssueId, issue, reactiveIntent.runType);
163
- }
164
- else {
165
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
166
- projectId: issue.projectId,
167
- linearIssueId: issue.linearIssueId,
168
- eventType: "delegated",
169
- dedupeKey: `delegated:${issue.linearIssueId}`,
170
- });
171
- }
172
- if (this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId)) {
170
+ if (this.reconcileAndFindRunnableTask(updated.projectId, updated.linearIssueId)) {
173
171
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
174
172
  }
175
173
  this.logger.info({
@@ -255,61 +253,53 @@ export class ServiceStartupRecovery {
255
253
  if (commit.outcome !== "applied")
256
254
  return;
257
255
  const updated = commit.issue;
258
- const hasPendingWake = this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id) !== undefined;
256
+ this.appendAuthorityObservation({
257
+ projectId: project.id,
258
+ linearIssueId: liveIssue.id,
259
+ delegated: true,
260
+ observedDelegateId: liveIssue.delegateId,
261
+ reason: "startup_recovery_discovered_delegated_issue",
262
+ });
259
263
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(project.id, liveIssue.id);
260
- if (!hasPendingWake && unresolvedBlockers === 0) {
261
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, liveIssue.id, {
262
- projectId: project.id,
263
- linearIssueId: liveIssue.id,
264
- eventType: "delegated",
265
- dedupeKey: `delegated:${liveIssue.id}`,
266
- });
267
- }
268
- if (this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id)) {
269
- this.enqueueIssue(project.id, liveIssue.id);
270
- }
271
264
  this.logger.info({
272
265
  issueKey: updated.issueKey,
273
266
  projectId: project.id,
274
267
  unresolvedBlockers,
275
268
  }, unresolvedBlockers === 0
276
- ? "Discovered delegated Linear issue during startup recovery and queued implementation"
269
+ ? "Discovered delegated Linear issue during startup recovery"
277
270
  : "Discovered delegated blocked Linear issue during startup recovery");
278
271
  }
279
- appendReactiveWakeEvent(projectId, linearIssueId, issue, runType) {
280
- const eventType = reactiveWakeEventType(runType);
281
- const dedupeKey = runType === "queue_repair" || runType === "ci_repair"
282
- ? buildRepairWakeDedupeKey({
283
- scope: "startup_recovery",
284
- runType,
285
- linearIssueId,
286
- signature: issue.lastGitHubFailureSignature,
287
- prHeadSha: issue.prHeadSha,
288
- failureHeadSha: issue.lastGitHubFailureHeadSha,
289
- })
290
- : buildRequestedChangesWakeIdentity({
291
- linearIssueId,
292
- runType,
293
- headSha: issue.prHeadSha,
294
- }).dedupeKey;
295
- const requestedChangesIdentity = eventType === "review_changes_requested"
296
- ? buildRequestedChangesWakeIdentity({
297
- linearIssueId,
298
- runType: runType === "branch_upkeep" ? "branch_upkeep" : "review_fix",
299
- headSha: issue.prHeadSha,
300
- })
301
- : undefined;
302
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
303
- projectId,
304
- linearIssueId,
305
- eventType,
306
- ...(requestedChangesIdentity ? {
307
- eventJson: JSON.stringify({
308
- requestedChangesCoalesceKey: requestedChangesIdentity.coalesceKey,
309
- ...(requestedChangesIdentity.headSha ? { requestedChangesHeadSha: requestedChangesIdentity.headSha } : {}),
310
- }),
311
- } : {}),
312
- dedupeKey,
272
+ appendAuthorityObservation(params) {
273
+ this.db.workflowObservations.appendObservation({
274
+ projectId: params.projectId,
275
+ subjectId: params.linearIssueId,
276
+ source: "linear",
277
+ type: params.delegated ? "linear.delegated" : "linear.undelegated",
278
+ payloadJson: JSON.stringify({
279
+ source: "startup_recovery",
280
+ delegated: params.delegated,
281
+ issueId: params.linearIssueId,
282
+ actorId: params.actorId,
283
+ observedDelegateId: params.observedDelegateId,
284
+ reason: params.reason,
285
+ }),
286
+ dedupeKey: [
287
+ "startup_recovery",
288
+ "authority",
289
+ params.linearIssueId,
290
+ params.delegated ? "delegated" : "undelegated",
291
+ params.observedDelegateId ?? "",
292
+ ].join(":"),
313
293
  });
314
294
  }
295
+ reconcileAndFindRunnableTask(projectId, linearIssueId) {
296
+ const issue = this.db.issues.getIssue(projectId, linearIssueId);
297
+ if (!issue)
298
+ return false;
299
+ const reconciliation = reconcileWorkflowTasksForIssue(this.db, issue);
300
+ return [
301
+ ...reconciliation.result.opened,
302
+ ...reconciliation.result.updated,
303
+ ].some((task) => task.gateAction === "start" && task.runType);
304
+ }
315
305
  }
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
+ }
@@ -85,8 +85,12 @@ export class TrackedIssueListQuery {
85
85
  const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
86
86
  const blockedByCount = Number(row.blocked_by_count ?? 0);
87
87
  const hasPendingSessionEvents = Number(row.pending_session_event_count ?? 0) > 0;
88
+ const hasRunnableWorkflowTask = this.db.workflowTasks
89
+ .listOpenRunnableTasks(String(row.project_id))
90
+ .some((task) => task.subjectId === String(row.linear_issue_id));
88
91
  const hasPendingWake = hasPendingSessionEvents
89
- || this.db.workflowWakes.peekIssueWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
92
+ || this.db.workflowWakes.peekIssueWake(String(row.project_id), String(row.linear_issue_id)) !== undefined
93
+ || hasRunnableWorkflowTask;
90
94
  const detachedActiveRun = hasDetachedActiveLatestRun({
91
95
  activeRunId: row.active_run_type !== null ? 1 : undefined,
92
96
  latestRun: row.latest_run_status !== null
@@ -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,6 +33,122 @@ export class WakeDispatcher {
32
33
  this.feed = feed;
33
34
  this.telemetry = telemetry;
34
35
  }
36
+ listOpenWorkflowTasks(projectId, linearIssueId) {
37
+ return this.db.workflowTasks.listOpenTasks(projectId, linearIssueId);
38
+ }
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) {
102
+ return {
103
+ runType: existingWorkflowTask.runType,
104
+ wakeReason: existingWorkflowTask.taskId,
105
+ eventIds: [],
106
+ source: "workflow_task",
107
+ };
108
+ }
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);
112
+ if (workflowTask?.runType) {
113
+ return {
114
+ runType: workflowTask.runType,
115
+ wakeReason: workflowTask.taskId,
116
+ eventIds: [],
117
+ source: "workflow_task",
118
+ };
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
+ }
135
+ if (issue.pendingRunType) {
136
+ return {
137
+ runType: issue.pendingRunType,
138
+ eventIds: [],
139
+ source: "legacy_pending_run_type",
140
+ };
141
+ }
142
+ const implicitWake = this.db.workflowWakes.peekIssueWake(projectId, linearIssueId);
143
+ if (!implicitWake)
144
+ return undefined;
145
+ return {
146
+ runType: implicitWake.runType,
147
+ ...(implicitWake.wakeReason ? { wakeReason: implicitWake.wakeReason } : {}),
148
+ eventIds: implicitWake.eventIds,
149
+ source: "implicit",
150
+ };
151
+ }
35
152
  // Scope the next enqueue calls inside `fn` to a single dedupe Set.
36
153
  // Nested ticks reuse the outermost Set so deeply nested helpers do
37
154
  // not silently lose dedupe.
@@ -144,12 +261,8 @@ export class WakeDispatcher {
144
261
  }
145
262
  return undefined;
146
263
  }
147
- const wake = this.db.workflowWakes.peekIssueWake(projectId, linearIssueId);
148
- // Fall back to the legacy pending_run_type column. The orchestrator
149
- // materializes it into a real event at run time, but the poke still
150
- // needs to happen now so the orchestrator gets called at all.
151
- const runType = wake?.runType ?? issue.pendingRunType;
152
- if (!runType) {
264
+ const dispatchable = this.resolveDispatchableWake(projectId, linearIssueId, issue);
265
+ if (!dispatchable) {
153
266
  emitTelemetry(this.telemetry, {
154
267
  type: "wake.suppressed",
155
268
  projectId,
@@ -175,10 +288,10 @@ export class WakeDispatcher {
175
288
  projectId,
176
289
  linearIssueId,
177
290
  ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
178
- runType,
179
- ...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
180
- ...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
181
- source: wake ? (wake.eventIds.length > 0 ? "session_event" : "implicit") : "legacy_pending_run_type",
291
+ runType: dispatchable.runType,
292
+ ...(dispatchable.wakeReason ? { wakeReason: dispatchable.wakeReason } : {}),
293
+ eventIds: dispatchable.eventIds,
294
+ source: dispatchable.source,
182
295
  });
183
296
  const tick = options?.enqueuedThisTick ?? this.currentTick;
184
297
  const key = `${projectId}:${linearIssueId}`;
@@ -188,18 +301,18 @@ export class WakeDispatcher {
188
301
  projectId,
189
302
  linearIssueId,
190
303
  ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
191
- runType,
192
- ...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
193
- ...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
304
+ runType: dispatchable.runType,
305
+ ...(dispatchable.wakeReason ? { wakeReason: dispatchable.wakeReason } : {}),
306
+ eventIds: dispatchable.eventIds,
194
307
  });
195
308
  emitTelemetry(this.telemetry, {
196
309
  type: "queue.deduped",
197
310
  projectId,
198
311
  linearIssueId,
199
312
  ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
200
- runType,
313
+ runType: dispatchable.runType,
201
314
  });
202
- return runType;
315
+ return dispatchable.runType;
203
316
  }
204
317
  tick?.add(key);
205
318
  this.enqueueIssue(projectId, linearIssueId);
@@ -208,18 +321,18 @@ export class WakeDispatcher {
208
321
  projectId,
209
322
  linearIssueId,
210
323
  ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
211
- runType,
212
- ...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
213
- ...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
324
+ runType: dispatchable.runType,
325
+ ...(dispatchable.wakeReason ? { wakeReason: dispatchable.wakeReason } : {}),
326
+ eventIds: dispatchable.eventIds,
214
327
  });
215
328
  emitTelemetry(this.telemetry, {
216
329
  type: "queue.enqueued",
217
330
  projectId,
218
331
  linearIssueId,
219
332
  ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
220
- runType,
333
+ runType: dispatchable.runType,
221
334
  });
222
- return runType;
335
+ return dispatchable.runType;
223
336
  }
224
337
  // Release the lease for a finished run, then drain any wake that
225
338
  // landed during the run. The single owner of "run is over, what's
@@ -241,7 +354,18 @@ export class WakeDispatcher {
241
354
  runId: params.run.id,
242
355
  runType: params.run.runType,
243
356
  });
244
- const wake = this.db.workflowWakes.peekIssueWake(params.run.projectId, params.run.linearIssueId);
357
+ const issue = this.db.issues.getIssue(params.run.projectId, params.run.linearIssueId);
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;
245
369
  if (!wake) {
246
370
  emitTelemetry(this.telemetry, {
247
371
  type: "wake.suppressed",