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.
@@ -13,6 +13,7 @@ import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
13
13
  import { fetchPullRequestSnapshot } from "./reconcile-pr-fetch.js";
14
14
  import { buildPrStateUpdates } from "./reconcile-pr-state-updates.js";
15
15
  import { buildRepairWakeDedupeKey, buildRequestedChangesWakeIdentity, reactiveWakeEventType } from "./reactive-wake-keys.js";
16
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
16
17
  import { execCommand } from "./utils.js";
17
18
  import { LinearIssueProjectionService } from "./linear-issue-projection.js";
18
19
  import { TerminalWakeReconciler } from "./terminal-wake-reconciler.js";
@@ -561,6 +562,29 @@ export class IdleIssueReconciler {
561
562
  headAdvanced,
562
563
  ...(prState === "closed" ? { closedPrDisposition: resolveClosedPrDisposition(issue) } : {}),
563
564
  };
565
+ this.db.workflowObservations.appendObservation({
566
+ projectId: issue.projectId,
567
+ subjectId: issue.linearIssueId,
568
+ source: "github",
569
+ type: "github.pr_reconciled",
570
+ payloadJson: JSON.stringify({
571
+ ...observed,
572
+ repoFullName: project.github.repoFullName,
573
+ mergeable: pr.mergeable,
574
+ mergeStateStatus: pr.mergeStateStatus,
575
+ }),
576
+ dedupeKey: [
577
+ "pr_reconciled",
578
+ project.github.repoFullName,
579
+ prNumber,
580
+ prState,
581
+ pr.headRefOid ?? "",
582
+ pr.reviewDecision ?? "",
583
+ gateCheckStatus ?? "",
584
+ pr.mergeable ?? "",
585
+ pr.mergeStateStatus ?? "",
586
+ ].join(":"),
587
+ });
564
588
  const currentFacts = (record) => ({
565
589
  factoryState: record.factoryState,
566
590
  prReviewState: record.prReviewState,
@@ -720,18 +744,21 @@ export class IdleIssueReconciler {
720
744
  return;
721
745
  }
722
746
  if (issue.delegatedToPatchRelay
723
- && reactiveIntent?.runType === "review_fix"
724
- && this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId) === undefined) {
747
+ && reactiveIntent?.runType === "review_fix") {
725
748
  this.logger.info({
726
749
  issueKey: issue.issueKey,
727
750
  prNumber: issue.prNumber,
728
751
  from: issue.factoryState,
729
752
  runType: reactiveIntent.runType,
730
753
  }, "Reconciliation: re-queued requested-changes follow-up from GitHub truth");
731
- this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, {
732
- pendingRunType: reactiveIntent.runType,
733
- ...(mayClearFailureProvenance(issue, provenanceEvidence) ? { clearFailureProvenance: true } : {}),
734
- });
754
+ this.advanceIdleIssue(issue, reactiveIntent.compatibilityFactoryState, mayClearFailureProvenance(issue, provenanceEvidence)
755
+ ? { clearFailureProvenance: true }
756
+ : undefined);
757
+ const currentIssue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId);
758
+ if (currentIssue) {
759
+ reconcileWorkflowTasksForIssue(this.db, currentIssue);
760
+ this.wakeDispatcher.dispatchIfWakePending(currentIssue.projectId, currentIssue.linearIssueId);
761
+ }
735
762
  this.feed?.publish({
736
763
  level: "warn",
737
764
  kind: "github",
@@ -127,7 +127,8 @@ export class IssueOverviewQuery {
127
127
  delegatedToPatchRelay: issueRecord?.delegatedToPatchRelay,
128
128
  ...(activeRun ? { activeRunId: activeRun.id } : {}),
129
129
  blockedByCount: unresolvedBlockedBy.length,
130
- hasPendingWake: this.db.workflowWakes.peekIssueWake(session.projectId, session.linearIssueId) !== undefined,
130
+ hasPendingWake: this.db.workflowWakes.peekIssueWake(session.projectId, session.linearIssueId) !== undefined
131
+ || this.db.workflowTasks.listOpenRunnableTasks(session.projectId).some((task) => task.subjectId === session.linearIssueId),
131
132
  hasLegacyPendingRun: issueRecord?.pendingRunType !== undefined,
132
133
  orchestrationSettleUntil: issueRecord?.orchestrationSettleUntil,
133
134
  ...(session.prNumber !== undefined ? { prNumber: session.prNumber } : {}),
@@ -46,6 +46,43 @@ export async function refreshIssueFromLinear(params) {
46
46
  }
47
47
  }
48
48
  export function upsertLinearIssueProjection(db, projectId, liveIssue) {
49
+ db.workflowObservations.appendObservation({
50
+ projectId,
51
+ subjectId: liveIssue.id,
52
+ source: "linear",
53
+ type: "linear.issue_reconciled",
54
+ payloadJson: JSON.stringify({
55
+ issueId: liveIssue.id,
56
+ issueKey: liveIssue.identifier,
57
+ title: liveIssue.title,
58
+ stateName: liveIssue.stateName,
59
+ stateType: liveIssue.stateType,
60
+ delegateId: liveIssue.delegateId,
61
+ parentId: liveIssue.parentId,
62
+ blockedBy: liveIssue.blockedBy.map((blocker) => ({
63
+ id: blocker.id,
64
+ identifier: blocker.identifier,
65
+ title: blocker.title,
66
+ stateName: blocker.stateName,
67
+ stateType: blocker.stateType,
68
+ })),
69
+ }),
70
+ dedupeKey: [
71
+ "issue_reconciled",
72
+ liveIssue.id,
73
+ liveIssue.identifier ?? "",
74
+ liveIssue.stateName ?? "",
75
+ liveIssue.stateType ?? "",
76
+ liveIssue.delegateId ?? "",
77
+ liveIssue.parentId ?? "",
78
+ ...liveIssue.blockedBy.map((blocker) => [
79
+ blocker.id,
80
+ blocker.identifier ?? "",
81
+ blocker.stateName ?? "",
82
+ blocker.stateType ?? "",
83
+ ].join("/")),
84
+ ].join(":"),
85
+ });
49
86
  replaceIssueDependenciesFromLinearIssue(db, projectId, liveIssue);
50
87
  db.issues.replaceIssueParentLink({
51
88
  projectId,
@@ -33,8 +33,8 @@ const followUpEntryShape = {
33
33
  author: z.string().optional(),
34
34
  };
35
35
  /** Inline review comment captured from GitHub. Produced by
36
- * github-webhook-reactive-run.ts fetchReviewCommentsForEvent and
37
- * reactive-run-policy.ts hydrateRequestedChangesContext (remote-pr-review.ts);
36
+ * github-review-context.ts and reactive-run-policy.ts
37
+ * hydrateRequestedChangesContext (remote-pr-review.ts);
38
38
  * consumed by prompting/patchrelay.ts readReviewFixComments and
39
39
  * run-orchestrator.ts (review round activity comment count). */
40
40
  const reviewCommentShape = {
@@ -74,8 +74,8 @@ const ciSnapshotCheckShape = {
74
74
  summary: z.string().optional(),
75
75
  };
76
76
  /** Settled CI snapshot. Produced by github-failure-context.ts
77
- * buildCiSnapshotFromChecks (attached to settled_red_ci payloads by
78
- * github-webhook-reactive-run.ts and to implicit ci_repair wakes by
77
+ * buildCiSnapshotFromChecks (attached to workflow task payloads by
78
+ * workflow-runtime.ts and to implicit ci_repair wakes by
79
79
  * workflow-wake-resolver.ts); consumed by prompting/patchrelay.ts
80
80
  * buildCiRepairContext. */
81
81
  const ciSnapshotShape = {
@@ -196,7 +196,7 @@ const runContextShape = {
196
196
  // ── Requested-changes / review fix ────────────────────────────────
197
197
  /** Coalescing identity for review_changes_requested wakes. Produced by
198
198
  * buildRequestedChangesWakeIdentity callers (run-wake-planner.ts,
199
- * github-webhook-reactive-run.ts, operator-retry-event.ts,
199
+ * github-review-context.ts, operator-retry-event.ts,
200
200
  * idle-reconciliation.ts); consumed by reactive-wake-keys.ts
201
201
  * readRequestedChangesCoalesceKey for event coalescing. */
202
202
  requestedChangesCoalesceKey: z.string().optional(),
@@ -211,7 +211,7 @@ const runContextShape = {
211
211
  * review_changes_requested payloads from operator-retry-event.ts and
212
212
  * deriveSessionWakePlan branch selection). */
213
213
  branchUpkeepRequired: z.boolean().optional(),
214
- /** GitHub review id. Produced by github-webhook-reactive-run.ts and
214
+ /** GitHub review id. Produced by github-review-context.ts and
215
215
  * reactive-run-policy.ts hydrateRequestedChangesContext; consumed by
216
216
  * prompting/patchrelay.ts buildStructuredReviewContext. */
217
217
  reviewId: z.number().optional(),
@@ -9,7 +9,21 @@ import { computeChangeIdentityFromWorktree } from "./change-identity.js";
9
9
  import { inspectGitWorktreeStatus, isRepairRunType } from "./git-worktree-status.js";
10
10
  import { buildRunOutcomeSummary } from "./run-outcome-summary.js";
11
11
  import { settleRun } from "./run-settlement.js";
12
+ import { evaluateTaskCompletion, projectWorkflowSnapshot } from "./workflow-runtime.js";
12
13
  const WRITER = "run-finalizer";
14
+ function parseObjectJson(raw) {
15
+ if (!raw)
16
+ return undefined;
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
20
+ ? parsed
21
+ : undefined;
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
13
27
  function buildRunSummaryJson(report, outcomeSummary) {
14
28
  return JSON.stringify({
15
29
  latestAssistantMessage: report.assistantMessages.at(-1) ?? null,
@@ -201,20 +215,16 @@ export class RunFinalizer {
201
215
  ...(options?.publishDeferredFollowUp ? { publishDeferredFollowUp: true } : {}),
202
216
  });
203
217
  }
204
- // Plan §4.4: finalize a run that was superseded mid-flight. The
205
- // status row was already moved to `superseded` by the trigger
206
- // observer; this just makes sure the issue's activeRunId is
207
- // cleared, the lease is released, and the operator sees a
208
- // clean recap event. No publication, no follow-up enqueue —
209
- // the approval that triggered supersedure already advanced the
210
- // factoryState.
211
- releaseSupersededRun(run, threadId, completedTurnId) {
218
+ // Finalize a run whose authority/premise was revoked mid-flight.
219
+ // No publication, no follow-up enqueue: the current external truth
220
+ // already superseded the run's right to act.
221
+ releaseSuppressedRun(run, threadId, completedTurnId, reason) {
212
222
  this.withHeldLease(run.projectId, run.linearIssueId, () => {
213
223
  this.db.runs.finishRun(run.id, {
214
224
  status: "superseded",
215
225
  threadId,
216
226
  ...(completedTurnId ? { turnId: completedTurnId } : {}),
217
- failureReason: run.failureReason ?? "approved on the same head; further publication suppressed",
227
+ failureReason: run.failureReason ?? reason,
218
228
  });
219
229
  this.db.issueSessions.commitIssueState({
220
230
  writer: WRITER,
@@ -231,10 +241,28 @@ export class RunFinalizer {
231
241
  this.feed?.publish({
232
242
  level: "info",
233
243
  kind: "agent",
234
- summary: `Run #${run.id} superseded — publication suppressed (approved on the same head)`,
244
+ summary: `Run #${run.id} superseded — publication suppressed (${reason})`,
235
245
  ...(run.projectId ? { projectId: run.projectId } : {}),
236
246
  });
237
247
  }
248
+ resolveSuppressedRunReason(run, issue) {
249
+ if (run.shouldNotPublish || run.status === "superseded") {
250
+ return run.leaseRevokeReason ?? run.failureReason ?? "publication suppressed";
251
+ }
252
+ const workflowSnapshot = projectWorkflowSnapshot({
253
+ issue,
254
+ observations: this.db.workflowObservations.listObservations(issue.projectId, issue.linearIssueId),
255
+ blockerCount: this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId),
256
+ childCount: this.db.issues.listCanonicalChildIssues(issue.projectId, issue.linearIssueId).length,
257
+ });
258
+ if (!workflowSnapshot.authority.delegated) {
259
+ return "authority revoked before run completion";
260
+ }
261
+ if (run.authorityEpoch > 0 && workflowSnapshot.authority.epoch > run.authorityEpoch) {
262
+ return `authority epoch changed from ${run.authorityEpoch} to ${workflowSnapshot.authority.epoch}`;
263
+ }
264
+ return undefined;
265
+ }
238
266
  publishTurnEvent(params) {
239
267
  this.feed?.publish({
240
268
  level: params.level,
@@ -287,6 +315,49 @@ export class RunFinalizer {
287
315
  return undefined;
288
316
  return status;
289
317
  }
318
+ resolveWorkflowCompletionTask(run) {
319
+ if (!isRepairRunType(run.runType))
320
+ return undefined;
321
+ const record = this.db.workflowTasks.getTask(run.projectId, run.linearIssueId, `run:${run.runType}`);
322
+ if (!record?.runType)
323
+ return undefined;
324
+ const requirements = parseObjectJson(record.requirementsJson);
325
+ return {
326
+ id: record.taskId,
327
+ type: record.taskType,
328
+ runType: record.runType,
329
+ reason: record.reason,
330
+ ...(requirements ? { requirements } : {}),
331
+ };
332
+ }
333
+ evaluateWorkflowCompletionGate(run, issue) {
334
+ const task = this.resolveWorkflowCompletionTask(run);
335
+ if (!task)
336
+ return undefined;
337
+ const snapshot = projectWorkflowSnapshot({
338
+ issue,
339
+ observations: this.db.workflowObservations.listObservations(issue.projectId, issue.linearIssueId),
340
+ blockerCount: this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId),
341
+ childCount: this.db.issues.listCanonicalChildIssues(issue.projectId, issue.linearIssueId).length,
342
+ });
343
+ const decision = evaluateTaskCompletion(snapshot, task);
344
+ if (decision.action === "start")
345
+ return undefined;
346
+ if (decision.action === "ask") {
347
+ return {
348
+ message: decision.question,
349
+ nextState: "awaiting_input",
350
+ status: decision.reason,
351
+ level: "warn",
352
+ };
353
+ }
354
+ return {
355
+ message: decision.reason,
356
+ nextState: "escalated",
357
+ status: decision.reason,
358
+ level: decision.action === "wait" ? "warn" : "error",
359
+ };
360
+ }
290
361
  continueDirtyRepairWorktree(params) {
291
362
  const message = params.status.summary
292
363
  ? `Repair run finished with a dirty worktree; ${params.status.summary}`
@@ -363,22 +434,18 @@ export class RunFinalizer {
363
434
  }
364
435
  async finalizeCompletedRun(params) {
365
436
  const { run, issue, thread, threadId } = params;
366
- // Plan §4.4: a run flagged shouldNotPublish was deliberately
367
- // superseded mid-flight (the PR was approved on the same head
368
- // while a review_fix run was still producing output). The Codex
369
- // turn may have completed; the finalizer must NOT run any of
370
- // the publication-verification policies they all assume the
371
- // run was supposed to publish, and would either fail it
372
- // spuriously (`verifyReviewFixAdvancedHead`) or open new
373
- // follow-up work. Just record the supersedure outcome and
374
- // release the lease.
375
- if (run.shouldNotPublish || run.status === "superseded") {
376
- this.releaseSupersededRun(run, threadId, params.completedTurnId);
437
+ const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
438
+ // A run flagged shouldNotPublish, or whose authority epoch no
439
+ // longer matches current truth, must not enter publication or
440
+ // completion-verification paths. Those policies assume the run is
441
+ // still allowed to publish and may open follow-up work.
442
+ const suppressedReason = this.resolveSuppressedRunReason(run, freshIssue);
443
+ if (suppressedReason) {
444
+ this.releaseSuppressedRun(run, threadId, params.completedTurnId, suppressedReason);
377
445
  return;
378
446
  }
379
447
  const trackedIssue = this.db.issueToTrackedIssue(issue);
380
448
  const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.runs.listThreadEvents(run.id)));
381
- const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
382
449
  const dirtyRepairWorktree = this.inspectDirtyRepairWorktree(run, freshIssue);
383
450
  if (dirtyRepairWorktree) {
384
451
  this.continueDirtyRepairWorktree({
@@ -391,6 +458,19 @@ export class RunFinalizer {
391
458
  });
392
459
  return;
393
460
  }
461
+ const workflowCompletionGate = this.evaluateWorkflowCompletionGate(run, freshIssue);
462
+ if (workflowCompletionGate) {
463
+ this.failRunAndClear(run, workflowCompletionGate.message, workflowCompletionGate.nextState);
464
+ this.syncFailureOutcome({
465
+ run,
466
+ fallbackIssue: freshIssue,
467
+ message: workflowCompletionGate.message,
468
+ level: workflowCompletionGate.level,
469
+ status: workflowCompletionGate.status,
470
+ summary: workflowCompletionGate.message,
471
+ });
472
+ return;
473
+ }
394
474
  const verifiedRepairError = await this.completionPolicy.verifyReactiveRunAdvancedBranch(run, freshIssue);
395
475
  if (verifiedRepairError) {
396
476
  // The run failed verification — it did not do its work, so resolve
@@ -123,6 +123,7 @@ export class RunLauncher {
123
123
  linearIssueId: params.item.issueId,
124
124
  runType: params.runType,
125
125
  ...(params.sourceHeadSha ? { sourceHeadSha: params.sourceHeadSha } : {}),
126
+ ...(params.authorityEpoch !== undefined ? { authorityEpoch: params.authorityEpoch } : {}),
126
127
  promptText: params.prompt,
127
128
  });
128
129
  const failureHeadSha = params.effectiveContext?.failureHeadSha ?? params.effectiveContext?.headSha;
@@ -26,6 +26,7 @@ import { CodexThreadMaterializingError, isThreadMaterializingError } from "./cod
26
26
  import { emitTelemetry, noopTelemetry } from "./telemetry.js";
27
27
  import { LinearIssueProjectionService } from "./linear-issue-projection.js";
28
28
  import { RunAdmissionController } from "./run-admission-controller.js";
29
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
29
30
  const WRITER = "run-orchestrator";
30
31
  function lowerCaseFirst(value) {
31
32
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
@@ -434,6 +435,7 @@ export class RunOrchestrator {
434
435
  const sourceHeadSha = effectiveContext?.failureHeadSha
435
436
  ?? effectiveContext?.headSha
436
437
  ?? issue.prHeadSha;
438
+ const workflowSnapshot = reconcileWorkflowTasksForIssue(this.db, issue).snapshot;
437
439
  const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, project, runType, isRequestedChangesRunType);
438
440
  if (budgetExceeded) {
439
441
  this.emitRunSkipped(item, "budget_exceeded", issue, { runType });
@@ -458,6 +460,7 @@ export class RunOrchestrator {
458
460
  runType,
459
461
  prompt,
460
462
  ...(sourceHeadSha ? { sourceHeadSha } : {}),
463
+ authorityEpoch: workflowSnapshot.authority.epoch,
461
464
  ...(effectiveContext ? { effectiveContext } : {}),
462
465
  materializeLegacyPendingWake: (targetIssue, lease) => this.materializeLegacyPendingWake(targetIssue, lease),
463
466
  resolveRunWake: (targetIssue) => this.resolveRunWake(targetIssue),
@@ -469,6 +472,10 @@ export class RunOrchestrator {
469
472
  this.releaseIssueSessionLease(item.projectId, item.issueId);
470
473
  return;
471
474
  }
475
+ const claimedIssue = this.db.issues.getIssue(item.projectId, item.issueId);
476
+ if (claimedIssue) {
477
+ reconcileWorkflowTasksForIssue(this.db, claimedIssue);
478
+ }
472
479
  this.feed?.publish({
473
480
  level: "info",
474
481
  kind: "stage",
@@ -1,8 +1,21 @@
1
1
  import { getCiRepairBudget, getQueueRepairBudget, getReviewFixBudget, } from "./run-budgets.js";
2
2
  import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
3
- import { parseRunContextOrWarn, serializeRunContext } from "./run-context.js";
3
+ import { parseRunContextOrWarn, serializeRunContext, tryParseRunContextValue } from "./run-context.js";
4
4
  import { assertNever } from "./utils.js";
5
5
  const WRITER = "run-wake-planner";
6
+ function parseObjectJson(raw) {
7
+ if (!raw)
8
+ return undefined;
9
+ try {
10
+ const parsed = JSON.parse(raw);
11
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
12
+ ? parsed
13
+ : undefined;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
6
19
  export class RunWakePlanner {
7
20
  db;
8
21
  logger;
@@ -11,15 +24,52 @@ export class RunWakePlanner {
11
24
  this.logger = logger;
12
25
  }
13
26
  resolveRunWake(issue) {
14
- const sessionWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId);
15
- if (!sessionWake)
27
+ if (this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId) > 0) {
28
+ return undefined;
29
+ }
30
+ const sessionWake = this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId);
31
+ if (sessionWake) {
32
+ return {
33
+ runType: sessionWake.runType,
34
+ context: sessionWake.context,
35
+ wakeReason: sessionWake.wakeReason,
36
+ resumeThread: sessionWake.resumeThread,
37
+ eventIds: sessionWake.eventIds,
38
+ };
39
+ }
40
+ const workflowTaskWake = this.resolveWorkflowTaskWake(issue);
41
+ if (workflowTaskWake)
42
+ return workflowTaskWake;
43
+ const implicitWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId);
44
+ if (!implicitWake)
45
+ return undefined;
46
+ return {
47
+ runType: implicitWake.runType,
48
+ context: implicitWake.context,
49
+ wakeReason: implicitWake.wakeReason,
50
+ resumeThread: implicitWake.resumeThread,
51
+ eventIds: implicitWake.eventIds,
52
+ };
53
+ }
54
+ resolveWorkflowTaskWake(issue) {
55
+ const task = this.db.workflowTasks
56
+ .listOpenRunnableTasks(issue.projectId)
57
+ .find((entry) => entry.subjectId === issue.linearIssueId);
58
+ if (!task?.runType)
16
59
  return undefined;
60
+ const runType = task.runType;
61
+ const rawRequirements = parseObjectJson(task.requirementsJson);
62
+ const context = tryParseRunContextValue({
63
+ ...rawRequirements,
64
+ ...(rawRequirements?.blockingHeadSha ? { requestedChangesHeadSha: rawRequirements.blockingHeadSha } : {}),
65
+ source: "workflow_task",
66
+ }) ?? { source: "workflow_task" };
17
67
  return {
18
- runType: sessionWake.runType,
19
- context: sessionWake.context,
20
- wakeReason: sessionWake.wakeReason,
21
- resumeThread: sessionWake.resumeThread,
22
- eventIds: sessionWake.eventIds,
68
+ runType,
69
+ ...(Object.keys(context).length > 0 ? { context } : {}),
70
+ wakeReason: task.taskId,
71
+ resumeThread: runType !== "implementation",
72
+ eventIds: [],
23
73
  };
24
74
  }
25
75
  appendWakeEventWithLease(lease, issue, runType, context, dedupeScope) {
@@ -1,8 +1,8 @@
1
1
  import { appendDelegationObservedEvent } from "./delegation-audit.js";
2
2
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
3
3
  import { isResumablePausedLocalWork } from "./paused-issue-state.js";
4
- import { buildRepairWakeDedupeKey, buildRequestedChangesWakeIdentity, reactiveWakeEventType } from "./reactive-wake-keys.js";
5
4
  import { upsertLinearIssueProjection } from "./linear-issue-projection.js";
5
+ import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.js";
6
6
  const WRITER = "service-startup-recovery";
7
7
  export class ServiceStartupRecovery {
8
8
  config;
@@ -81,6 +81,14 @@ export class ServiceStartupRecovery {
81
81
  issue = this.db.issues.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
82
82
  const delegated = liveIssue.delegateId === installation.actorId;
83
83
  if (issue.delegatedToPatchRelay !== delegated) {
84
+ this.appendAuthorityObservation({
85
+ projectId: issue.projectId,
86
+ linearIssueId: issue.linearIssueId,
87
+ delegated,
88
+ actorId: installation.actorId,
89
+ observedDelegateId: liveIssue.delegateId,
90
+ reason: "startup_recovery_refreshed_linear_delegation",
91
+ });
84
92
  appendDelegationObservedEvent(this.db, {
85
93
  projectId: issue.projectId,
86
94
  linearIssueId: issue.linearIssueId,
@@ -100,7 +108,8 @@ export class ServiceStartupRecovery {
100
108
  }
101
109
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
102
110
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
103
- const hasPendingWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId) !== undefined;
111
+ const hasPendingWake = this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId) !== undefined
112
+ || this.db.workflowTasks.listOpenRunnableTasks(issue.projectId).some((task) => task.subjectId === issue.linearIssueId);
104
113
  const shouldRecoverPausedLocalWork = delegated
105
114
  && isResumablePausedLocalWork({
106
115
  issue: {
@@ -158,18 +167,7 @@ export class ServiceStartupRecovery {
158
167
  continue;
159
168
  }
160
169
  if (unresolvedBlockers === 0) {
161
- if (shouldRecoverReactivePrWork && reactiveIntent) {
162
- this.appendReactiveWakeEvent(issue.projectId, issue.linearIssueId, issue, reactiveIntent.runType);
163
- }
164
- else {
165
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
166
- projectId: issue.projectId,
167
- linearIssueId: issue.linearIssueId,
168
- eventType: "delegated",
169
- dedupeKey: `delegated:${issue.linearIssueId}`,
170
- });
171
- }
172
- if (this.db.workflowWakes.peekIssueWake(issue.projectId, issue.linearIssueId)) {
170
+ if (this.reconcileAndFindRunnableTask(updated.projectId, updated.linearIssueId)) {
173
171
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
174
172
  }
175
173
  this.logger.info({
@@ -255,61 +253,53 @@ export class ServiceStartupRecovery {
255
253
  if (commit.outcome !== "applied")
256
254
  return;
257
255
  const updated = commit.issue;
258
- const hasPendingWake = this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id) !== undefined;
256
+ this.appendAuthorityObservation({
257
+ projectId: project.id,
258
+ linearIssueId: liveIssue.id,
259
+ delegated: true,
260
+ observedDelegateId: liveIssue.delegateId,
261
+ reason: "startup_recovery_discovered_delegated_issue",
262
+ });
259
263
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(project.id, liveIssue.id);
260
- if (!hasPendingWake && unresolvedBlockers === 0) {
261
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, liveIssue.id, {
262
- projectId: project.id,
263
- linearIssueId: liveIssue.id,
264
- eventType: "delegated",
265
- dedupeKey: `delegated:${liveIssue.id}`,
266
- });
267
- }
268
- if (this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id)) {
269
- this.enqueueIssue(project.id, liveIssue.id);
270
- }
271
264
  this.logger.info({
272
265
  issueKey: updated.issueKey,
273
266
  projectId: project.id,
274
267
  unresolvedBlockers,
275
268
  }, unresolvedBlockers === 0
276
- ? "Discovered delegated Linear issue during startup recovery and queued implementation"
269
+ ? "Discovered delegated Linear issue during startup recovery"
277
270
  : "Discovered delegated blocked Linear issue during startup recovery");
278
271
  }
279
- appendReactiveWakeEvent(projectId, linearIssueId, issue, runType) {
280
- const eventType = reactiveWakeEventType(runType);
281
- const dedupeKey = runType === "queue_repair" || runType === "ci_repair"
282
- ? buildRepairWakeDedupeKey({
283
- scope: "startup_recovery",
284
- runType,
285
- linearIssueId,
286
- signature: issue.lastGitHubFailureSignature,
287
- prHeadSha: issue.prHeadSha,
288
- failureHeadSha: issue.lastGitHubFailureHeadSha,
289
- })
290
- : buildRequestedChangesWakeIdentity({
291
- linearIssueId,
292
- runType,
293
- headSha: issue.prHeadSha,
294
- }).dedupeKey;
295
- const requestedChangesIdentity = eventType === "review_changes_requested"
296
- ? buildRequestedChangesWakeIdentity({
297
- linearIssueId,
298
- runType: runType === "branch_upkeep" ? "branch_upkeep" : "review_fix",
299
- headSha: issue.prHeadSha,
300
- })
301
- : undefined;
302
- this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
303
- projectId,
304
- linearIssueId,
305
- eventType,
306
- ...(requestedChangesIdentity ? {
307
- eventJson: JSON.stringify({
308
- requestedChangesCoalesceKey: requestedChangesIdentity.coalesceKey,
309
- ...(requestedChangesIdentity.headSha ? { requestedChangesHeadSha: requestedChangesIdentity.headSha } : {}),
310
- }),
311
- } : {}),
312
- dedupeKey,
272
+ appendAuthorityObservation(params) {
273
+ this.db.workflowObservations.appendObservation({
274
+ projectId: params.projectId,
275
+ subjectId: params.linearIssueId,
276
+ source: "linear",
277
+ type: params.delegated ? "linear.delegated" : "linear.undelegated",
278
+ payloadJson: JSON.stringify({
279
+ source: "startup_recovery",
280
+ delegated: params.delegated,
281
+ issueId: params.linearIssueId,
282
+ actorId: params.actorId,
283
+ observedDelegateId: params.observedDelegateId,
284
+ reason: params.reason,
285
+ }),
286
+ dedupeKey: [
287
+ "startup_recovery",
288
+ "authority",
289
+ params.linearIssueId,
290
+ params.delegated ? "delegated" : "undelegated",
291
+ params.observedDelegateId ?? "",
292
+ ].join(":"),
313
293
  });
314
294
  }
295
+ reconcileAndFindRunnableTask(projectId, linearIssueId) {
296
+ const issue = this.db.issues.getIssue(projectId, linearIssueId);
297
+ if (!issue)
298
+ return false;
299
+ const reconciliation = reconcileWorkflowTasksForIssue(this.db, issue);
300
+ return [
301
+ ...reconciliation.result.opened,
302
+ ...reconciliation.result.updated,
303
+ ].some((task) => task.gateAction === "start" && task.runType);
304
+ }
315
305
  }
@@ -85,8 +85,12 @@ export class TrackedIssueListQuery {
85
85
  const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
86
86
  const blockedByCount = Number(row.blocked_by_count ?? 0);
87
87
  const hasPendingSessionEvents = Number(row.pending_session_event_count ?? 0) > 0;
88
+ const hasRunnableWorkflowTask = this.db.workflowTasks
89
+ .listOpenRunnableTasks(String(row.project_id))
90
+ .some((task) => task.subjectId === String(row.linear_issue_id));
88
91
  const hasPendingWake = hasPendingSessionEvents
89
- || this.db.workflowWakes.peekIssueWake(String(row.project_id), String(row.linear_issue_id)) !== undefined;
92
+ || this.db.workflowWakes.peekIssueWake(String(row.project_id), String(row.linear_issue_id)) !== undefined
93
+ || hasRunnableWorkflowTask;
90
94
  const detachedActiveRun = hasDetachedActiveLatestRun({
91
95
  activeRunId: row.active_run_type !== null ? 1 : undefined,
92
96
  latestRun: row.latest_run_status !== null