@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.
- package/README.md +61 -1
- package/config/plan-draft.example.json +3 -0
- package/config/plan-draft.multiple-tasks.example.json +30 -0
- package/package.json +1 -1
- package/prompts/abel-design.md +33 -3
- package/prompts/abel-implement.md +36 -4
- package/src/apply-transaction.ts +1 -9
- package/src/artifact-store.ts +1 -9
- package/src/change-verification.ts +758 -65
- package/src/contracts.ts +8 -0
- package/src/delivery-compiler.ts +452 -9
- package/src/design-control.ts +2 -2
- package/src/design-diagnostics.ts +6 -4
- package/src/directory-sync.ts +36 -0
- package/src/durable-contracts.ts +19 -3
- package/src/durable-workflow.ts +47 -38
- package/src/implement-continuation.ts +559 -0
- package/src/implement-graph.ts +114 -0
- package/src/implement-plan.ts +2 -0
- package/src/index.ts +179 -1
- package/src/operator-cli.mjs +12 -7
- package/src/package-state.ts +309 -0
- package/src/package-verification.ts +17 -1
- package/src/package-workflow.ts +219 -171
- package/src/phase-execution.ts +7 -1
- package/src/plan-draft-summary.ts +385 -7
- package/src/plan-draft.ts +9 -0
- package/src/run-store.ts +39 -15
- package/src/storage-schema.ts +14 -6
- package/src/subagent-activity.ts +3 -1
- package/src/verification-capability.ts +17 -5
- package/src/verification-environment.ts +20 -2
- package/src/workflow-policy.ts +296 -5
- package/src/workflow-recovery-policy.ts +2 -0
- package/src/workflow-state-machine.ts +400 -131
- package/src/workflow-status.ts +251 -59
- package/src/workspace-io-worker.mjs +48 -42
- 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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2710
|
-
while
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
const
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
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
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
runnable.
|
|
2749
|
-
|
|
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
|
-
)
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
)
|
|
2771
|
-
|
|
2772
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
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 =
|
|
3049
|
-
|
|
3050
|
-
|
|
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,
|