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.
- package/dist/build-info.json +3 -3
- package/dist/db.js +15 -0
- package/dist/delegation-linked-pr.js +2 -0
- package/dist/idle-reconciliation.js +4 -0
- package/dist/issue-overview-query.js +2 -0
- package/dist/issue-session-events.js +11 -0
- package/dist/issue-session.js +16 -1
- package/dist/linear-progress-reporter.js +15 -2
- package/dist/merged-linear-completion-reconciler.js +2 -0
- package/dist/orchestration-parent-wake.js +11 -0
- package/dist/run-completion-policy.js +2 -0
- package/dist/run-notification-handler.js +16 -1
- package/dist/run-wake-planner.js +68 -6
- package/dist/service-runtime.js +30 -2
- package/dist/service-startup-recovery.js +2 -0
- package/dist/service.js +3 -0
- package/dist/sqlite-errors.js +5 -0
- package/dist/tracked-issue-projector.js +2 -0
- package/dist/wake-dispatcher.js +99 -13
- package/dist/webhooks/decision-helpers.js +2 -0
- package/dist/workflow-runtime.js +9 -1
- package/dist/workflow-task-reconciler.js +18 -10
- package/dist/workflow-wake-resolver.js +2 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
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
|
}
|
package/dist/issue-session.js
CHANGED
|
@@ -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 (
|
|
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.
|
|
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.
|
|
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({
|
|
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);
|
package/dist/run-wake-planner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const implicitWake = this.db.workflowWakes.peekIssueWake(
|
|
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;
|
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));
|
|
@@ -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);
|
|
@@ -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 } : {}),
|
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,22 +33,82 @@ export class WakeDispatcher {
|
|
|
32
33
|
this.feed = feed;
|
|
33
34
|
this.telemetry = telemetry;
|
|
34
35
|
}
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
45
|
-
|
|
46
|
-
eventIds:
|
|
47
|
-
source: "
|
|
103
|
+
runType: existingWorkflowTask.runType,
|
|
104
|
+
wakeReason: existingWorkflowTask.taskId,
|
|
105
|
+
eventIds: [],
|
|
106
|
+
source: "workflow_task",
|
|
48
107
|
};
|
|
49
108
|
}
|
|
50
|
-
const
|
|
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
|
-
|
|
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) {
|
package/dist/workflow-runtime.js
CHANGED
|
@@ -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" &&
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
.
|
|
11
|
-
.
|
|
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)
|