patchrelay 0.82.0 → 0.83.0
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 +60 -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/run-context.js +6 -6
- package/dist/run-finalizer.js +102 -22
- package/dist/run-launcher.js +1 -0
- package/dist/run-orchestrator.js +7 -0
- package/dist/run-wake-planner.js +58 -8
- package/dist/service-startup-recovery.js +51 -61
- package/dist/tracked-issue-list-query.js +5 -1
- package/dist/wake-dispatcher.js +59 -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 +381 -0
- package/dist/workflow-task-reconciler.js +64 -0
- package/package.json +1 -1
- package/dist/github-webhook-reactive-run.js +0 -309
package/dist/wake-dispatcher.js
CHANGED
|
@@ -32,6 +32,47 @@ export class WakeDispatcher {
|
|
|
32
32
|
this.feed = feed;
|
|
33
33
|
this.telemetry = telemetry;
|
|
34
34
|
}
|
|
35
|
+
peekRunnableWorkflowTask(projectId, linearIssueId) {
|
|
36
|
+
return this.db.workflowTasks
|
|
37
|
+
.listOpenRunnableTasks(projectId)
|
|
38
|
+
.find((task) => task.subjectId === linearIssueId && task.runType !== undefined);
|
|
39
|
+
}
|
|
40
|
+
resolveDispatchableWake(projectId, linearIssueId, issue) {
|
|
41
|
+
const sessionWake = this.db.issueSessions.peekIssueSessionWake(projectId, linearIssueId);
|
|
42
|
+
if (sessionWake) {
|
|
43
|
+
return {
|
|
44
|
+
runType: sessionWake.runType,
|
|
45
|
+
...(sessionWake.wakeReason ? { wakeReason: sessionWake.wakeReason } : {}),
|
|
46
|
+
eventIds: sessionWake.eventIds,
|
|
47
|
+
source: "session_event",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const workflowTask = this.peekRunnableWorkflowTask(projectId, linearIssueId);
|
|
51
|
+
if (workflowTask?.runType) {
|
|
52
|
+
return {
|
|
53
|
+
runType: workflowTask.runType,
|
|
54
|
+
wakeReason: workflowTask.taskId,
|
|
55
|
+
eventIds: [],
|
|
56
|
+
source: "workflow_task",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (issue.pendingRunType) {
|
|
60
|
+
return {
|
|
61
|
+
runType: issue.pendingRunType,
|
|
62
|
+
eventIds: [],
|
|
63
|
+
source: "legacy_pending_run_type",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const implicitWake = this.db.workflowWakes.peekIssueWake(projectId, linearIssueId);
|
|
67
|
+
if (!implicitWake)
|
|
68
|
+
return undefined;
|
|
69
|
+
return {
|
|
70
|
+
runType: implicitWake.runType,
|
|
71
|
+
...(implicitWake.wakeReason ? { wakeReason: implicitWake.wakeReason } : {}),
|
|
72
|
+
eventIds: implicitWake.eventIds,
|
|
73
|
+
source: "implicit",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
35
76
|
// Scope the next enqueue calls inside `fn` to a single dedupe Set.
|
|
36
77
|
// Nested ticks reuse the outermost Set so deeply nested helpers do
|
|
37
78
|
// not silently lose dedupe.
|
|
@@ -144,12 +185,8 @@ export class WakeDispatcher {
|
|
|
144
185
|
}
|
|
145
186
|
return undefined;
|
|
146
187
|
}
|
|
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) {
|
|
188
|
+
const dispatchable = this.resolveDispatchableWake(projectId, linearIssueId, issue);
|
|
189
|
+
if (!dispatchable) {
|
|
153
190
|
emitTelemetry(this.telemetry, {
|
|
154
191
|
type: "wake.suppressed",
|
|
155
192
|
projectId,
|
|
@@ -175,10 +212,10 @@ export class WakeDispatcher {
|
|
|
175
212
|
projectId,
|
|
176
213
|
linearIssueId,
|
|
177
214
|
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
178
|
-
runType,
|
|
179
|
-
...(
|
|
180
|
-
|
|
181
|
-
source:
|
|
215
|
+
runType: dispatchable.runType,
|
|
216
|
+
...(dispatchable.wakeReason ? { wakeReason: dispatchable.wakeReason } : {}),
|
|
217
|
+
eventIds: dispatchable.eventIds,
|
|
218
|
+
source: dispatchable.source,
|
|
182
219
|
});
|
|
183
220
|
const tick = options?.enqueuedThisTick ?? this.currentTick;
|
|
184
221
|
const key = `${projectId}:${linearIssueId}`;
|
|
@@ -188,18 +225,18 @@ export class WakeDispatcher {
|
|
|
188
225
|
projectId,
|
|
189
226
|
linearIssueId,
|
|
190
227
|
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
191
|
-
runType,
|
|
192
|
-
...(
|
|
193
|
-
|
|
228
|
+
runType: dispatchable.runType,
|
|
229
|
+
...(dispatchable.wakeReason ? { wakeReason: dispatchable.wakeReason } : {}),
|
|
230
|
+
eventIds: dispatchable.eventIds,
|
|
194
231
|
});
|
|
195
232
|
emitTelemetry(this.telemetry, {
|
|
196
233
|
type: "queue.deduped",
|
|
197
234
|
projectId,
|
|
198
235
|
linearIssueId,
|
|
199
236
|
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
200
|
-
runType,
|
|
237
|
+
runType: dispatchable.runType,
|
|
201
238
|
});
|
|
202
|
-
return runType;
|
|
239
|
+
return dispatchable.runType;
|
|
203
240
|
}
|
|
204
241
|
tick?.add(key);
|
|
205
242
|
this.enqueueIssue(projectId, linearIssueId);
|
|
@@ -208,18 +245,18 @@ export class WakeDispatcher {
|
|
|
208
245
|
projectId,
|
|
209
246
|
linearIssueId,
|
|
210
247
|
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
211
|
-
runType,
|
|
212
|
-
...(
|
|
213
|
-
|
|
248
|
+
runType: dispatchable.runType,
|
|
249
|
+
...(dispatchable.wakeReason ? { wakeReason: dispatchable.wakeReason } : {}),
|
|
250
|
+
eventIds: dispatchable.eventIds,
|
|
214
251
|
});
|
|
215
252
|
emitTelemetry(this.telemetry, {
|
|
216
253
|
type: "queue.enqueued",
|
|
217
254
|
projectId,
|
|
218
255
|
linearIssueId,
|
|
219
256
|
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
220
|
-
runType,
|
|
257
|
+
runType: dispatchable.runType,
|
|
221
258
|
});
|
|
222
|
-
return runType;
|
|
259
|
+
return dispatchable.runType;
|
|
223
260
|
}
|
|
224
261
|
// Release the lease for a finished run, then drain any wake that
|
|
225
262
|
// landed during the run. The single owner of "run is over, what's
|
|
@@ -241,7 +278,8 @@ export class WakeDispatcher {
|
|
|
241
278
|
runId: params.run.id,
|
|
242
279
|
runType: params.run.runType,
|
|
243
280
|
});
|
|
244
|
-
const
|
|
281
|
+
const issue = this.db.issues.getIssue(params.run.projectId, params.run.linearIssueId);
|
|
282
|
+
const wake = issue ? this.resolveDispatchableWake(params.run.projectId, params.run.linearIssueId, issue) : undefined;
|
|
245
283
|
if (!wake) {
|
|
246
284
|
emitTelemetry(this.telemetry, {
|
|
247
285
|
type: "wake.suppressed",
|
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,
|