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.
@@ -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
- if (result.wakeRunType && !wakeAlreadyQueuedByFollowUpHandler) {
175
- const queuedRunType = this.enqueuePendingSessionWake(project.id, issue.id);
176
- this.feed?.publish({
177
- level: "info",
178
- kind: "stage",
179
- issueKey: issue.identifier,
180
- projectId: project.id,
181
- stage: queuedRunType ?? result.wakeRunType,
182
- status: "queued",
183
- summary: `Queued ${(queuedRunType ?? result.wakeRunType)} workflow`,
184
- detail: `Triggered by ${hydrated.triggerEvent}.`,
185
- });
186
- }
187
- for (const dependentIssueId of newlyReadyDependents) {
188
- // The dependency-readiness handler already dispatched via the
189
- // wake dispatcher; here we just emit the operator-feed event so
190
- // the dispatched run shows up in the timeline.
191
- const dependent = this.db.getTrackedIssue(project.id, dependentIssueId);
192
- const queuedRunType = this.peekPendingSessionWakeRunType(project.id, dependentIssueId);
193
- this.feed?.publish({
194
- level: "info",
195
- kind: "stage",
196
- issueKey: dependent?.issueKey,
197
- projectId: project.id,
198
- stage: queuedRunType ?? "implementation",
199
- status: "queued",
200
- summary: `Queued ${(queuedRunType ?? "implementation")} after blockers resolved`,
201
- detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
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
+ }