@abelxiaoxing/cadence 1.5.3 → 1.7.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.
Files changed (38) hide show
  1. package/README.md +61 -1
  2. package/config/plan-draft.example.json +3 -0
  3. package/config/plan-draft.multiple-tasks.example.json +30 -0
  4. package/package.json +1 -1
  5. package/prompts/abel-design.md +33 -3
  6. package/prompts/abel-implement.md +36 -4
  7. package/src/apply-transaction.ts +1 -9
  8. package/src/artifact-store.ts +1 -9
  9. package/src/change-verification.ts +758 -65
  10. package/src/contracts.ts +8 -0
  11. package/src/delivery-compiler.ts +452 -9
  12. package/src/design-control.ts +2 -2
  13. package/src/design-diagnostics.ts +6 -4
  14. package/src/directory-sync.ts +36 -0
  15. package/src/durable-contracts.ts +19 -3
  16. package/src/durable-workflow.ts +47 -38
  17. package/src/implement-continuation.ts +559 -0
  18. package/src/implement-graph.ts +114 -0
  19. package/src/implement-plan.ts +2 -0
  20. package/src/index.ts +179 -1
  21. package/src/operator-cli.mjs +12 -7
  22. package/src/package-state.ts +309 -0
  23. package/src/package-verification.ts +17 -1
  24. package/src/package-workflow.ts +219 -171
  25. package/src/phase-execution.ts +7 -1
  26. package/src/plan-draft-summary.ts +385 -7
  27. package/src/plan-draft.ts +9 -0
  28. package/src/run-store.ts +39 -15
  29. package/src/storage-schema.ts +14 -6
  30. package/src/subagent-activity.ts +3 -1
  31. package/src/verification-capability.ts +17 -5
  32. package/src/verification-environment.ts +20 -2
  33. package/src/workflow-policy.ts +296 -5
  34. package/src/workflow-recovery-policy.ts +2 -0
  35. package/src/workflow-state-machine.ts +400 -131
  36. package/src/workflow-status.ts +251 -59
  37. package/src/workspace-io-worker.mjs +48 -42
  38. package/src/workspace-store.ts +1 -9
@@ -7,7 +7,7 @@ import {
7
7
  normalizeChangeContract,
8
8
  retainedChangeContract,
9
9
  } from "./change-contract.ts";
10
- import { LIMITS } from "./contracts.ts";
10
+ import { LIMITS, verificationInputPaths } from "./contracts.ts";
11
11
  import {
12
12
  assertControlCommand,
13
13
  type ControlCommand,
@@ -45,6 +45,7 @@ import {
45
45
  type ActiveOperation,
46
46
  assertDelivery,
47
47
  type BootstrapRow,
48
+ baselineRecoveryKey,
48
49
  bootstrapAcceptanceHash,
49
50
  CHANGE_NAME,
50
51
  cancellableRead,
@@ -62,6 +63,7 @@ import {
62
63
  isRecord,
63
64
  isWorkflowApprovalCode,
64
65
  normalizeBootstrapAcceptanceFacts,
66
+ normalizeVerificationPrerequisite,
65
67
  normalizeWorkflowContextRequest,
66
68
  normalizeWorkflowVerificationStatus,
67
69
  parseJsonRecord,
@@ -635,6 +637,8 @@ export class WorkflowEngine {
635
637
  let lease: OperationLease;
636
638
  try {
637
639
  lease = this.#operationLease(runId, operationId);
640
+ if (this.#operation(runId, operationId)?.command !== "amend")
641
+ this.#pauseInterruptedRun(runId, operationId, lease);
638
642
  this.#runStore.interruptLease(lease);
639
643
  this.#database
640
644
  .prepare(
@@ -678,6 +682,64 @@ export class WorkflowEngine {
678
682
  });
679
683
  }
680
684
 
685
+ #pauseInterruptedRun(
686
+ runId: string,
687
+ operationId: string,
688
+ lease: OperationLease,
689
+ ): void {
690
+ const projection = this.#runStore.status(runId);
691
+ if (["completed", "discarded", "rejected"].includes(projection.state))
692
+ return;
693
+ const write = (database: DatabaseSync) => {
694
+ database
695
+ .prepare(`UPDATE workflow_engine_tasks
696
+ SET state = 'paused',
697
+ pause_code = CASE WHEN pause_code = 'red-artifact-constraint'
698
+ THEN pause_code ELSE 'operation-interrupted' END,
699
+ context_request_json = CASE WHEN pause_code = 'red-artifact-constraint'
700
+ THEN context_request_json ELSE NULL END,
701
+ queue_position = NULL
702
+ WHERE run_id = ? AND state IN ('phase-running', 'validating')`)
703
+ .run(runId);
704
+ };
705
+ const active = [
706
+ "created",
707
+ "validating-delivery",
708
+ "ready",
709
+ "queued",
710
+ "connecting",
711
+ "running",
712
+ "validating",
713
+ "verifying",
714
+ "retryable",
715
+ "approval-needed",
716
+ "change-verifying",
717
+ "ready-to-apply",
718
+ ];
719
+ const to =
720
+ projection.state === "applying"
721
+ ? "recovering"
722
+ : active.includes(projection.state)
723
+ ? "paused"
724
+ : undefined;
725
+ if (to) {
726
+ // Keep task and run projections atomic; retained evidence and budgets are
727
+ // deliberately untouched. The original exception still reaches the caller.
728
+ this.#runStore.transitionAtomically(
729
+ {
730
+ runId,
731
+ to,
732
+ lease,
733
+ code: "operation-interrupted",
734
+ operationId: `eng-${hash(runId, operationId, String(projection.sequence), "interrupted").slice(0, 40)}`,
735
+ },
736
+ write,
737
+ );
738
+ } else {
739
+ this.#leasedTransaction(lease, () => write(this.#database));
740
+ }
741
+ }
742
+
681
743
  #recoverInterruptedOperations(): void {
682
744
  if (this.#orphanRecoveryTimer) {
683
745
  clearTimeout(this.#orphanRecoveryTimer);
@@ -687,7 +749,11 @@ export class WorkflowEngine {
687
749
  const interrupted = this.#database
688
750
  .prepare(
689
751
  `SELECT run_id, operation_id FROM workflow_engine_operations
690
- WHERE state = 'running'`,
752
+ WHERE state = 'running' OR (state = 'interrupted' AND command <> 'amend'
753
+ AND (run_id IN (SELECT run_id FROM workflow_engine_tasks
754
+ WHERE state IN ('phase-running', 'validating'))
755
+ OR run_id IN (SELECT run_id FROM runs WHERE state IN
756
+ ('running', 'validating', 'verifying', 'change-verifying', 'applying'))))`,
691
757
  )
692
758
  .all() as unknown as Array<{ run_id: string; operation_id: string }>;
693
759
  for (const row of interrupted) {
@@ -724,57 +790,28 @@ export class WorkflowEngine {
724
790
  WHERE run_id = ? AND operation_id = ? AND state = 'running'`,
725
791
  )
726
792
  .run(row.run_id, row.operation_id);
727
- const projection = this.#runStore.status(row.run_id);
728
- if (["completed", "discarded", "rejected"].includes(projection.state)) {
729
- continue;
730
- }
731
- this.#database
732
- .prepare(
733
- `UPDATE workflow_engine_tasks
734
- SET state = 'paused',
735
- pause_code = CASE
736
- WHEN pause_code = 'red-artifact-constraint'
737
- THEN pause_code
738
- ELSE 'operation-interrupted'
739
- END,
740
- context_request_json = CASE
741
- WHEN pause_code = 'red-artifact-constraint'
742
- THEN context_request_json
743
- ELSE NULL
744
- END,
745
- queue_position = NULL
746
- WHERE run_id = ? AND state IN ('phase-running', 'validating')`,
793
+ // Claim the same run-exclusive lease before repairing projections. A live
794
+ // owner (including a different host) must never be mistaken for an orphan.
795
+ let recoveryLease: OperationLease;
796
+ try {
797
+ recoveryLease = this.#runStore.acquireLease({
798
+ runId: row.run_id,
799
+ operationId: this.#leaseOperationId(row.run_id, row.operation_id),
800
+ ttlMs: this.#leaseTtlMs,
801
+ exclusive: true,
802
+ });
803
+ } catch (error) {
804
+ if (
805
+ error instanceof Error &&
806
+ error.message === "operation-already-running"
747
807
  )
748
- .run(row.run_id);
749
- if (projection.state === "applying") {
750
- this.#transition(
751
- row.run_id,
752
- "recovering",
753
- "restart-apply-recovery",
754
- "operation-interrupted",
755
- );
756
- } else if (
757
- [
758
- "created",
759
- "validating-delivery",
760
- "ready",
761
- "queued",
762
- "connecting",
763
- "running",
764
- "validating",
765
- "verifying",
766
- "retryable",
767
- "approval-needed",
768
- "change-verifying",
769
- "ready-to-apply",
770
- ].includes(projection.state)
771
- ) {
772
- this.#transition(
773
- row.run_id,
774
- "paused",
775
- "restart-operation-interrupted",
776
- "operation-interrupted",
777
- );
808
+ continue;
809
+ throw error;
810
+ }
811
+ try {
812
+ this.#pauseInterruptedRun(row.run_id, row.operation_id, recoveryLease);
813
+ } finally {
814
+ this.#runStore.interruptLease(recoveryLease);
778
815
  }
779
816
  }
780
817
  if (nextExpiry !== undefined && !this.#closed) {
@@ -1118,12 +1155,7 @@ export class WorkflowEngine {
1118
1155
  serializedPlan,
1119
1156
  );
1120
1157
 
1121
- if (
1122
- database
1123
- .prepare("SELECT 1 FROM workflow_work_budget WHERE run_id = ?")
1124
- .get(runId)
1125
- )
1126
- this.#growWorkBudget(database, runId, delivery.plan);
1158
+ this.#growWorkBudget(database, runId, delivery.plan);
1127
1159
  const admitted = new Set<string>();
1128
1160
  delivery.plan.tasks.forEach((task, taskOrder) => {
1129
1161
  admitted.add(task.taskId);
@@ -1560,8 +1592,12 @@ export class WorkflowEngine {
1560
1592
  : undefined;
1561
1593
  const priorDiagnostic = this.#safeAttemptDiagnostic(runId, row);
1562
1594
  const priorRecovery = priorDiagnostic?.recovery;
1595
+ const preparationKey = baselineRecoveryKey(task, phase);
1563
1596
  const activeRecovery =
1564
- priorRecovery?.key === recoveryKey(task, phase, row, current)
1597
+ priorRecovery &&
1598
+ [recoveryKey(task, phase, row, current), preparationKey].includes(
1599
+ priorRecovery.key,
1600
+ )
1565
1601
  ? priorRecovery
1566
1602
  : undefined;
1567
1603
  const maxRecoveryAttempts = Math.min(
@@ -1584,9 +1620,10 @@ export class WorkflowEngine {
1584
1620
  const granted = this.#hasRecoveryGrant(
1585
1621
  runId,
1586
1622
  operationId,
1587
- recoveryKey(task, phase, row, current),
1623
+ activeRecovery?.key ?? recoveryKey(task, phase, row, current),
1588
1624
  );
1589
1625
  const verificationOnly =
1626
+ activeRecovery?.key !== preparationKey &&
1590
1627
  recoveryExhausted(activeRecovery, maxRecoveryAttempts, granted) &&
1591
1628
  (await this.#worker.hasPendingVerification?.({
1592
1629
  runId,
@@ -1623,7 +1660,7 @@ export class WorkflowEngine {
1623
1660
  );
1624
1661
  return;
1625
1662
  }
1626
- if (activeRecovery)
1663
+ if (activeRecovery && activeRecovery.key !== preparationKey)
1627
1664
  artifactAttempts = Math.max(artifactAttempts, activeRecovery.failures);
1628
1665
  const repair =
1629
1666
  verificationStatus?.scope === "change-task-affected" &&
@@ -1649,6 +1686,187 @@ export class WorkflowEngine {
1649
1686
  : {}),
1650
1687
  }
1651
1688
  : undefined;
1689
+ if (this.#worker.prepareTask) {
1690
+ // Reserve this selected task's conflicts while asynchronous baseline
1691
+ // preparation runs, without reserving a Worker attempt.
1692
+ this.#setTask(
1693
+ runId,
1694
+ row.task_id,
1695
+ { state: "phase-ready", phase, queuePosition: null },
1696
+ lease,
1697
+ );
1698
+ if (granted && activeRecovery?.key === preparationKey) {
1699
+ this.#leasedTransaction(lease, () => {
1700
+ const consumed = this.#database
1701
+ .prepare(
1702
+ "UPDATE workflow_recovery_grants SET consumed = 1 WHERE run_id = ? AND operation_id = ? AND incident_key = ? AND consumed = 0",
1703
+ )
1704
+ .run(runId, operationId, preparationKey);
1705
+ if (consumed.changes !== 1)
1706
+ throw new Error("recovery-request-stale");
1707
+ this.#database
1708
+ .prepare(`INSERT INTO workflow_recovery_events(run_id, sequence, incident_key, kind)
1709
+ SELECT ?, COALESCE(MAX(sequence), 0) + 1, ?, 'launch' FROM workflow_recovery_events WHERE run_id = ?`)
1710
+ .run(runId, preparationKey, runId);
1711
+ });
1712
+ }
1713
+ const prepared = await this.#worker.prepareTask({
1714
+ runId,
1715
+ operationId,
1716
+ deliveryRevision: row.delivery_revision,
1717
+ taskId: row.task_id,
1718
+ phase,
1719
+ task: structuredClone(task),
1720
+ plan: structuredClone(plan),
1721
+ ...(current.baseline_workspace_revision
1722
+ ? { baselineRevisionId: current.baseline_workspace_revision }
1723
+ : {}),
1724
+ ...(current.current_workspace_revision
1725
+ ? { currentWorkspaceRevisionId: current.current_workspace_revision }
1726
+ : {}),
1727
+ signal,
1728
+ });
1729
+ this.#runStore.assertLease(lease);
1730
+ if (prepared.kind === "phase-committed")
1731
+ throw new Error("workflow-preparation-outcome-invalid");
1732
+ this.#recordWorkspaceFacts(
1733
+ runId,
1734
+ {
1735
+ ...prepared,
1736
+ kind: "paused",
1737
+ code: "task-prepared",
1738
+ // Preparation has no merge authority over a sibling's newer commit.
1739
+ ...(prepared.currentWorkspaceRevisionId
1740
+ ? {
1741
+ currentWorkspaceRevisionId:
1742
+ this.#engineRun(runId).current_workspace_revision ??
1743
+ prepared.currentWorkspaceRevisionId,
1744
+ }
1745
+ : {}),
1746
+ },
1747
+ lease,
1748
+ );
1749
+ // Preparation may finish successfully while close is settling it.
1750
+ // Re-enter the cancellation guard before granting any Worker work.
1751
+ if (signal.aborted) continue;
1752
+ if (prepared.kind !== "prepared") {
1753
+ const prerequisite =
1754
+ prepared.kind !== "operation-cancelled"
1755
+ ? normalizeVerificationPrerequisite(prepared.prerequisite)
1756
+ : undefined;
1757
+ if (prerequisite) {
1758
+ const verification =
1759
+ prerequisite.scope === "baseline-full-suite"
1760
+ ? plan.verification.baseline?.fullSuite
1761
+ : (task.baselineVerification ?? task.affectedVerification);
1762
+ if (
1763
+ !verification ||
1764
+ verification.id !== prerequisite.verificationId ||
1765
+ (prerequisite.scope === "baseline-task-affected" &&
1766
+ prerequisite.taskId !== row.task_id) ||
1767
+ (prerequisite.input &&
1768
+ !verificationInputPaths(verification).includes(
1769
+ prerequisite.input.path,
1770
+ )) ||
1771
+ (prerequisite.producer &&
1772
+ !plan.outputs.some(
1773
+ (output) =>
1774
+ output.path === prerequisite.input?.path &&
1775
+ output.producer.taskId === prerequisite.producer?.taskId &&
1776
+ output.producer.phase === prerequisite.producer?.phase,
1777
+ ))
1778
+ )
1779
+ throw new Error("workflow-prerequisite-evidence-invalid");
1780
+ }
1781
+ if (prerequisite && prepared.kind === "paused") {
1782
+ const identity = hash(
1783
+ canonicalJson({
1784
+ ...prerequisite,
1785
+ verificationId: undefined,
1786
+ taskId: undefined,
1787
+ producer: prerequisite.producer
1788
+ ? { phase: prerequisite.producer.phase }
1789
+ : undefined,
1790
+ }),
1791
+ );
1792
+ const priorPreparation = this.#recovery(runId, preparationKey);
1793
+ if (priorPreparation?.feedback.prerequisiteIdentity !== identity) {
1794
+ const failures = (priorPreparation?.failures ?? 0) + 1;
1795
+ this.#recordRecovery(
1796
+ runId,
1797
+ {
1798
+ key: preparationKey,
1799
+ failures,
1800
+ feedback: {
1801
+ code: prepared.code,
1802
+ attempt: failures + 1,
1803
+ maxAttempts: maxRecoveryAttempts,
1804
+ strategy: "repair-verification",
1805
+ prerequisiteIdentity: identity,
1806
+ },
1807
+ },
1808
+ lease,
1809
+ );
1810
+ }
1811
+ }
1812
+ if (prepared.kind !== "operation-cancelled" && prepared.verification)
1813
+ this.#setVerificationStatus(runId, prepared.verification, lease);
1814
+ this.#setTask(
1815
+ runId,
1816
+ row.task_id,
1817
+ {
1818
+ state:
1819
+ prepared.kind === "approval-needed"
1820
+ ? "approval-needed"
1821
+ : "paused",
1822
+ phase,
1823
+ pauseCode:
1824
+ prepared.kind === "operation-cancelled"
1825
+ ? "operation-cancelled"
1826
+ : prepared.code,
1827
+ attemptDiagnostic: {
1828
+ ...priorDiagnostic,
1829
+ prerequisite,
1830
+ },
1831
+ queuePosition: null,
1832
+ },
1833
+ lease,
1834
+ );
1835
+ return;
1836
+ }
1837
+ if (priorDiagnostic) delete priorDiagnostic.prerequisite;
1838
+ if (activeRecovery?.key === preparationKey) {
1839
+ // A successful original-baseline observation resolves this incident.
1840
+ // Keep its events and immutable failure evidence, then re-enter the
1841
+ // ordinary phase recovery checks before reserving Worker work.
1842
+ this.#leasedTransaction(lease, () => {
1843
+ this.#database
1844
+ .prepare(`INSERT INTO workflow_recovery_events(run_id, sequence, incident_key, kind)
1845
+ SELECT ?, COALESCE(MAX(sequence), 0) + 1, ?, 'resolved' FROM workflow_recovery_events WHERE run_id = ?`)
1846
+ .run(runId, preparationKey, runId);
1847
+ this.#database
1848
+ .prepare(
1849
+ "DELETE FROM workflow_recovery_incidents WHERE run_id = ? AND incident_key = ?",
1850
+ )
1851
+ .run(runId, preparationKey);
1852
+ });
1853
+ if (priorDiagnostic) delete priorDiagnostic.recovery;
1854
+ const retainedDiagnostic =
1855
+ priorDiagnostic && Object.keys(priorDiagnostic).length
1856
+ ? priorDiagnostic
1857
+ : null;
1858
+ this.#setTask(
1859
+ runId,
1860
+ row.task_id,
1861
+ { attemptDiagnostic: retainedDiagnostic },
1862
+ lease,
1863
+ );
1864
+ row.attempt_diagnostic_json = retainedDiagnostic
1865
+ ? JSON.stringify(retainedDiagnostic)
1866
+ : null;
1867
+ continue;
1868
+ }
1869
+ }
1652
1870
  this.#setTask(
1653
1871
  runId,
1654
1872
  row.task_id,
@@ -1822,6 +2040,8 @@ export class WorkflowEngine {
1822
2040
  ? { action: "rebind-or-revise-delivery" as const }
1823
2041
  : {}),
1824
2042
  };
2043
+ // Only parent preparation can contribute original-baseline authority.
2044
+ delete attemptDiagnostic.prerequisite;
1825
2045
  this.#setTask(runId, row.task_id, { attemptDiagnostic }, lease);
1826
2046
  row.attempt_diagnostic_json = JSON.stringify(attemptDiagnostic);
1827
2047
  }
@@ -2706,73 +2926,90 @@ export class WorkflowEngine {
2706
2926
  this.#setVerificationStatus(runId, null, lease);
2707
2927
  }
2708
2928
  const attempted = new Set<string>();
2709
- let madeProgress = true;
2710
- while (madeProgress && !signal.aborted) {
2711
- madeProgress = false;
2712
- const rows = this.#tasks(runId);
2713
- const selected = selectRunnableTasks({
2714
- rows,
2715
- attempted,
2716
- activeTasks: this.#activeTasks,
2717
- capacity: LIMITS.maxActiveChildSessions,
2718
- nextQueuePosition: this.#engineRun(runId).next_queue_position,
2719
- });
2720
- const runnable = selected.runnable.map((taskId) => {
2721
- const row = rows.find((candidate) => candidate.task_id === taskId);
2722
- if (!row) throw new Error("scheduler-task-unavailable");
2723
- attempted.add(taskId);
2724
- return { row, before: `${row.state}:${row.phase}` };
2725
- });
2726
- for (const update of selected.updates) {
2727
- const row = rows.find(
2728
- (candidate) => candidate.task_id === update.taskId,
2729
- );
2730
- if (!row) throw new Error("scheduler-task-unavailable");
2731
- if (update.kind === "pending")
2732
- this.#setTask(
2733
- runId,
2734
- row.task_id,
2735
- { state: "pending", pauseCode: null, queuePosition: null },
2736
- lease,
2929
+ // Keep every launched operation owned until it settles, including failures.
2930
+ // A fulfilled envelope avoids unhandled rejections while another task wins
2931
+ // the race; the finally barrier also protects cancellation and storage close.
2932
+ type Settlement =
2933
+ | { taskId: string; status: "fulfilled" }
2934
+ | { taskId: string; status: "rejected"; reason: unknown };
2935
+ const pending = new Map<string, Promise<Settlement>>();
2936
+ let firstFailure: { reason: unknown } | undefined;
2937
+ try {
2938
+ while (!signal.aborted) {
2939
+ if (firstFailure) throw firstFailure.reason;
2940
+ const rows = this.#tasks(runId);
2941
+ const selected = selectRunnableTasks({
2942
+ rows,
2943
+ attempted,
2944
+ activeTasks: this.#activeTasks,
2945
+ capacity: LIMITS.maxActiveChildSessions,
2946
+ nextQueuePosition: this.#engineRun(runId).next_queue_position,
2947
+ });
2948
+ const runnable = selected.runnable.map((taskId) => {
2949
+ const row = rows.find((candidate) => candidate.task_id === taskId);
2950
+ if (!row) throw new Error("scheduler-task-unavailable");
2951
+ attempted.add(taskId);
2952
+ return row;
2953
+ });
2954
+ for (const update of selected.updates) {
2955
+ const row = rows.find(
2956
+ (candidate) => candidate.task_id === update.taskId,
2737
2957
  );
2738
- else this.#queueTask(runId, row, lease, update.reason);
2739
- }
2740
- const capacityBlocked = selected.capacityBlocked;
2741
- if (runnable.length === 0 && capacityBlocked) {
2742
- await this.#waitForCapacity(signal);
2743
- madeProgress = true;
2744
- continue;
2745
- }
2746
- this.#activeTasks += runnable.length;
2747
- const settled = await Promise.allSettled(
2748
- runnable.map(({ row }) =>
2749
- this.#runTask(
2958
+ if (!row) throw new Error("scheduler-task-unavailable");
2959
+ if (update.kind === "pending")
2960
+ this.#setTask(
2961
+ runId,
2962
+ row.task_id,
2963
+ { state: "pending", pauseCode: null, queuePosition: null },
2964
+ lease,
2965
+ );
2966
+ else this.#queueTask(runId, row, lease, update.reason);
2967
+ }
2968
+ this.#activeTasks += runnable.length;
2969
+ for (const row of runnable) {
2970
+ const operation = this.#runTask(
2750
2971
  runId,
2751
2972
  row,
2752
2973
  operationId,
2753
2974
  signal,
2754
2975
  lease,
2755
2976
  onActivity,
2756
- ).finally(() => {
2757
- this.#activeTasks--;
2758
- for (const wake of this.#capacityWaiters) wake();
2759
- }),
2760
- ),
2761
- );
2762
- const rejected = settled.find(
2763
- (outcome): outcome is PromiseRejectedResult =>
2764
- outcome.status === "rejected",
2765
- );
2766
- if (rejected) throw rejected.reason;
2767
- for (const { row, before } of runnable) {
2768
- const after = this.#tasks(runId).find(
2769
- (candidate) => candidate.task_id === row.task_id,
2770
- );
2771
- if (after && `${after.state}:${after.phase}` !== before) {
2772
- madeProgress = true;
2977
+ )
2978
+ .then<Settlement, Settlement>(
2979
+ () => ({ taskId: row.task_id, status: "fulfilled" }),
2980
+ (reason: unknown) => {
2981
+ firstFailure ??= { reason };
2982
+ return { taskId: row.task_id, status: "rejected", reason };
2983
+ },
2984
+ )
2985
+ .finally(() => {
2986
+ this.#activeTasks--;
2987
+ for (const wake of this.#capacityWaiters) wake();
2988
+ });
2989
+ pending.set(row.task_id, operation);
2990
+ }
2991
+ if (pending.size > 0) {
2992
+ const settled = await Promise.race([
2993
+ ...pending.values(),
2994
+ ...(selected.capacityBlocked
2995
+ ? [this.#waitForCapacity(signal).then(() => null)]
2996
+ : []),
2997
+ ]);
2998
+ if (settled === null) continue;
2999
+ pending.delete(settled.taskId);
3000
+ if (settled.status === "rejected") throw settled.reason;
3001
+ continue;
3002
+ }
3003
+ if (selected.capacityBlocked) {
3004
+ await this.#waitForCapacity(signal);
3005
+ continue;
2773
3006
  }
3007
+ break;
2774
3008
  }
3009
+ } finally {
3010
+ await Promise.all(pending.values());
2775
3011
  }
3012
+ if (firstFailure) throw firstFailure.reason;
2776
3013
 
2777
3014
  if (signal.aborted) {
2778
3015
  const current = this.#runStore.status(runId);
@@ -2863,8 +3100,11 @@ export class WorkflowEngine {
2863
3100
 
2864
3101
  #recoveryConditions(runId: string, key: string) {
2865
3102
  const run = this.#engineRun(runId);
2866
- const task = this.#tasks(runId).find(
2867
- (row) => recoveryKey(this.#taskPlan(row), row.phase, row, run) === key,
3103
+ const task = this.#tasks(runId).find((row) =>
3104
+ [
3105
+ recoveryKey(this.#taskPlan(row), row.phase, row, run),
3106
+ baselineRecoveryKey(this.#taskPlan(row), row.phase),
3107
+ ].includes(key),
2868
3108
  );
2869
3109
  return {
2870
3110
  route: task?.route_fingerprint ?? run.route_fingerprint,
@@ -2904,12 +3144,15 @@ export class WorkflowEngine {
2904
3144
  const current = this.#tasks(runId).some(
2905
3145
  (row) =>
2906
3146
  ["paused", "retryable"].includes(row.state) &&
2907
- recoveryKey(
2908
- this.#taskPlan(row),
2909
- row.phase,
2910
- row,
2911
- this.#engineRun(runId),
2912
- ) === request.incidentKey,
3147
+ [
3148
+ recoveryKey(
3149
+ this.#taskPlan(row),
3150
+ row.phase,
3151
+ row,
3152
+ this.#engineRun(runId),
3153
+ ),
3154
+ baselineRecoveryKey(this.#taskPlan(row), row.phase),
3155
+ ].includes(request.incidentKey),
2913
3156
  );
2914
3157
  if (
2915
3158
  !current ||
@@ -3045,10 +3288,20 @@ export class WorkflowEngine {
3045
3288
  ? (JSON.parse(row.attempt_diagnostic_json) as SafeAttemptDiagnostic)
3046
3289
  : {};
3047
3290
  delete diagnostic.recovery;
3048
- const recovery = this.#recovery(
3049
- runId,
3050
- recoveryKey(this.#taskPlan(row), row.phase, row, this.#engineRun(runId)),
3051
- );
3291
+ const recovery =
3292
+ this.#recovery(
3293
+ runId,
3294
+ baselineRecoveryKey(this.#taskPlan(row), row.phase),
3295
+ ) ??
3296
+ this.#recovery(
3297
+ runId,
3298
+ recoveryKey(
3299
+ this.#taskPlan(row),
3300
+ row.phase,
3301
+ row,
3302
+ this.#engineRun(runId),
3303
+ ),
3304
+ );
3052
3305
  if (recovery) diagnostic.recovery = recovery;
3053
3306
  return Object.keys(diagnostic).length ? diagnostic : undefined;
3054
3307
  }
@@ -3759,8 +4012,24 @@ export class WorkflowEngine {
3759
4012
  lease,
3760
4013
  );
3761
4014
  }
3762
- outcome = this.#statusByRun(runId);
3763
4015
  await activeSettlement;
4016
+ for (const task of this.#tasks(runId)) {
4017
+ if (
4018
+ ["phase-ready", "phase-running", "validating"].includes(task.state)
4019
+ ) {
4020
+ this.#setTask(
4021
+ runId,
4022
+ task.task_id,
4023
+ {
4024
+ state: "paused",
4025
+ pauseCode: "operation-cancelled",
4026
+ queuePosition: null,
4027
+ },
4028
+ lease,
4029
+ );
4030
+ }
4031
+ }
4032
+ outcome = this.#statusByRun(runId);
3764
4033
  }
3765
4034
  outcome = {
3766
4035
  ...outcome,