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/webhook-handler.js
CHANGED
|
@@ -15,6 +15,7 @@ import { WakeDispatcher } from "./wake-dispatcher.js";
|
|
|
15
15
|
import { CodexFollowupIntentClassifier } from "./followup-intent.js";
|
|
16
16
|
import { AgentInputService } from "./agent-input-service.js";
|
|
17
17
|
import { noopTelemetry } from "./telemetry.js";
|
|
18
|
+
import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
|
|
18
19
|
export class WebhookHandler {
|
|
19
20
|
config;
|
|
20
21
|
db;
|
|
@@ -139,6 +140,33 @@ export class WebhookHandler {
|
|
|
139
140
|
stopActiveRun: (run, input) => this.stopActiveRun(run, input),
|
|
140
141
|
});
|
|
141
142
|
const trackedIssue = result.issue;
|
|
143
|
+
this.db.workflowObservations.appendObservation({
|
|
144
|
+
projectId: project.id,
|
|
145
|
+
subjectId: issue.id,
|
|
146
|
+
source: "linear",
|
|
147
|
+
type: hydrated.triggerEvent === "delegateChanged"
|
|
148
|
+
? result.delegated ? "linear.delegated" : "linear.undelegated"
|
|
149
|
+
: `linear.${hydrated.triggerEvent}`,
|
|
150
|
+
payloadJson: JSON.stringify({
|
|
151
|
+
triggerEvent: hydrated.triggerEvent,
|
|
152
|
+
webhookId: hydrated.webhookId,
|
|
153
|
+
delegated: result.delegated,
|
|
154
|
+
issueId: issue.id,
|
|
155
|
+
issueKey: issue.identifier,
|
|
156
|
+
agentSessionId: hydrated.agentSession?.id,
|
|
157
|
+
promptContext: hydrated.agentSession?.promptContext?.trim(),
|
|
158
|
+
promptBody: hydrated.agentSession?.promptBody?.trim(),
|
|
159
|
+
actorId: hydrated.actor?.id,
|
|
160
|
+
actorName: hydrated.actor?.name,
|
|
161
|
+
}),
|
|
162
|
+
dedupeKey: hydrated.webhookId,
|
|
163
|
+
});
|
|
164
|
+
const observedIssue = this.db.getIssue(project.id, issue.id);
|
|
165
|
+
let openedRunnableWorkflowTask = false;
|
|
166
|
+
if (observedIssue) {
|
|
167
|
+
const workflowReconciliation = reconcileWorkflowTasksForIssue(this.db, observedIssue);
|
|
168
|
+
openedRunnableWorkflowTask = workflowReconciliation.result.opened.some((task) => task.gateAction === "start" && task.runType);
|
|
169
|
+
}
|
|
142
170
|
const newlyReadyDependents = this.dependencyReadinessHandler.reconcile(project.id, issue.id);
|
|
143
171
|
const syncTargets = new Set(shouldSyncLinearStateAfterWebhook(hydrated.triggerEvent)
|
|
144
172
|
? [issue.id, ...newlyReadyDependents]
|
|
@@ -171,36 +199,53 @@ export class WebhookHandler {
|
|
|
171
199
|
const wakeAlreadyQueuedByFollowUpHandler = normalized.triggerEvent === "commentCreated"
|
|
172
200
|
|| normalized.triggerEvent === "commentUpdated"
|
|
173
201
|
|| normalized.triggerEvent === "agentPrompted";
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
202
|
+
await this.wakeDispatcher.withTick(async () => {
|
|
203
|
+
if (result.wakeRunType && !wakeAlreadyQueuedByFollowUpHandler) {
|
|
204
|
+
const queuedRunType = this.enqueuePendingSessionWake(project.id, issue.id);
|
|
205
|
+
this.feed?.publish({
|
|
206
|
+
level: "info",
|
|
207
|
+
kind: "stage",
|
|
208
|
+
issueKey: issue.identifier,
|
|
209
|
+
projectId: project.id,
|
|
210
|
+
stage: queuedRunType ?? result.wakeRunType,
|
|
211
|
+
status: "queued",
|
|
212
|
+
summary: `Queued ${(queuedRunType ?? result.wakeRunType)} workflow`,
|
|
213
|
+
detail: `Triggered by ${hydrated.triggerEvent}.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (openedRunnableWorkflowTask && !wakeAlreadyQueuedByFollowUpHandler) {
|
|
217
|
+
const workflowTaskRunType = this.enqueuePendingSessionWake(project.id, issue.id);
|
|
218
|
+
if (workflowTaskRunType && !result.wakeRunType) {
|
|
219
|
+
this.feed?.publish({
|
|
220
|
+
level: "info",
|
|
221
|
+
kind: "stage",
|
|
222
|
+
issueKey: issue.identifier,
|
|
223
|
+
projectId: project.id,
|
|
224
|
+
stage: workflowTaskRunType,
|
|
225
|
+
status: "queued",
|
|
226
|
+
summary: `Queued ${workflowTaskRunType} workflow`,
|
|
227
|
+
detail: `Derived after ${hydrated.triggerEvent}.`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const dependentIssueId of newlyReadyDependents) {
|
|
232
|
+
// The dependency-readiness handler already dispatched via the
|
|
233
|
+
// wake dispatcher; here we just emit the operator-feed event so
|
|
234
|
+
// the dispatched run shows up in the timeline.
|
|
235
|
+
const dependent = this.db.getTrackedIssue(project.id, dependentIssueId);
|
|
236
|
+
const queuedRunType = this.peekPendingSessionWakeRunType(project.id, dependentIssueId);
|
|
237
|
+
this.feed?.publish({
|
|
238
|
+
level: "info",
|
|
239
|
+
kind: "stage",
|
|
240
|
+
issueKey: dependent?.issueKey,
|
|
241
|
+
projectId: project.id,
|
|
242
|
+
stage: queuedRunType ?? "implementation",
|
|
243
|
+
status: "queued",
|
|
244
|
+
summary: `Queued ${(queuedRunType ?? "implementation")} after blockers resolved`,
|
|
245
|
+
detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
});
|
|
204
249
|
for (const issueId of syncTargets) {
|
|
205
250
|
const syncIssue = this.db.getIssue(project.id, issueId);
|
|
206
251
|
if (!syncIssue) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { emitTelemetry, noopTelemetry } from "../telemetry.js";
|
|
2
|
+
import { reconcileWorkflowTasksForIssue } from "../workflow-task-reconciler.js";
|
|
2
3
|
const WRITER = "dependency-readiness-handler";
|
|
3
4
|
export class DependencyReadinessHandler {
|
|
4
5
|
db;
|
|
@@ -57,6 +58,11 @@ export class DependencyReadinessHandler {
|
|
|
57
58
|
if (!issue.delegatedToPatchRelay || issue.activeRunId !== undefined) {
|
|
58
59
|
continue;
|
|
59
60
|
}
|
|
61
|
+
const workflowReconciliation = reconcileWorkflowTasksForIssue(this.db, issue);
|
|
62
|
+
const hasRunnableWorkflowTask = [
|
|
63
|
+
...workflowReconciliation.result.opened,
|
|
64
|
+
...workflowReconciliation.result.updated,
|
|
65
|
+
].some((task) => task.gateAction === "start" && task.runType);
|
|
60
66
|
const pendingWakeRunType = this.db.workflowWakes.peekIssueWake(projectId, dependent.linearIssueId)?.runType
|
|
61
67
|
?? issue.pendingRunType;
|
|
62
68
|
if (pendingWakeRunType) {
|
|
@@ -72,6 +78,19 @@ export class DependencyReadinessHandler {
|
|
|
72
78
|
newlyReady.push(dependent.linearIssueId);
|
|
73
79
|
continue;
|
|
74
80
|
}
|
|
81
|
+
if (hasRunnableWorkflowTask) {
|
|
82
|
+
const dispatchedRunType = this.wakeDispatcher.dispatchIfWakePending(projectId, dependent.linearIssueId);
|
|
83
|
+
emitTelemetry(this.telemetry, {
|
|
84
|
+
type: "dependency.dependent_unblocked",
|
|
85
|
+
projectId,
|
|
86
|
+
linearIssueId: dependent.linearIssueId,
|
|
87
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
88
|
+
blockerLinearIssueId,
|
|
89
|
+
...(dispatchedRunType ? { dispatchedRunType } : {}),
|
|
90
|
+
});
|
|
91
|
+
newlyReady.push(dependent.linearIssueId);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
75
94
|
if (issue.factoryState !== "delegated" || this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
|
|
76
95
|
continue;
|
|
77
96
|
}
|
|
@@ -86,19 +105,6 @@ export class DependencyReadinessHandler {
|
|
|
86
105
|
},
|
|
87
106
|
});
|
|
88
107
|
}
|
|
89
|
-
const dispatchedRunType = this.wakeDispatcher.recordEventAndDispatch(projectId, dependent.linearIssueId, {
|
|
90
|
-
eventType: "delegated",
|
|
91
|
-
dedupeKey: `delegated:${dependent.linearIssueId}`,
|
|
92
|
-
});
|
|
93
|
-
emitTelemetry(this.telemetry, {
|
|
94
|
-
type: "dependency.dependent_unblocked",
|
|
95
|
-
projectId,
|
|
96
|
-
linearIssueId: dependent.linearIssueId,
|
|
97
|
-
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
98
|
-
blockerLinearIssueId,
|
|
99
|
-
...(dispatchedRunType ? { dispatchedRunType } : {}),
|
|
100
|
-
});
|
|
101
|
-
newlyReady.push(dependent.linearIssueId);
|
|
102
108
|
}
|
|
103
109
|
return newlyReady;
|
|
104
110
|
}
|
|
@@ -141,6 +141,9 @@ export class DesiredStageRecorder {
|
|
|
141
141
|
const wasResolved = isResolvedLinearState(existingIssue?.currentLinearStateType, existingIssue?.currentLinearState);
|
|
142
142
|
const isResolved = isResolvedLinearState(issue.currentLinearStateType, issue.currentLinearState);
|
|
143
143
|
if (workflowPlan.undelegation.factoryState) {
|
|
144
|
+
if (activeRun && releaseReason) {
|
|
145
|
+
this.db.runs.revokeRunLease(activeRun.id, { reason: releaseReason });
|
|
146
|
+
}
|
|
144
147
|
if (activeRun?.threadId && activeRun.turnId) {
|
|
145
148
|
await params.stopActiveRun(activeRun, "STOP: The issue was un-delegated from PatchRelay. Stop working immediately and exit.");
|
|
146
149
|
}
|
|
@@ -205,24 +208,6 @@ export class DesiredStageRecorder {
|
|
|
205
208
|
summary: "Waiting briefly for child issues to settle before orchestration starts",
|
|
206
209
|
});
|
|
207
210
|
}
|
|
208
|
-
else if (!workflowPlan.startupResume.factoryState
|
|
209
|
-
&& !workflowPlan.startupResume.pendingRunType
|
|
210
|
-
&& workflowPlan.desiredStage === "implementation"
|
|
211
|
-
&& params.normalized.triggerEvent !== "commentCreated"
|
|
212
|
-
&& params.normalized.triggerEvent !== "commentUpdated"
|
|
213
|
-
&& params.normalized.triggerEvent !== "agentPrompted") {
|
|
214
|
-
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.project.id, normalizedIssue.id, {
|
|
215
|
-
projectId: params.project.id,
|
|
216
|
-
linearIssueId: normalizedIssue.id,
|
|
217
|
-
eventType: "delegated",
|
|
218
|
-
eventJson: JSON.stringify({
|
|
219
|
-
promptContext: params.normalized.agentSession?.promptContext?.trim()
|
|
220
|
-
?? (issue.issueKey ? `Linear issue ${issue.issueKey} was delegated to PatchRelay.` : undefined),
|
|
221
|
-
promptBody: params.normalized.agentSession?.promptBody?.trim(),
|
|
222
|
-
}),
|
|
223
|
-
dedupeKey: `delegated:${normalizedIssue.id}`,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
211
|
if (previousParentIssueId && previousParentIssueId !== currentParentIssueId) {
|
|
227
212
|
wakeOrchestrationParentsForChildEvent({
|
|
228
213
|
db: this.db,
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { buildFailureContext } from "./idle-reconciliation-helpers.js";
|
|
2
|
+
import { tryParseRunContextValue } from "./run-context.js";
|
|
3
|
+
function parseObservationPayload(observation) {
|
|
4
|
+
if (!observation.payloadJson)
|
|
5
|
+
return undefined;
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(observation.payloadJson);
|
|
8
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
9
|
+
? parsed
|
|
10
|
+
: undefined;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function deriveAuthority(issue, observations) {
|
|
17
|
+
let delegated = issue.delegatedToPatchRelay;
|
|
18
|
+
let epoch = 0;
|
|
19
|
+
let source = "linear";
|
|
20
|
+
let observedAt;
|
|
21
|
+
for (const observation of observations) {
|
|
22
|
+
if (observation.type !== "linear.delegated" && observation.type !== "linear.undelegated" && observation.type !== "operator.authority_changed") {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
epoch += 1;
|
|
26
|
+
source = observation.source === "operator" ? "operator" : "linear";
|
|
27
|
+
observedAt = observation.observedAt;
|
|
28
|
+
const payload = parseObservationPayload(observation);
|
|
29
|
+
if (typeof payload?.delegated === "boolean") {
|
|
30
|
+
delegated = payload.delegated;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
delegated = observation.type !== "linear.undelegated";
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
delegated,
|
|
37
|
+
epoch,
|
|
38
|
+
source,
|
|
39
|
+
...(observedAt ? { observedAt } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function issueStatus(issue, blockerCount) {
|
|
43
|
+
if (issue.factoryState === "done" || issue.prState === "merged")
|
|
44
|
+
return "done";
|
|
45
|
+
if (issue.factoryState === "failed" || issue.factoryState === "escalated")
|
|
46
|
+
return "failed";
|
|
47
|
+
if (issue.activeRunId !== undefined)
|
|
48
|
+
return "running";
|
|
49
|
+
if (!issue.delegatedToPatchRelay || blockerCount > 0 || issue.factoryState === "awaiting_input")
|
|
50
|
+
return "waiting";
|
|
51
|
+
return "idle";
|
|
52
|
+
}
|
|
53
|
+
function issueArtifacts(issue) {
|
|
54
|
+
const artifacts = [];
|
|
55
|
+
if (issue.branchName) {
|
|
56
|
+
artifacts.push({ type: "branch", ref: issue.branchName });
|
|
57
|
+
}
|
|
58
|
+
if (issue.prNumber !== undefined) {
|
|
59
|
+
artifacts.push({
|
|
60
|
+
type: "pr",
|
|
61
|
+
ref: String(issue.prNumber),
|
|
62
|
+
...(issue.prState ? { state: issue.prState } : {}),
|
|
63
|
+
metadata: {
|
|
64
|
+
...(issue.prUrl ? { url: issue.prUrl } : {}),
|
|
65
|
+
...(issue.prHeadSha ? { headSha: issue.prHeadSha } : {}),
|
|
66
|
+
...(issue.prReviewState ? { reviewState: issue.prReviewState } : {}),
|
|
67
|
+
...(issue.prCheckStatus ? { checkStatus: issue.prCheckStatus } : {}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (issue.threadId) {
|
|
72
|
+
artifacts.push({ type: "codex_thread", ref: issue.threadId });
|
|
73
|
+
}
|
|
74
|
+
if (issue.agentSessionId) {
|
|
75
|
+
artifacts.push({ type: "linear_session", ref: issue.agentSessionId });
|
|
76
|
+
}
|
|
77
|
+
return artifacts;
|
|
78
|
+
}
|
|
79
|
+
function parseCiSnapshotContext(raw) {
|
|
80
|
+
const payload = parseObjectJson(raw);
|
|
81
|
+
if (!payload)
|
|
82
|
+
return undefined;
|
|
83
|
+
return tryParseRunContextValue({ ciSnapshot: payload })?.ciSnapshot;
|
|
84
|
+
}
|
|
85
|
+
function latestRequestedChangesContext(observations, blockingHeadSha) {
|
|
86
|
+
for (const observation of [...observations].reverse()) {
|
|
87
|
+
if (observation.source !== "github" || observation.type !== "github.review_changes_requested") {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const payload = parseObservationPayload(observation);
|
|
91
|
+
const rawContext = payload?.requestedChangesContext;
|
|
92
|
+
const context = rawContext && typeof rawContext === "object" && !Array.isArray(rawContext)
|
|
93
|
+
? tryParseRunContextValue(rawContext)
|
|
94
|
+
: tryParseRunContextValue(payload ?? {});
|
|
95
|
+
if (!context)
|
|
96
|
+
continue;
|
|
97
|
+
if (blockingHeadSha
|
|
98
|
+
&& context.requestedChangesHeadSha
|
|
99
|
+
&& context.requestedChangesHeadSha !== blockingHeadSha) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
return context;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
function latestDelegationContext(observations) {
|
|
107
|
+
for (const observation of [...observations].reverse()) {
|
|
108
|
+
if (observation.source !== "linear" || observation.type !== "linear.delegated") {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const payload = parseObservationPayload(observation);
|
|
112
|
+
const context = tryParseRunContextValue({
|
|
113
|
+
...(typeof payload?.promptContext === "string" ? { promptContext: payload.promptContext } : {}),
|
|
114
|
+
...(typeof payload?.promptBody === "string" ? { promptBody: payload.promptBody } : {}),
|
|
115
|
+
});
|
|
116
|
+
if (context && Object.keys(context).length > 0) {
|
|
117
|
+
return context;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
function parseObjectJson(raw) {
|
|
123
|
+
if (!raw)
|
|
124
|
+
return undefined;
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(raw);
|
|
127
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
128
|
+
? parsed
|
|
129
|
+
: undefined;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export function projectWorkflowSnapshot(input) {
|
|
136
|
+
const observations = input.observations ?? [];
|
|
137
|
+
const blockerCount = input.blockerCount ?? 0;
|
|
138
|
+
const childCount = input.childCount ?? 0;
|
|
139
|
+
const openChildCount = input.openChildCount ?? childCount;
|
|
140
|
+
const authority = deriveAuthority(input.issue, observations);
|
|
141
|
+
const failureContext = buildFailureContext(input.issue);
|
|
142
|
+
const ciSnapshot = parseCiSnapshotContext(input.issue.lastGitHubCiSnapshotJson);
|
|
143
|
+
const requestedChangesContext = latestRequestedChangesContext(observations, input.issue.lastBlockingReviewHeadSha);
|
|
144
|
+
const delegationContext = latestDelegationContext(observations);
|
|
145
|
+
const baseSnapshot = {
|
|
146
|
+
id: `${input.issue.projectId}:${input.issue.linearIssueId}`,
|
|
147
|
+
projectId: input.issue.projectId,
|
|
148
|
+
subjectId: input.issue.linearIssueId,
|
|
149
|
+
status: input.activeRun ? "running" : issueStatus({ ...input.issue, delegatedToPatchRelay: authority.delegated }, blockerCount),
|
|
150
|
+
authority,
|
|
151
|
+
context: {
|
|
152
|
+
...(input.issue.issueKey ? { issueKey: input.issue.issueKey } : {}),
|
|
153
|
+
...(input.issue.title ? { title: input.issue.title } : {}),
|
|
154
|
+
factoryState: input.issue.factoryState,
|
|
155
|
+
...(input.issue.lastBlockingReviewHeadSha ? { lastBlockingReviewHeadSha: input.issue.lastBlockingReviewHeadSha } : {}),
|
|
156
|
+
...(input.issue.lastGitHubFailureSource ? { lastGitHubFailureSource: input.issue.lastGitHubFailureSource } : {}),
|
|
157
|
+
...(input.issue.lastGitHubFailureHeadSha ? { lastGitHubFailureHeadSha: input.issue.lastGitHubFailureHeadSha } : {}),
|
|
158
|
+
...(input.issue.lastGitHubFailureSignature ? { lastGitHubFailureSignature: input.issue.lastGitHubFailureSignature } : {}),
|
|
159
|
+
...(input.issue.lastAttemptedFailureHeadSha ? { lastAttemptedFailureHeadSha: input.issue.lastAttemptedFailureHeadSha } : {}),
|
|
160
|
+
...(input.issue.lastAttemptedFailureSignature ? { lastAttemptedFailureSignature: input.issue.lastAttemptedFailureSignature } : {}),
|
|
161
|
+
...(failureContext ? { failureContext } : {}),
|
|
162
|
+
...(ciSnapshot ? { ciSnapshot } : {}),
|
|
163
|
+
...(requestedChangesContext ? { requestedChangesContext } : {}),
|
|
164
|
+
...(delegationContext ? { delegationContext } : {}),
|
|
165
|
+
},
|
|
166
|
+
...(input.activeRun
|
|
167
|
+
? { activeRun: input.activeRun }
|
|
168
|
+
: input.issue.activeRunId !== undefined
|
|
169
|
+
? {
|
|
170
|
+
activeRun: {
|
|
171
|
+
id: input.issue.activeRunId,
|
|
172
|
+
runType: input.issue.pendingRunType ?? "implementation",
|
|
173
|
+
authorityEpoch: authority.epoch,
|
|
174
|
+
status: "running",
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
: {}),
|
|
178
|
+
artifacts: issueArtifacts(input.issue),
|
|
179
|
+
blockerCount,
|
|
180
|
+
childCount,
|
|
181
|
+
openChildCount,
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
...baseSnapshot,
|
|
185
|
+
openTasks: deriveWorkflowTasks(baseSnapshot),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export function deriveWorkflowTasks(snapshot) {
|
|
189
|
+
const tasks = [];
|
|
190
|
+
if (!snapshot.authority.delegated) {
|
|
191
|
+
return [{
|
|
192
|
+
id: "wait:authority",
|
|
193
|
+
type: "wait",
|
|
194
|
+
reason: "Workflow is waiting for delegated authority",
|
|
195
|
+
}];
|
|
196
|
+
}
|
|
197
|
+
if (snapshot.status === "done") {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
if (snapshot.status === "failed") {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
if (snapshot.activeRun) {
|
|
204
|
+
return [{
|
|
205
|
+
id: `wait:active-run:${snapshot.activeRun.id}`,
|
|
206
|
+
type: "wait",
|
|
207
|
+
reason: "A run is already active",
|
|
208
|
+
}];
|
|
209
|
+
}
|
|
210
|
+
const issue = snapshot.context;
|
|
211
|
+
const prState = snapshot.artifacts.find((artifact) => artifact.type === "pr")?.state;
|
|
212
|
+
const prHeadSha = snapshot.artifacts.find((artifact) => artifact.type === "pr")?.metadata?.headSha;
|
|
213
|
+
const prReviewState = snapshot.artifacts.find((artifact) => artifact.type === "pr")?.metadata?.reviewState;
|
|
214
|
+
if (issue.factoryState === "awaiting_input") {
|
|
215
|
+
return [{
|
|
216
|
+
id: "wait:input",
|
|
217
|
+
type: "wait",
|
|
218
|
+
reason: "Workflow is waiting for human input",
|
|
219
|
+
}];
|
|
220
|
+
}
|
|
221
|
+
if (snapshot.blockerCount > 0 && prState !== "open") {
|
|
222
|
+
return [{
|
|
223
|
+
id: "wait:blockers",
|
|
224
|
+
type: "wait",
|
|
225
|
+
reason: "Workflow is blocked by unresolved Linear dependencies",
|
|
226
|
+
requirements: { blockerCount: snapshot.blockerCount },
|
|
227
|
+
}];
|
|
228
|
+
}
|
|
229
|
+
if (snapshot.childCount > 0 && prState !== "open") {
|
|
230
|
+
if (snapshot.openChildCount > 0) {
|
|
231
|
+
return [{
|
|
232
|
+
id: "wait:children",
|
|
233
|
+
type: "wait",
|
|
234
|
+
reason: "Workflow is waiting for child workflows to complete",
|
|
235
|
+
requirements: {
|
|
236
|
+
childCount: snapshot.childCount,
|
|
237
|
+
openChildCount: snapshot.openChildCount,
|
|
238
|
+
},
|
|
239
|
+
}];
|
|
240
|
+
}
|
|
241
|
+
return [{
|
|
242
|
+
id: "verify:children_complete",
|
|
243
|
+
type: "verify",
|
|
244
|
+
reason: "Child workflows are complete; parent objective needs verification",
|
|
245
|
+
requirements: { childCount: snapshot.childCount },
|
|
246
|
+
}];
|
|
247
|
+
}
|
|
248
|
+
if (prState === "open" && prReviewState === "changes_requested") {
|
|
249
|
+
tasks.push({
|
|
250
|
+
id: "run:review_fix",
|
|
251
|
+
type: "run",
|
|
252
|
+
runType: "review_fix",
|
|
253
|
+
reason: "PR has requested changes",
|
|
254
|
+
requirements: {
|
|
255
|
+
...issue.requestedChangesContext,
|
|
256
|
+
prState,
|
|
257
|
+
blockingHeadSha: issue.lastBlockingReviewHeadSha ?? prHeadSha,
|
|
258
|
+
requestedChangesHeadSha: issue.requestedChangesContext?.requestedChangesHeadSha
|
|
259
|
+
?? issue.lastBlockingReviewHeadSha
|
|
260
|
+
?? prHeadSha,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
return tasks;
|
|
264
|
+
}
|
|
265
|
+
if (prState === "open" && issue.lastGitHubFailureSource === "queue_eviction") {
|
|
266
|
+
tasks.push({
|
|
267
|
+
id: "run:queue_repair",
|
|
268
|
+
type: "run",
|
|
269
|
+
runType: "queue_repair",
|
|
270
|
+
reason: "Merge queue eviction requires repair",
|
|
271
|
+
requirements: {
|
|
272
|
+
...issue.failureContext,
|
|
273
|
+
failureSignature: issue.lastGitHubFailureSignature,
|
|
274
|
+
failureHeadSha: issue.lastGitHubFailureHeadSha ?? prHeadSha,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
return tasks;
|
|
278
|
+
}
|
|
279
|
+
const branchFailureMatchesCurrentHead = issue.lastGitHubFailureSource === "branch_ci"
|
|
280
|
+
&& typeof issue.lastGitHubFailureSignature === "string"
|
|
281
|
+
&& typeof issue.lastGitHubFailureHeadSha === "string"
|
|
282
|
+
&& typeof prHeadSha === "string"
|
|
283
|
+
&& issue.lastGitHubFailureHeadSha === prHeadSha;
|
|
284
|
+
const branchFailureAlreadyAttempted = branchFailureMatchesCurrentHead
|
|
285
|
+
&& issue.lastAttemptedFailureHeadSha === issue.lastGitHubFailureHeadSha
|
|
286
|
+
&& issue.lastAttemptedFailureSignature === issue.lastGitHubFailureSignature;
|
|
287
|
+
if (prState === "open" && branchFailureMatchesCurrentHead && !branchFailureAlreadyAttempted) {
|
|
288
|
+
tasks.push({
|
|
289
|
+
id: "run:ci_repair",
|
|
290
|
+
type: "run",
|
|
291
|
+
runType: "ci_repair",
|
|
292
|
+
reason: "Settled branch CI failure requires repair",
|
|
293
|
+
requirements: {
|
|
294
|
+
...issue.failureContext,
|
|
295
|
+
failureSignature: issue.lastGitHubFailureSignature,
|
|
296
|
+
failureHeadSha: issue.lastGitHubFailureHeadSha ?? prHeadSha,
|
|
297
|
+
...(issue.ciSnapshot ? { ciSnapshot: issue.ciSnapshot } : {}),
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
return tasks;
|
|
301
|
+
}
|
|
302
|
+
if (!snapshot.artifacts.some((artifact) => artifact.type === "pr") && issue.factoryState === "delegated") {
|
|
303
|
+
tasks.push({
|
|
304
|
+
id: "run:implementation",
|
|
305
|
+
type: "run",
|
|
306
|
+
runType: "implementation",
|
|
307
|
+
reason: "Delegated workflow has no PR artifact yet",
|
|
308
|
+
requirements: {
|
|
309
|
+
...issue.delegationContext,
|
|
310
|
+
blockerCount: snapshot.blockerCount,
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
else if (!snapshot.artifacts.some((artifact) => artifact.type === "pr")) {
|
|
315
|
+
tasks.push({
|
|
316
|
+
id: `wait:${issue.factoryState}`,
|
|
317
|
+
type: "wait",
|
|
318
|
+
reason: `Workflow is waiting in ${issue.factoryState}`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return tasks;
|
|
322
|
+
}
|
|
323
|
+
export function evaluateTaskStart(snapshot, task) {
|
|
324
|
+
if (!snapshot.authority.delegated) {
|
|
325
|
+
return { action: "wait", reason: "authority_not_delegated" };
|
|
326
|
+
}
|
|
327
|
+
if (snapshot.activeRun) {
|
|
328
|
+
return { action: "wait", reason: "active_run_present" };
|
|
329
|
+
}
|
|
330
|
+
if (task.type !== "run") {
|
|
331
|
+
return { action: "start" };
|
|
332
|
+
}
|
|
333
|
+
if (task.runType === "implementation" && snapshot.blockerCount > 0) {
|
|
334
|
+
return { action: "wait", reason: "blocked" };
|
|
335
|
+
}
|
|
336
|
+
if (task.runType === "review_fix" && typeof task.requirements?.blockingHeadSha !== "string") {
|
|
337
|
+
return {
|
|
338
|
+
action: "ask",
|
|
339
|
+
reason: "missing_blocking_review_head",
|
|
340
|
+
question: "PatchRelay cannot verify the requested-changes repair without a blocking review head SHA.",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if ((task.runType === "ci_repair" || task.runType === "queue_repair") && typeof task.requirements?.failureHeadSha !== "string") {
|
|
344
|
+
return { action: "wait", reason: "missing_failure_head" };
|
|
345
|
+
}
|
|
346
|
+
return { action: "start" };
|
|
347
|
+
}
|
|
348
|
+
export function evaluateTaskCompletion(snapshot, task) {
|
|
349
|
+
if (!snapshot.authority.delegated) {
|
|
350
|
+
return { action: "wait", reason: "authority_revoked" };
|
|
351
|
+
}
|
|
352
|
+
const pr = snapshot.artifacts.find((artifact) => artifact.type === "pr");
|
|
353
|
+
if (task.runType === "implementation" && (!pr || pr.state !== "open")) {
|
|
354
|
+
return { action: "escalate", reason: "implementation_completed_without_open_pr" };
|
|
355
|
+
}
|
|
356
|
+
if (task.runType === "review_fix") {
|
|
357
|
+
const blockingHeadSha = task.requirements?.blockingHeadSha;
|
|
358
|
+
const currentHeadSha = pr?.metadata?.headSha;
|
|
359
|
+
if (typeof blockingHeadSha !== "string") {
|
|
360
|
+
return { action: "ask", reason: "missing_blocking_review_head", question: "PatchRelay cannot verify the requested-changes repair without the original head SHA." };
|
|
361
|
+
}
|
|
362
|
+
if (currentHeadSha === blockingHeadSha) {
|
|
363
|
+
return { action: "escalate", reason: "same_head_review_handoff_blocked" };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (task.runType === "ci_repair" || task.runType === "queue_repair") {
|
|
367
|
+
const failureHeadSha = task.requirements?.failureHeadSha;
|
|
368
|
+
const currentHeadSha = pr?.metadata?.headSha;
|
|
369
|
+
if (typeof failureHeadSha !== "string") {
|
|
370
|
+
return {
|
|
371
|
+
action: "ask",
|
|
372
|
+
reason: "missing_failure_head",
|
|
373
|
+
question: "PatchRelay cannot verify the repair without the failing PR head SHA.",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (typeof currentHeadSha !== "string") {
|
|
377
|
+
return { action: "escalate", reason: "repair_completed_without_pr_head" };
|
|
378
|
+
}
|
|
379
|
+
if (currentHeadSha === failureHeadSha) {
|
|
380
|
+
return { action: "escalate", reason: "same_head_repair_handoff_blocked" };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { action: "start" };
|
|
384
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { evaluateTaskStart, projectWorkflowSnapshot, } from "./workflow-runtime.js";
|
|
2
|
+
function isActiveRun(run) {
|
|
3
|
+
return run.status === "queued" || run.status === "running";
|
|
4
|
+
}
|
|
5
|
+
function resolveActiveRunSnapshot(db, issue, options) {
|
|
6
|
+
const pinnedRun = issue.activeRunId !== undefined ? db.runs.getRunById(issue.activeRunId) : undefined;
|
|
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);
|
|
20
|
+
if (!run)
|
|
21
|
+
return undefined;
|
|
22
|
+
return {
|
|
23
|
+
id: run.id,
|
|
24
|
+
runType: run.runType,
|
|
25
|
+
authorityEpoch: run.authorityEpoch,
|
|
26
|
+
status: run.status,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function readinessForTask(snapshot, task) {
|
|
30
|
+
if (task.type === "wait") {
|
|
31
|
+
return { action: "wait", reason: task.reason };
|
|
32
|
+
}
|
|
33
|
+
if (task.type === "ask") {
|
|
34
|
+
return {
|
|
35
|
+
action: "ask",
|
|
36
|
+
reason: task.reason,
|
|
37
|
+
question: typeof task.requirements?.question === "string" ? task.requirements.question : task.reason,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (task.type === "escalate") {
|
|
41
|
+
return { action: "escalate", reason: task.reason };
|
|
42
|
+
}
|
|
43
|
+
return evaluateTaskStart(snapshot, task);
|
|
44
|
+
}
|
|
45
|
+
export function buildWorkflowSnapshotForIssue(db, issue, options) {
|
|
46
|
+
const activeRun = resolveActiveRunSnapshot(db, issue, options);
|
|
47
|
+
return projectWorkflowSnapshot({
|
|
48
|
+
issue,
|
|
49
|
+
observations: db.workflowObservations.listObservations(issue.projectId, issue.linearIssueId),
|
|
50
|
+
blockerCount: db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId),
|
|
51
|
+
childCount: db.issues.listCanonicalChildIssues(issue.projectId, issue.linearIssueId).length,
|
|
52
|
+
openChildCount: db.issues.countOpenChildIssues(issue.projectId, issue.linearIssueId),
|
|
53
|
+
...(activeRun ? { activeRun } : {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function reconcileWorkflowTasksForIssue(db, issue, options) {
|
|
57
|
+
const snapshot = buildWorkflowSnapshotForIssue(db, issue, options);
|
|
58
|
+
const result = db.workflowTasks.reconcileTasks({
|
|
59
|
+
projectId: issue.projectId,
|
|
60
|
+
subjectId: issue.linearIssueId,
|
|
61
|
+
tasks: snapshot.openTasks.map((task) => {
|
|
62
|
+
const decision = readinessForTask(snapshot, task);
|
|
63
|
+
return {
|
|
64
|
+
task,
|
|
65
|
+
authorityEpoch: snapshot.authority.epoch,
|
|
66
|
+
gateAction: decision.action,
|
|
67
|
+
...("reason" in decision ? { gateReason: decision.reason } : {}),
|
|
68
|
+
};
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
return { snapshot, result };
|
|
72
|
+
}
|