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.
- package/dist/build-info.json +3 -3
- package/dist/db/migrations.js +43 -0
- package/dist/db/run-store.js +62 -4
- package/dist/db/schema-guard.js +2 -0
- package/dist/db/workflow-observation-store.js +61 -0
- package/dist/db/workflow-task-store.js +111 -0
- package/dist/db.js +75 -3
- package/dist/github-review-context.js +90 -0
- package/dist/github-webhook-handler.js +64 -11
- package/dist/idle-reconciliation.js +33 -6
- package/dist/issue-overview-query.js +2 -1
- package/dist/linear-issue-projection.js +37 -0
- package/dist/linear-progress-reporter.js +15 -2
- package/dist/orchestration-parent-wake.js +11 -0
- package/dist/run-context.js +6 -6
- package/dist/run-finalizer.js +102 -22
- package/dist/run-launcher.js +1 -0
- package/dist/run-notification-handler.js +16 -1
- package/dist/run-orchestrator.js +7 -0
- package/dist/run-wake-planner.js +120 -8
- package/dist/service-runtime.js +30 -2
- package/dist/service-startup-recovery.js +51 -61
- package/dist/service.js +3 -0
- package/dist/sqlite-errors.js +5 -0
- package/dist/tracked-issue-list-query.js +5 -1
- package/dist/wake-dispatcher.js +145 -21
- package/dist/webhook-handler.js +75 -30
- package/dist/webhooks/dependency-readiness-handler.js +19 -13
- package/dist/webhooks/desired-stage-recorder.js +3 -18
- package/dist/workflow-runtime.js +384 -0
- package/dist/workflow-task-reconciler.js +72 -0
- package/package.json +1 -1
- package/dist/github-webhook-reactive-run.js +0 -309
package/dist/run-wake-planner.js
CHANGED
|
@@ -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
|
|
15
|
-
if (
|
|
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
|
|
19
|
-
context:
|
|
20
|
-
wakeReason:
|
|
21
|
-
resumeThread:
|
|
22
|
-
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;
|
package/dist/service-runtime.js
CHANGED
|
@@ -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
|
|
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({
|
|
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 (
|
|
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
|
-
|
|
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
|
|
269
|
+
? "Discovered delegated Linear issue during startup recovery"
|
|
277
270
|
: "Discovered delegated blocked Linear issue during startup recovery");
|
|
278
271
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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);
|
|
@@ -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
|
package/dist/wake-dispatcher.js
CHANGED
|
@@ -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
|
|
148
|
-
|
|
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
|
-
...(
|
|
180
|
-
|
|
181
|
-
source:
|
|
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
|
-
...(
|
|
193
|
-
|
|
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
|
-
...(
|
|
213
|
-
|
|
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
|
|
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",
|