@agilno-tech/rivet 0.1.0-alpha.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 (283) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +59 -0
  4. package/bin/cli.js +42 -0
  5. package/dist/agents/backend-django-agent/SKILL.md +35 -0
  6. package/dist/agents/backend-nestjs-agent/SKILL.md +36 -0
  7. package/dist/agents/boss-agent/SKILL.md +28 -0
  8. package/dist/agents/bounded-worker/SKILL.md +28 -0
  9. package/dist/agents/data-performance-agent/SKILL.md +32 -0
  10. package/dist/agents/delivery-ticketing-agent/SKILL.md +36 -0
  11. package/dist/agents/devops-agent/SKILL.md +34 -0
  12. package/dist/agents/engineering-manager/SKILL.md +28 -0
  13. package/dist/agents/frontend-nextjs-agent/SKILL.md +34 -0
  14. package/dist/agents/frontend-web-agent/SKILL.md +31 -0
  15. package/dist/agents/incident-response-agent/SKILL.md +42 -0
  16. package/dist/agents/jest-agent/SKILL.md +77 -0
  17. package/dist/agents/mobile-agent/SKILL.md +32 -0
  18. package/dist/agents/playwright-agent/SKILL.md +54 -0
  19. package/dist/agents/product-design-manager/SKILL.md +27 -0
  20. package/dist/agents/qa-agent/SKILL.md +29 -0
  21. package/dist/agents/quality-manager/SKILL.md +28 -0
  22. package/dist/agents/vitest-agent/SKILL.md +77 -0
  23. package/dist/governance/pre-push-rules.md +75 -0
  24. package/dist/governance/prompt-hygiene.md +22 -0
  25. package/dist/governance/review-checklist.md +24 -0
  26. package/dist/governance/safety-and-data.md +27 -0
  27. package/dist/governance/usage-rules.md +21 -0
  28. package/dist/mandatory/address-pr-feedback/SKILL.md +196 -0
  29. package/dist/mandatory/agentic-goal/SKILL.md +42 -0
  30. package/dist/mandatory/agentic-status/SKILL.md +74 -0
  31. package/dist/mandatory/apply-design/SKILL.md +228 -0
  32. package/dist/mandatory/check-ac/SKILL.md +124 -0
  33. package/dist/mandatory/create-pr-and-commit/SKILL.md +304 -0
  34. package/dist/mandatory/design/SKILL.md +312 -0
  35. package/dist/mandatory/feature-workflow/SKILL.md +87 -0
  36. package/dist/mandatory/hotfix/SKILL.md +195 -0
  37. package/dist/mandatory/pre-push/SKILL.md +272 -0
  38. package/dist/mandatory/project-context/SKILL.md +180 -0
  39. package/dist/mandatory/release-docs/SKILL.md +191 -0
  40. package/dist/mandatory/review-pr/SKILL.md +323 -0
  41. package/dist/mandatory/security-review/SKILL.md +157 -0
  42. package/dist/skills/api-contract/SKILL.md +77 -0
  43. package/dist/skills/backend-django/SKILL.md +27 -0
  44. package/dist/skills/backend-nestjs/SKILL.md +65 -0
  45. package/dist/skills/bug-ticket-creation/SKILL.md +43 -0
  46. package/dist/skills/cloudwatch-troubleshooting/SKILL.md +73 -0
  47. package/dist/skills/database-migration/SKILL.md +97 -0
  48. package/dist/skills/debugging/SKILL.md +32 -0
  49. package/dist/skills/devops-infra/SKILL.md +26 -0
  50. package/dist/skills/documentation/SKILL.md +24 -0
  51. package/dist/skills/frontend-nextjs/SKILL.md +27 -0
  52. package/dist/skills/incident-postmortem/SKILL.md +38 -0
  53. package/dist/skills/kubernetes-troubleshooting/SKILL.md +71 -0
  54. package/dist/skills/mobile-react-native/SKILL.md +37 -0
  55. package/dist/skills/postgres-analytics/SKILL.md +29 -0
  56. package/dist/skills/product-jira-ticketing/SKILL.md +28 -0
  57. package/dist/skills/qa-bug-analysis/SKILL.md +27 -0
  58. package/dist/skills/refactoring/SKILL.md +32 -0
  59. package/dist/skills/sprint-planning/SKILL.md +32 -0
  60. package/dist/skills/testing-quality/SKILL.md +62 -0
  61. package/dist/v2/protocols/agent-orchestration.md +49 -0
  62. package/dist/v2/protocols/delivery.md +45 -0
  63. package/dist/v2/protocols/design-authority.md +29 -0
  64. package/dist/v2/protocols/goal-graph.md +44 -0
  65. package/dist/v2/protocols/pattern-first-development.md +29 -0
  66. package/dist/v2/protocols/qa-evidence.md +29 -0
  67. package/dist/v2/protocols/security.md +29 -0
  68. package/dist/v2/schemas/event.schema.json +86 -0
  69. package/dist/v2/schemas/evidence.schema.json +94 -0
  70. package/dist/v2/schemas/feature-decomposition.schema.json +48 -0
  71. package/dist/v2/schemas/feature-plan.schema.json +95 -0
  72. package/dist/v2/schemas/goal-graph.schema.json +61 -0
  73. package/dist/v2/schemas/integration.schema.json +119 -0
  74. package/dist/v2/schemas/orchestration.schema.json +69 -0
  75. package/dist/v2/schemas/project.schema.json +209 -0
  76. package/dist/v2/schemas/providers.schema.json +142 -0
  77. package/dist/v2/schemas/quality.schema.json +50 -0
  78. package/dist/v2/schemas/work-action.schema.json +44 -0
  79. package/dist/v2/schemas/work-request.schema.json +253 -0
  80. package/dist/v2/templates/evidence/qa-bundle.json +194 -0
  81. package/dist/v2/templates/github-actions/rivet-deploy.yml +98 -0
  82. package/dist/v2/templates/harness/SKILL.md +90 -0
  83. package/dist/v2/templates/project/.rivet/orchestration.yaml +42 -0
  84. package/dist/v2/templates/project/.rivet/project.yaml +14 -0
  85. package/dist/v2/templates/project/.rivet/providers.yaml +8 -0
  86. package/dist/v2/templates/project/.rivet/quality.yaml +16 -0
  87. package/package.json +58 -0
  88. package/protocols/agent-orchestration.md +49 -0
  89. package/protocols/delivery.md +45 -0
  90. package/protocols/design-authority.md +29 -0
  91. package/protocols/goal-graph.md +44 -0
  92. package/protocols/pattern-first-development.md +29 -0
  93. package/protocols/qa-evidence.md +29 -0
  94. package/protocols/security.md +29 -0
  95. package/schemas/event.schema.json +86 -0
  96. package/schemas/evidence.schema.json +94 -0
  97. package/schemas/feature-decomposition.schema.json +48 -0
  98. package/schemas/feature-plan.schema.json +95 -0
  99. package/schemas/goal-graph.schema.json +61 -0
  100. package/schemas/integration.schema.json +119 -0
  101. package/schemas/orchestration.schema.json +69 -0
  102. package/schemas/project.schema.json +209 -0
  103. package/schemas/providers.schema.json +142 -0
  104. package/schemas/quality.schema.json +50 -0
  105. package/schemas/work-action.schema.json +44 -0
  106. package/schemas/work-request.schema.json +253 -0
  107. package/src/adapters/confluence.js +138 -0
  108. package/src/adapters/contract.js +657 -0
  109. package/src/adapters/factory.js +122 -0
  110. package/src/adapters/figma.js +178 -0
  111. package/src/adapters/fixtures.js +183 -0
  112. package/src/adapters/github.js +314 -0
  113. package/src/adapters/http.js +704 -0
  114. package/src/adapters/jira.js +195 -0
  115. package/src/adapters/linear.js +171 -0
  116. package/src/adapters/node-transport.js +59 -0
  117. package/src/cli/integration-setup-prompt.js +32 -0
  118. package/src/cli/interrupt.js +25 -0
  119. package/src/cli/main.js +596 -0
  120. package/src/cli/output.js +174 -0
  121. package/src/cli/parse-args.js +246 -0
  122. package/src/cli/project-discovery.js +70 -0
  123. package/src/cli/task-confirmation.js +38 -0
  124. package/src/cli/task-presentation.js +22 -0
  125. package/src/clients/claude.js +214 -0
  126. package/src/clients/codex.js +180 -0
  127. package/src/clients/compatibility.js +23 -0
  128. package/src/clients/contract.js +224 -0
  129. package/src/clients/fake.js +170 -0
  130. package/src/clients/process-runner.js +657 -0
  131. package/src/clients/result-contract.js +78 -0
  132. package/src/commands/delivery-publish.js +80 -0
  133. package/src/commands/delivery-remote.js +230 -0
  134. package/src/commands/delivery-review-update.js +39 -0
  135. package/src/commands/delivery-tracker.js +94 -0
  136. package/src/commands/delivery-transition.js +79 -0
  137. package/src/commands/delivery.js +223 -0
  138. package/src/commands/dependency-approval.js +21 -0
  139. package/src/commands/doctor.js +191 -0
  140. package/src/commands/evidence.js +44 -0
  141. package/src/commands/feature.js +212 -0
  142. package/src/commands/goals.js +231 -0
  143. package/src/commands/human-run.js +208 -0
  144. package/src/commands/human-task.js +278 -0
  145. package/src/commands/init.js +848 -0
  146. package/src/commands/install.js +814 -0
  147. package/src/commands/integration-setup.js +65 -0
  148. package/src/commands/integrations.js +54 -0
  149. package/src/commands/models.js +158 -0
  150. package/src/commands/orchestrate.js +61 -0
  151. package/src/commands/preflight.js +125 -0
  152. package/src/commands/protocols.js +228 -0
  153. package/src/commands/repositories.js +77 -0
  154. package/src/commands/setup-remote.js +41 -0
  155. package/src/commands/setup.js +214 -0
  156. package/src/commands/status.js +224 -0
  157. package/src/commands/support.js +44 -0
  158. package/src/commands/task-completion.js +104 -0
  159. package/src/commands/uninstall.js +234 -0
  160. package/src/commands/verify.js +171 -0
  161. package/src/commands/work.js +220 -0
  162. package/src/commands/worktrees.js +52 -0
  163. package/src/config/command-readiness.js +218 -0
  164. package/src/config/commands.js +206 -0
  165. package/src/config/defaults.js +55 -0
  166. package/src/config/load.js +230 -0
  167. package/src/config/validate.js +560 -0
  168. package/src/delivery/bitbucket-review.js +123 -0
  169. package/src/delivery/branch-publication.js +23 -0
  170. package/src/delivery/contract.js +379 -0
  171. package/src/delivery/github-deployment.js +132 -0
  172. package/src/delivery/github-review.js +5 -0
  173. package/src/delivery/github.js +382 -0
  174. package/src/delivery/gitlab-review.js +5 -0
  175. package/src/delivery/gitlab.js +395 -0
  176. package/src/delivery/prepare.js +90 -0
  177. package/src/delivery/publication-process.js +33 -0
  178. package/src/delivery/publication-transport.js +112 -0
  179. package/src/delivery/review-request.js +184 -0
  180. package/src/delivery/review-update.js +74 -0
  181. package/src/delivery/service.js +400 -0
  182. package/src/delivery/store.js +76 -0
  183. package/src/delivery/tracker-target.js +51 -0
  184. package/src/delivery/tracker-transition.js +158 -0
  185. package/src/delivery/tracker.js +164 -0
  186. package/src/discovery/git.js +85 -0
  187. package/src/discovery/portable.js +108 -0
  188. package/src/discovery/project.js +345 -0
  189. package/src/discovery/tools.js +145 -0
  190. package/src/evaluations/approval.js +20 -0
  191. package/src/evaluations/cost-policy.js +20 -0
  192. package/src/evaluations/harness-attempt.js +113 -0
  193. package/src/evaluations/live-runner.js +55 -0
  194. package/src/evaluations/profile.js +28 -0
  195. package/src/evaluations/report.js +11 -0
  196. package/src/evaluations/text-attempt.js +18 -0
  197. package/src/evidence/checksum.js +87 -0
  198. package/src/evidence/collect.js +727 -0
  199. package/src/evidence/validate.js +354 -0
  200. package/src/feature/accepted-integration.js +46 -0
  201. package/src/feature/actions.js +37 -0
  202. package/src/feature/client-profile.js +56 -0
  203. package/src/feature/decomposition-contract.js +58 -0
  204. package/src/feature/host-execution.js +633 -0
  205. package/src/feature/host-lock-recovery.js +96 -0
  206. package/src/feature/host-run-lock.js +32 -0
  207. package/src/feature/local-approval.js +183 -0
  208. package/src/feature/plan-contract.js +215 -0
  209. package/src/feature/planner.js +253 -0
  210. package/src/feature/run-store.js +241 -0
  211. package/src/feature/runtime-bridge.js +840 -0
  212. package/src/feature/verification-report.js +168 -0
  213. package/src/feature/workflow.js +375 -0
  214. package/src/git/client.js +509 -0
  215. package/src/git/integration-worktree.js +153 -0
  216. package/src/git/reconcile.js +222 -0
  217. package/src/git/reservations.js +450 -0
  218. package/src/git/worktrees.js +478 -0
  219. package/src/graph/completion.js +189 -0
  220. package/src/graph/fixtures.js +116 -0
  221. package/src/graph/reducer.js +124 -0
  222. package/src/graph/scheduler.js +252 -0
  223. package/src/graph/validate.js +106 -0
  224. package/src/install/managed.js +579 -0
  225. package/src/install/project-reference.js +33 -0
  226. package/src/install/project-runtime.js +142 -0
  227. package/src/install/runtime-integrity.cjs +141 -0
  228. package/src/integrations/capabilities.js +69 -0
  229. package/src/integrations/host-observation.js +26 -0
  230. package/src/integrations/registry.js +56 -0
  231. package/src/models/delegate.js +102 -0
  232. package/src/models/profiles.js +53 -0
  233. package/src/models/protocols.js +208 -0
  234. package/src/models/registry.js +83 -0
  235. package/src/models/transport.js +59 -0
  236. package/src/policy/approvals.js +251 -0
  237. package/src/policy/authority.js +282 -0
  238. package/src/policy/budget.js +149 -0
  239. package/src/policy/command-bootstrap.js +219 -0
  240. package/src/policy/commands.js +803 -0
  241. package/src/prompts/launch-contract.js +43 -0
  242. package/src/prompts/planning-contract.js +92 -0
  243. package/src/protocols/presentation.js +53 -0
  244. package/src/protocols/project.js +289 -0
  245. package/src/quality/runner.js +497 -0
  246. package/src/quality/traceability.js +215 -0
  247. package/src/repositories/identity.js +59 -0
  248. package/src/repositories/index.js +2 -0
  249. package/src/repositories/provider.js +210 -0
  250. package/src/runtime/application.js +353 -0
  251. package/src/runtime/dependency-directory.js +33 -0
  252. package/src/runtime/harness-discovery.js +121 -0
  253. package/src/runtime/heartbeat.js +47 -0
  254. package/src/runtime/instance-store.js +112 -0
  255. package/src/runtime/orchestrator.js +1133 -0
  256. package/src/runtime/portable-dependencies.js +94 -0
  257. package/src/runtime/recovery.js +190 -0
  258. package/src/runtime/retry.js +54 -0
  259. package/src/runtime/supervisor.js +166 -0
  260. package/src/runtime/worktree-bootstrap.js +169 -0
  261. package/src/state/event-store.js +333 -0
  262. package/src/state/lock.js +266 -0
  263. package/src/state/paths.js +274 -0
  264. package/src/state/redact.js +106 -0
  265. package/src/state/snapshot-store.js +310 -0
  266. package/src/status/public/app.js +96 -0
  267. package/src/status/public/index.html +37 -0
  268. package/src/status/public/styles.css +50 -0
  269. package/src/status/server.js +323 -0
  270. package/src/status/view-model.js +356 -0
  271. package/src/support/bundle.js +195 -0
  272. package/src/support/failure-report.js +96 -0
  273. package/src/work-request/contract.js +250 -0
  274. package/src/work-request/host.js +78 -0
  275. package/src/work-request/local.js +163 -0
  276. package/src/work-request/tracker.js +105 -0
  277. package/templates/evidence/qa-bundle.json +194 -0
  278. package/templates/github-actions/rivet-deploy.yml +98 -0
  279. package/templates/harness/SKILL.md +90 -0
  280. package/templates/project/.rivet/orchestration.yaml +42 -0
  281. package/templates/project/.rivet/project.yaml +14 -0
  282. package/templates/project/.rivet/providers.yaml +8 -0
  283. package/templates/project/.rivet/quality.yaml +16 -0
@@ -0,0 +1,1133 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ import { evaluateAuthority } from '../policy/authority.js';
4
+ import { claimApproval } from '../policy/approvals.js';
5
+ import { captureWorktree, containsSecretMaterial, immutableJson } from '../clients/contract.js';
6
+ import { validateEvent } from '../config/validate.js';
7
+ import { reduceGraph } from '../graph/reducer.js';
8
+ import { scheduleReadyNodes } from '../graph/scheduler.js';
9
+ import { validatedGraphSnapshot } from '../graph/validate.js';
10
+ import { createRetryPolicy, MAX_RETRY_DELAY_MS, retryDecision, retryPolicySchedule } from './retry.js';
11
+ import { createHeartbeat, heartbeatStatus } from './heartbeat.js';
12
+ import { cancelGoal as recoverCancelGoal, cancelNode as recoverCancelNode, recoverStalledNode as recoverStalled, retryNode as recoverRetryNode } from './recovery.js';
13
+
14
+ const TERMINAL = new Set(['complete', 'completed', 'blocked', 'cancelled', 'budget-exhausted', 'failed']);
15
+ const INTENT_STATUSES = new Set(['committed', 'prepared', 'started', 'complete', 'recovered']);
16
+ const INTENT_KEYS = Object.freeze(['id', 'nodeId', 'reservationId', 'attempt', 'idempotencyKey', 'allocation', 'status', 'eventSequence']);
17
+ const INTENT_STATUS_KEYS = Object.freeze({
18
+ committed: Object.freeze(['preparationClaim']),
19
+ prepared: Object.freeze(['worktree']),
20
+ started: Object.freeze(['startedAtMs', 'worktree']),
21
+ complete: Object.freeze(['startedAtMs', 'worktree', 'retryAtMs']),
22
+ recovered: Object.freeze(['startedAtMs', 'worktree']),
23
+ });
24
+
25
+ export class RuntimeError extends Error {
26
+ constructor(reason = 'invalid-runtime-input') {
27
+ const messages = { 'approval-required': 'Runtime activation requires valid human approval.', 'version-conflict': 'Runtime state version changed concurrently.', 'not-activated': 'Runtime instance is not activated.', 'invalid-runtime-input': 'Runtime request is invalid.', 'client-output': 'Agent client output is invalid.' };
28
+ super(messages[reason] ?? messages['invalid-runtime-input']);
29
+ this.name = 'RuntimeError';
30
+ this.code = `ERR_RUNTIME_${reason.replaceAll('-', '_').toUpperCase()}`;
31
+ this.safeMessage = this.message;
32
+ this.details = Object.freeze({ reason });
33
+ }
34
+ }
35
+
36
+ function fail(reason) { throw new RuntimeError(reason); }
37
+ function clone(value) { try { return structuredClone(value); } catch { fail('invalid-runtime-input'); } }
38
+ function exactVersion(value) { if (!Number.isSafeInteger(value) || value < 0 || value >= Number.MAX_SAFE_INTEGER) fail('invalid-runtime-input'); return value; }
39
+ function iso(nowMs) { if (!Number.isSafeInteger(nowMs) || nowMs < 0) fail('invalid-runtime-input'); return new Date(nowMs).toISOString(); }
40
+ function safeId(value) { return typeof value === 'string' && /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value) && value.length <= 64; }
41
+ function launchIntentId(nodeId, attempt) {
42
+ if (!safeId(nodeId) || !Number.isSafeInteger(attempt) || attempt < 1 || attempt > 10_000) fail('invalid-runtime-input');
43
+ const direct = `launch-${nodeId}-${attempt}`;
44
+ if (safeId(direct)) return direct;
45
+ const digest = createHash('sha256').update(nodeId).digest('hex').slice(0, 40);
46
+ return `launch-${digest}-${attempt}`;
47
+ }
48
+ function evidenceBindings(node, pinned = new Map()) {
49
+ if (node.evidenceRefs.length !== node.requiredEvidenceTypes.length) fail('invalid-runtime-input');
50
+ const references = [...node.evidenceRefs].sort(); const types = [...node.requiredEvidenceTypes].sort();
51
+ const bindings = new Map();
52
+ for (const [id, type] of pinned) {
53
+ const referenceIndex = references.indexOf(id); const typeIndex = types.indexOf(type);
54
+ if (referenceIndex < 0 || typeIndex < 0) fail('invalid-runtime-input');
55
+ references.splice(referenceIndex, 1); types.splice(typeIndex, 1); bindings.set(id, type);
56
+ }
57
+ for (let index = 0; index < references.length; index += 1) bindings.set(references[index], types[index]);
58
+ return bindings;
59
+ }
60
+
61
+ function captureOptions(input, allowed, required = []) {
62
+ try {
63
+ if (!input || typeof input !== 'object' || Array.isArray(input)) fail('invalid-runtime-input');
64
+ const keys = Reflect.ownKeys(input);
65
+ if (keys.some(key => typeof key !== 'string' || !allowed.includes(key))) fail('invalid-runtime-input');
66
+ const output = {};
67
+ for (const key of keys) output[key] = input[key];
68
+ if (required.some(key => !Object.hasOwn(output, key))) fail('invalid-runtime-input');
69
+ return output;
70
+ } catch (error) { if (error instanceof RuntimeError) throw error; fail('invalid-runtime-input'); }
71
+ }
72
+
73
+ function assertInstance(instance) {
74
+ if (!instance || typeof instance !== 'object' || !safeId(instance.id) || typeof instance.acquire !== 'function' || typeof instance.read !== 'function' || typeof instance.commit !== 'function') fail('invalid-runtime-input');
75
+ }
76
+
77
+ function canonicalState(input, expectedInstanceId, retryDelaysMs = null) {
78
+ const state = clone(input);
79
+ if (!state || typeof state !== 'object' || Array.isArray(state) || !Number.isSafeInteger(state.version)
80
+ || state.version < 0 || !Array.isArray(state.events) || state.events.length > 10_000) fail('invalid-runtime-input');
81
+ if (typeof state.activated !== 'boolean') fail('invalid-runtime-input');
82
+ state.graph = validatedGraphSnapshot(state.graph);
83
+ for (const node of state.graph.nodes) {
84
+ if (node.requiredEvidenceTypes.includes('human-approval')
85
+ && !(node.owner.role === 'boss' && (!node.parentId || node.approvalGate))) fail('invalid-runtime-input');
86
+ }
87
+ const bindingsByNode = new Map(state.graph.nodes.map(node => [node.id, evidenceBindings(node)]));
88
+ for (let index = 0; index < state.events.length; index += 1) {
89
+ try { validateEvent(state.events[index]); } catch { fail('invalid-runtime-input'); }
90
+ if (state.events[index].sequence !== index + 1 || state.events[index].graphId !== state.graph.id) fail('invalid-runtime-input');
91
+ }
92
+ const applied = state.appliedSequence ?? state.events.length;
93
+ if (!Number.isSafeInteger(applied) || applied < 0 || applied > state.events.length) fail('invalid-runtime-input');
94
+ state.attempts ??= {}; state.launchIntents ??= {}; state.heartbeats ??= {}; state.evidence ??= [];
95
+ state.lastHeartbeatAt ??= {};
96
+ if (state.nodeUsage === undefined) state.nodeUsage = {};
97
+ if (state.delegatedUsage === undefined) state.delegatedUsage = {};
98
+ if (state.usage === undefined) state.usage = { tokens: 0, costUsd: '0', retries: 0, timeMinutes: 0, taskLimit: 0 };
99
+ const nodesById = new Map(state.graph.nodes.map(node => [node.id, node]));
100
+ for (const [nodeId, intent] of Object.entries(state.launchIntents)) {
101
+ if (!safeId(nodeId) || !intent || typeof intent !== 'object' || Array.isArray(intent) || intent.nodeId !== nodeId || !nodesById.has(nodeId)
102
+ || !safeId(intent.id) || !safeId(intent.reservationId) || !Number.isSafeInteger(intent.attempt) || intent.attempt < 1 || intent.attempt > 10_000
103
+ || !Number.isSafeInteger(intent.eventSequence) || intent.eventSequence < 0 || intent.eventSequence > 10_000
104
+ || !INTENT_STATUSES.has(intent.status)) fail('invalid-runtime-input');
105
+ const allowedIntentKeys = new Set([...INTENT_KEYS, ...INTENT_STATUS_KEYS[intent.status]]);
106
+ const intentKeys = Reflect.ownKeys(intent);
107
+ if (intentKeys.some(key => typeof key !== 'string' || !allowedIntentKeys.has(key))
108
+ || INTENT_KEYS.some(key => !Object.hasOwn(intent, key))
109
+ || (intent.status === 'prepared' && !Object.hasOwn(intent, 'worktree'))
110
+ || !safeId(expectedInstanceId) || intent.idempotencyKey !== `${expectedInstanceId}:${nodeId}:${intent.attempt}`) fail('invalid-runtime-input');
111
+ const allocation = intent.allocation; const nodeBudget = nodesById.get(nodeId).budget;
112
+ if (!allocation || typeof allocation !== 'object' || Array.isArray(allocation)
113
+ || Reflect.ownKeys(allocation).length !== 4
114
+ || ['timeMinutes', 'tokenLimit', 'costUsd', 'taskLimit'].some(key => !Object.hasOwn(allocation, key))
115
+ || !Number.isSafeInteger(allocation.timeMinutes) || allocation.timeMinutes < 1 || allocation.timeMinutes > nodeBudget.timeMinutes
116
+ || !Number.isSafeInteger(allocation.tokenLimit) || allocation.tokenLimit < 1 || allocation.tokenLimit > nodeBudget.tokenLimit
117
+ || !Number.isSafeInteger(allocation.taskLimit) || allocation.taskLimit < 1 || allocation.taskLimit > nodeBudget.taskLimit
118
+ || typeof allocation.costUsd !== 'string' || decimalCompare(allocation.costUsd, 0) <= 0
119
+ || decimalCompare(allocation.costUsd, nodeBudget.costUsd) > 0) fail('invalid-runtime-input');
120
+ const claim = intent?.preparationClaim;
121
+ if (claim !== undefined && (!claim || typeof claim !== 'object' || Array.isArray(claim) || Reflect.ownKeys(claim).length !== 2
122
+ || !safeId(claim.id) || !Number.isSafeInteger(claim.claimedAtMs) || claim.claimedAtMs < 0
123
+ || intent.status !== 'committed')) fail('invalid-runtime-input');
124
+ if ((Object.hasOwn(intent, 'startedAtMs') && (!Number.isSafeInteger(intent.startedAtMs) || intent.startedAtMs < 0))
125
+ || (Object.hasOwn(intent, 'retryAtMs') && (!Number.isSafeInteger(intent.retryAtMs) || intent.retryAtMs < 0))) fail('invalid-runtime-input');
126
+ if (intent?.status === 'started' && (intent.nodeId !== nodeId || !nodesById.has(nodeId)
127
+ || !Number.isSafeInteger(intent.startedAtMs) || intent.startedAtMs < 0)) fail('invalid-runtime-input');
128
+ if (Object.hasOwn(intent, 'worktree')) {
129
+ let worktree;
130
+ try { worktree = captureWorktree(intent.worktree); } catch { fail('invalid-runtime-input'); }
131
+ if (worktree.reservationId !== intent.reservationId) fail('invalid-runtime-input');
132
+ intent.worktree = worktree;
133
+ }
134
+ }
135
+ for (let index = applied; index < state.events.length; index += 1) {
136
+ const pending = state.events[index];
137
+ if (['state-transition', 'cancelled'].includes(pending.type)) state.graph = reduceGraph(state.graph, pending);
138
+ else if (pending.type === 'retry') {
139
+ state.attempts[pending.nodeId] = (state.attempts[pending.nodeId] ?? 1) + 1;
140
+ state.usage.retries += 1;
141
+ } else if (pending.type === 'heartbeat') {
142
+ const node = state.graph.nodes.find(item => item.id === pending.nodeId); const intent = state.launchIntents[pending.nodeId];
143
+ const heartbeat = createHeartbeat({
144
+ version: 1,
145
+ instanceId: pending.instanceId,
146
+ nodeId: pending.nodeId,
147
+ actorId: pending.actor.id,
148
+ leaseId: pending.leaseId,
149
+ sequence: pending.heartbeatSequence,
150
+ timestampMs: Date.parse(pending.timestamp),
151
+ intervalMs: pending.heartbeatIntervalMs,
152
+ });
153
+ const prior = state.heartbeats[pending.nodeId] ? createHeartbeat(state.heartbeats[pending.nodeId]) : null;
154
+ const same = prior && Object.keys(heartbeat).every(key => heartbeat[key] === prior[key]);
155
+ if (!safeId(expectedInstanceId) || !node || !intent || heartbeat.instanceId !== expectedInstanceId || heartbeat.nodeId !== node.id || heartbeat.actorId !== node.owner.id || pending.actor.role !== node.owner.role
156
+ || heartbeat.leaseId !== intent.reservationId
157
+ || (prior && !same && (heartbeat.sequence <= prior.sequence || heartbeat.timestampMs < prior.timestampMs))) fail('invalid-runtime-input');
158
+ state.heartbeats[pending.nodeId] = heartbeat; state.lastHeartbeatAt[pending.nodeId] = heartbeat.timestampMs;
159
+ }
160
+ else if (pending.type === 'evidence-recorded' || pending.type === 'approval-recorded') {
161
+ const approvalReceiptId = pending.type === 'approval-recorded' ? pending.approvalReceiptId : null;
162
+ if (approvalReceiptId && (pending.actor.role !== 'human' || !pending.evidenceRefs.includes(approvalReceiptId))) fail('invalid-runtime-input');
163
+ if (!pending.nodeId) { if (pending.type === 'approval-recorded') state.activated = true; }
164
+ else {
165
+ const node = state.graph.nodes.find(item => item.id === pending.nodeId);
166
+ if (!node) fail('invalid-runtime-input');
167
+ if (approvalReceiptId) {
168
+ const actual = [...pending.evidenceRefs].sort(); const expected = [...node.evidenceRefs].sort();
169
+ if (node.owner.role !== 'boss' || !node.approvalGate || actual.length !== expected.length
170
+ || actual.some((item, index) => item !== expected[index])) fail('invalid-runtime-input');
171
+ }
172
+ const bindings = approvalReceiptId
173
+ ? evidenceBindings(node, new Map([[approvalReceiptId, 'human-approval']]))
174
+ : bindingsByNode.get(node.id);
175
+ for (const id of pending.evidenceRefs) {
176
+ const type = bindings.get(id);
177
+ if (!type) fail('invalid-runtime-input');
178
+ if (!state.evidence.some(item => item.id === id && item.nodeId === node.id)) state.evidence.push({ id, nodeId: node.id, type, approvalState: 'approved' });
179
+ }
180
+ }
181
+ } else if (pending.type === 'authority-adjusted') {
182
+ const node = state.graph.nodes.find(item => item.id === pending.nodeId); if (!node) fail('invalid-runtime-input');
183
+ const scopes = new Set(node.authorityScopes); for (const value of pending.authorityDelta.removed) scopes.delete(value); for (const value of pending.authorityDelta.added) scopes.add(value);
184
+ node.authorityScopes = [...scopes].sort(); state.graph = validatedGraphSnapshot(state.graph);
185
+ } else if (pending.type === 'budget-adjusted') {
186
+ const node = state.graph.nodes.find(item => item.id === pending.nodeId); if (!node) fail('invalid-runtime-input');
187
+ for (const [key, delta] of Object.entries(pending.budgetDelta)) node.budget[key] += delta;
188
+ state.graph = validatedGraphSnapshot(state.graph);
189
+ } else if (pending.type !== 'graph-created') fail('invalid-runtime-input');
190
+ }
191
+ const reducedNodesById = new Map(state.graph.nodes.map(node => [node.id, node]));
192
+ for (const [nodeId, intent] of Object.entries(state.launchIntents)) {
193
+ const nodeStatus = reducedNodesById.get(nodeId)?.status;
194
+ if ((['committed', 'prepared'].includes(intent.status) && nodeStatus !== 'reserved')
195
+ || (intent.status === 'started' && nodeStatus !== 'running')
196
+ || (intent.status === 'recovered' && nodeStatus !== 'ready')
197
+ || (intent.status === 'complete' && ['reserved', 'running'].includes(nodeStatus))) fail('invalid-runtime-input');
198
+ }
199
+ for (const node of state.graph.nodes) {
200
+ const intentStatus = state.launchIntents[node.id]?.status;
201
+ if ((node.status === 'reserved' && !['committed', 'prepared'].includes(intentStatus))
202
+ || (node.status === 'running' && intentStatus !== 'started')) fail('invalid-runtime-input');
203
+ }
204
+ validateDurableIntentRelations(state, retryDelaysMs);
205
+ state.appliedSequence = state.events.length;
206
+ state.terminal ??= null;
207
+ return state;
208
+ }
209
+
210
+ /** Validate a saved default-policy host runtime without acquiring locks or writing state. */
211
+ export function validateRuntimeSnapshot(input, instanceId) {
212
+ return immutableJson(canonicalState(input, instanceId, [0, 0]));
213
+ }
214
+
215
+ async function transaction(instance, expectedVersion, operation, retryDelaysMs = null) {
216
+ assertInstance(instance);
217
+ const lock = await instance.acquire();
218
+ if (!lock || typeof lock.release !== 'function') fail('invalid-runtime-input');
219
+ try {
220
+ const current = canonicalState(await instance.read(), instance.id, retryDelaysMs);
221
+ if (exactVersion(current.version) !== exactVersion(expectedVersion)) fail('version-conflict');
222
+ const priorVersion = current.version;
223
+ const next = await operation(current);
224
+ if (next === null) return current;
225
+ next.version = priorVersion + 1;
226
+ next.appliedSequence = next.events.length;
227
+ const validatedNext = canonicalState(next, instance.id, retryDelaysMs);
228
+ return canonicalState(await instance.commit(priorVersion, validatedNext), instance.id, retryDelaysMs);
229
+ } catch (error) {
230
+ if (error?.code === 'ERR_STATE_VERSION_CONFLICT') fail('version-conflict');
231
+ throw error;
232
+ } finally { await lock.release(); }
233
+ }
234
+
235
+ export { transaction as runtimeTransaction };
236
+
237
+ function event(state, nowMs, values) {
238
+ if (!Array.isArray(state.events) || state.events.length >= 10_000) fail('invalid-runtime-input');
239
+ const sequence = state.events.length + 1;
240
+ const persisted = { schemaVersion: 1, eventId: `runtime-${sequence}`, graphId: state.graph.id, sequence, timestamp: iso(nowMs), actor: values.actor, ...values.event };
241
+ try { validateEvent(persisted); } catch { fail('invalid-runtime-input'); }
242
+ state.events.push(persisted); state.appliedSequence = state.events.length;
243
+ return persisted;
244
+ }
245
+
246
+ export { event as appendRuntimeEvent };
247
+
248
+ function transition(state, node, status, nowMs, actor = { role: 'system', id: 'runtime' }) {
249
+ const graphTarget = Array.isArray(node?.nodes);
250
+ const nodeId = graphTarget ? null : node?.id;
251
+ const target = graphTarget ? state.graph : state.graph.nodes.find(item => item.id === nodeId);
252
+ if (!target) fail('invalid-runtime-input');
253
+ const persisted = event(state, nowMs, { actor, event: { type: 'state-transition', ...(nodeId ? { nodeId } : {}), priorState: target.status, newState: status } });
254
+ state.graph = reduceGraph(state.graph, persisted);
255
+ return nodeId ? state.graph.nodes.find(item => item.id === nodeId) : state.graph;
256
+ }
257
+
258
+ export { transition as transitionRuntimeState };
259
+
260
+ function decimal(value) {
261
+ const source = typeof value === 'number' && Number.isFinite(value) ? String(value) : value;
262
+ const match = typeof source === 'string' && source.length <= 64 ? /^(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(source) : null;
263
+ if (!match) fail('invalid-runtime-input');
264
+ const exponent = Number(match[3] ?? 0);
265
+ if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > 100) fail('invalid-runtime-input');
266
+ let digits = `${match[1]}${match[2] ?? ''}`.replace(/^0+(?=\d)/, '');
267
+ let scale = (match[2]?.length ?? 0) - exponent;
268
+ if (scale < 0) { digits += '0'.repeat(-scale); scale = 0; }
269
+ while (scale > 0 && digits.endsWith('0')) { digits = digits.slice(0, -1); scale -= 1; }
270
+ return { coefficient: BigInt(digits || '0'), scale };
271
+ }
272
+
273
+ function decimalAt(value, scale) { return value.coefficient * (10n ** BigInt(scale - value.scale)); }
274
+ function decimalCompare(leftValue, rightValue) {
275
+ const left = decimal(leftValue); const right = decimal(rightValue); const scale = Math.max(left.scale, right.scale);
276
+ const leftScaled = decimalAt(left, scale); const rightScaled = decimalAt(right, scale);
277
+ return leftScaled < rightScaled ? -1 : leftScaled > rightScaled ? 1 : 0;
278
+ }
279
+ function decimalText(coefficient, scale) {
280
+ while (scale > 0 && coefficient % 10n === 0n) { coefficient /= 10n; scale -= 1; }
281
+ const digits = coefficient.toString().padStart(scale + 1, '0');
282
+ return scale === 0 ? digits : `${digits.slice(0, -scale)}.${digits.slice(-scale)}`;
283
+ }
284
+ function decimalMath(leftValue, rightValue, subtract = false) {
285
+ const left = decimal(leftValue); const right = decimal(rightValue); const scale = Math.max(left.scale, right.scale);
286
+ const result = decimalAt(left, scale) + (subtract ? -decimalAt(right, scale) : decimalAt(right, scale));
287
+ if (result < 0n) fail('invalid-runtime-input');
288
+ return decimalText(result, scale);
289
+ }
290
+
291
+ const NODE_USAGE_KEYS = Object.freeze(['tokens', 'costUsd', 'timeMinutes', 'tasks']);
292
+ const GLOBAL_USAGE_KEYS = Object.freeze(['tokens', 'costUsd', 'retries', 'timeMinutes', 'taskLimit']);
293
+ function zeroNodeUsage() { return { tokens: 0, costUsd: '0', timeMinutes: 0, tasks: 0 }; }
294
+ function exactRecord(value, keys) {
295
+ if (!value || typeof value !== 'object' || Array.isArray(value)
296
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail('invalid-runtime-input');
297
+ const ownKeys = Reflect.ownKeys(value);
298
+ if (ownKeys.length !== keys.length || ownKeys.some(key => typeof key !== 'string' || !keys.includes(key))
299
+ || keys.some(key => !Object.hasOwn(value, key))) fail('invalid-runtime-input');
300
+ }
301
+ function validateNodeUsage(value) {
302
+ exactRecord(value, NODE_USAGE_KEYS);
303
+ if (!Number.isSafeInteger(value.tokens) || value.tokens < 0
304
+ || !Number.isSafeInteger(value.timeMinutes) || value.timeMinutes < 0
305
+ || !Number.isSafeInteger(value.tasks) || value.tasks < 0
306
+ || decimalCompare(value.costUsd, 0) < 0) fail('invalid-runtime-input');
307
+ }
308
+ function addNodeUsage(total, value) {
309
+ total.tokens += value.tokens; total.timeMinutes += value.timeMinutes; total.tasks += value.tasks;
310
+ if (!Number.isSafeInteger(total.tokens) || !Number.isSafeInteger(total.timeMinutes) || !Number.isSafeInteger(total.tasks)) fail('invalid-runtime-input');
311
+ total.costUsd = decimalMath(total.costUsd, value.costUsd);
312
+ }
313
+ function sameNodeUsage(left, right) {
314
+ return left.tokens === right.tokens && left.timeMinutes === right.timeMinutes && left.tasks === right.tasks
315
+ && decimalCompare(left.costUsd, right.costUsd) === 0;
316
+ }
317
+ function validateUsageLedgers(state) {
318
+ if (!state.usage || typeof state.usage !== 'object' || Array.isArray(state.usage)
319
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(state.usage))) fail('invalid-runtime-input');
320
+ exactRecord(state.usage, GLOBAL_USAGE_KEYS);
321
+ if (!Number.isSafeInteger(state.usage.tokens) || state.usage.tokens < 0
322
+ || !Number.isSafeInteger(state.usage.retries) || state.usage.retries < 0
323
+ || !Number.isSafeInteger(state.usage.timeMinutes) || state.usage.timeMinutes < 0
324
+ || !Number.isSafeInteger(state.usage.taskLimit) || state.usage.taskLimit < 0
325
+ || decimalCompare(state.usage.costUsd, 0) < 0) fail('invalid-runtime-input');
326
+ let retryFacts = 0;
327
+ for (let index = 0; index < state.events.length; index += 1) if (state.events[index].type === 'retry') retryFacts += 1;
328
+ if (state.usage.retries !== retryFacts) fail('invalid-runtime-input');
329
+
330
+ const nodes = new Map(state.graph.nodes.map(node => [node.id, node]));
331
+ const total = zeroNodeUsage(); const expectedDelegated = new Map(state.graph.nodes.map(node => [node.id, zeroNodeUsage()]));
332
+ for (const [name, ledger] of [['nodeUsage', state.nodeUsage], ['delegatedUsage', state.delegatedUsage]]) {
333
+ if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)
334
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(ledger))) fail('invalid-runtime-input');
335
+ const keys = Reflect.ownKeys(ledger);
336
+ if (keys.some(key => typeof key !== 'string' || !nodes.has(key))) fail('invalid-runtime-input');
337
+ for (const key of keys) validateNodeUsage(ledger[key]);
338
+ if (name === 'delegatedUsage') continue;
339
+ for (const nodeId of keys) {
340
+ const value = ledger[nodeId]; addNodeUsage(total, value);
341
+ for (const ancestor of ancestorNodes(state, nodes.get(nodeId))) addNodeUsage(expectedDelegated.get(ancestor.id), value);
342
+ }
343
+ }
344
+ if (state.usage.tokens !== total.tokens || state.usage.timeMinutes !== total.timeMinutes || state.usage.taskLimit !== total.tasks
345
+ || decimalCompare(state.usage.costUsd, total.costUsd) !== 0) fail('invalid-runtime-input');
346
+ for (const nodeId of nodes.keys()) {
347
+ const actual = state.delegatedUsage[nodeId] ?? zeroNodeUsage();
348
+ if (!sameNodeUsage(actual, expectedDelegated.get(nodeId))) fail('invalid-runtime-input');
349
+ }
350
+ }
351
+
352
+ function availableBudget(state) {
353
+ const limits = state.limits ?? {}; const usage = state.usage ?? {};
354
+ const available = {
355
+ tokenLimit: (limits.tokenLimit ?? limits.tokens ?? Number.MAX_SAFE_INTEGER) - (usage.tokens ?? 0),
356
+ costUsd: decimalMath(limits.costUsd ?? '999999999999999', usage.costUsd ?? 0, true),
357
+ timeMinutes: (limits.timeMinutes ?? Number.MAX_SAFE_INTEGER) - (usage.timeMinutes ?? 0),
358
+ taskLimit: (limits.taskLimit ?? Number.MAX_SAFE_INTEGER) - (usage.taskLimit ?? 0),
359
+ retries: (limits.retries ?? 0) - (usage.retries ?? 0),
360
+ };
361
+ for (const intent of Object.values(state.launchIntents ?? {})) {
362
+ if (!['committed', 'prepared', 'started'].includes(intent.status) || !intent.allocation) continue;
363
+ reserveBudget(available, intent.allocation);
364
+ }
365
+ return available;
366
+ }
367
+
368
+ function budgetFits(budget, available) {
369
+ return budget.tokenLimit <= available.tokenLimit && decimalCompare(budget.costUsd, available.costUsd) <= 0
370
+ && budget.timeMinutes <= available.timeMinutes && budget.taskLimit <= available.taskLimit;
371
+ }
372
+
373
+ function reserveBudget(available, budget) {
374
+ available.tokenLimit -= budget.tokenLimit;
375
+ available.costUsd = decimalMath(available.costUsd, budget.costUsd, true);
376
+ available.timeMinutes -= budget.timeMinutes;
377
+ available.taskLimit -= budget.taskLimit;
378
+ }
379
+
380
+ function remainingNodeBudget(state, node) {
381
+ const used = state.nodeUsage?.[node.id] ?? {};
382
+ const budget = {
383
+ tokenLimit: node.budget.tokenLimit - (used.tokens ?? 0),
384
+ costUsd: decimalMath(node.budget.costUsd, used.costUsd ?? 0, true),
385
+ timeMinutes: node.budget.timeMinutes - (used.timeMinutes ?? 0),
386
+ taskLimit: node.budget.taskLimit - (used.tasks ?? 0),
387
+ };
388
+ if (budget.tokenLimit < 0 || budget.timeMinutes < 0 || budget.taskLimit < 0) fail('invalid-runtime-input');
389
+ return budget;
390
+ }
391
+
392
+ export { remainingNodeBudget as remainingRuntimeNodeBudget };
393
+
394
+ function ancestorNodes(state, node) {
395
+ const byId = new Map(state.graph.nodes.map(item => [item.id, item]));
396
+ const ancestors = []; let parentId = node.parentId;
397
+ while (parentId) {
398
+ const parent = byId.get(parentId); if (!parent || ancestors.length >= state.graph.maxDelegationDepth) fail('invalid-runtime-input');
399
+ ancestors.push(parent); parentId = parent.parentId;
400
+ }
401
+ return ancestors;
402
+ }
403
+
404
+ function remainingParentBudget(state, parent) {
405
+ const own = state.nodeUsage?.[parent.id] ?? {}; const delegated = state.delegatedUsage?.[parent.id] ?? {};
406
+ return {
407
+ tokenLimit: parent.budget.tokenLimit - (own.tokens ?? 0) - (delegated.tokens ?? 0),
408
+ costUsd: decimalMath(decimalMath(parent.budget.costUsd, own.costUsd ?? 0, true), delegated.costUsd ?? 0, true),
409
+ timeMinutes: parent.budget.timeMinutes - (own.timeMinutes ?? 0) - (delegated.timeMinutes ?? 0),
410
+ taskLimit: parent.budget.taskLimit - (own.tasks ?? 0) - (delegated.tasks ?? 0),
411
+ };
412
+ }
413
+
414
+ function validateRetryDeadline(state, nodeId, deadlineMs, retryDelaysMs) {
415
+ if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 0 || deadlineMs >= Number.MAX_SAFE_INTEGER) fail('invalid-runtime-input');
416
+ let retryFact = null;
417
+ for (const candidate of state.events) if (candidate.type === 'retry' && candidate.nodeId === nodeId) retryFact = candidate;
418
+ if (!retryFact) fail('invalid-runtime-input');
419
+ const factMs = Date.parse(retryFact.timestamp); const delayMs = deadlineMs - factMs;
420
+ const retryScheduleIndex = state.attempts[nodeId] - 2;
421
+ if (!Number.isSafeInteger(factMs) || !Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > MAX_RETRY_DELAY_MS
422
+ || (retryDelaysMs && (!Number.isSafeInteger(retryScheduleIndex) || retryScheduleIndex < 0
423
+ || retryScheduleIndex >= retryDelaysMs.length || delayMs !== retryDelaysMs[retryScheduleIndex]))) fail('invalid-runtime-input');
424
+ }
425
+
426
+ function validateNonnegativeBudget(value) {
427
+ if (!Number.isSafeInteger(value.tokenLimit) || value.tokenLimit < 0
428
+ || !Number.isSafeInteger(value.timeMinutes) || value.timeMinutes < 0
429
+ || !Number.isSafeInteger(value.taskLimit) || value.taskLimit < 0
430
+ || (Object.hasOwn(value, 'retries') && (!Number.isSafeInteger(value.retries) || value.retries < 0))
431
+ || decimalCompare(value.costUsd, 0) < 0) fail('invalid-runtime-input');
432
+ }
433
+
434
+ function validateDurableIntentRelations(state, retryDelaysMs) {
435
+ validateUsageLedgers(state);
436
+ const activeStatuses = new Set(['committed', 'prepared', 'started']);
437
+ const active = []; const reservations = new Set(); const worktreePaths = new Set(); const worktreeIdentities = new Set();
438
+ for (const [nodeId, intent] of Object.entries(state.launchIntents)) {
439
+ const node = state.graph.nodes.find(item => item.id === nodeId); const recordedAttempt = state.attempts[nodeId];
440
+ if (intent.id !== launchIntentId(nodeId, intent.attempt) || !Number.isSafeInteger(recordedAttempt)
441
+ || recordedAttempt < intent.attempt || recordedAttempt > 10_000) fail('invalid-runtime-input');
442
+ if (activeStatuses.has(intent.status)) {
443
+ if (recordedAttempt !== intent.attempt || (retryDelaysMs && intent.attempt > retryDelaysMs.length + 1)
444
+ || reservations.has(intent.reservationId)) fail('invalid-runtime-input');
445
+ reservations.add(intent.reservationId); active.push({ node, intent });
446
+ if (intent.worktree) {
447
+ const identity = `${intent.worktree.dev}:${intent.worktree.ino}`;
448
+ if (worktreePaths.has(intent.worktree.path) || worktreeIdentities.has(identity)) fail('invalid-runtime-input');
449
+ worktreePaths.add(intent.worktree.path); worktreeIdentities.add(identity);
450
+ }
451
+ const remaining = remainingNodeBudget(state, node);
452
+ if (intent.allocation.tokenLimit !== remaining.tokenLimit || intent.allocation.timeMinutes !== remaining.timeMinutes
453
+ || intent.allocation.taskLimit !== remaining.taskLimit || decimalCompare(intent.allocation.costUsd, remaining.costUsd) !== 0) fail('invalid-runtime-input');
454
+ }
455
+ if (Object.hasOwn(intent, 'retryAtMs')) {
456
+ if (intent.status !== 'complete' || !['corrective', 'ready'].includes(node.status)) fail('invalid-runtime-input');
457
+ validateRetryDeadline(state, nodeId, intent.retryAtMs, retryDelaysMs);
458
+ }
459
+ }
460
+ if (state.retryAtMs !== undefined) {
461
+ if (!state.retryAtMs || typeof state.retryAtMs !== 'object' || Array.isArray(state.retryAtMs)) fail('invalid-runtime-input');
462
+ const retryKeys = Reflect.ownKeys(state.retryAtMs);
463
+ if (retryKeys.length > state.graph.nodes.length || retryKeys.some(key => typeof key !== 'string')) fail('invalid-runtime-input');
464
+ for (const nodeId of retryKeys) {
465
+ const node = state.graph.nodes.find(item => item.id === nodeId); const intent = state.launchIntents[nodeId];
466
+ if (!safeId(nodeId) || !node || node.status !== 'corrective' || Object.hasOwn(intent ?? {}, 'retryAtMs')) fail('invalid-runtime-input');
467
+ validateRetryDeadline(state, nodeId, state.retryAtMs[nodeId], retryDelaysMs);
468
+ }
469
+ }
470
+ // Observed exhaustion may exceed the approved allocation. Retain those
471
+ // measurements in the ledgers; a terminal blocked graph grants no new work.
472
+ if (state.terminal === 'budget-exhausted' && state.graph.status === 'blocked') return;
473
+ validateNonnegativeBudget(availableBudget(state));
474
+ const parentAvailability = new Map(state.graph.nodes.map(parent => [parent.id, remainingParentBudget(state, parent)]));
475
+ for (const { node, intent } of active) for (const parent of ancestorNodes(state, node)) reserveBudget(parentAvailability.get(parent.id), intent.allocation);
476
+ for (const remaining of parentAvailability.values()) validateNonnegativeBudget(remaining);
477
+ }
478
+
479
+ function captureResult(input) {
480
+ try {
481
+ const value = captureOptions(input, ['version', 'status', 'output', 'usage'], ['version', 'status', 'output', 'usage']);
482
+ const output = immutableJson(value.output);
483
+ const usage = immutableJson(value.usage);
484
+ if (value.version !== 1 || !['success', 'retry', 'failed', 'blocked', 'budget-exhausted'].includes(value.status)
485
+ || Reflect.ownKeys(output).some(key => !['summary', 'evidence'].includes(key))
486
+ || Reflect.ownKeys(usage).some(key => !['tokens', 'costUsd', 'timeMinutes', 'tasks'].includes(key))
487
+ || typeof output.summary !== 'string' || output.summary.length < 1 || containsSecretMaterial(output.summary)
488
+ || !Array.isArray(output.evidence) || output.evidence.length > 64
489
+ || output.evidence.some(item => !safeId(item) || containsSecretMaterial(item))
490
+ || new Set(output.evidence).size !== output.evidence.length
491
+ || !Number.isSafeInteger(usage.tokens) || usage.tokens < 0
492
+ || typeof usage.costUsd !== 'number' || !Number.isFinite(usage.costUsd) || usage.costUsd < 0
493
+ || (usage.timeMinutes !== undefined && (!Number.isSafeInteger(usage.timeMinutes) || usage.timeMinutes < 0))
494
+ || (usage.tasks !== undefined && (!Number.isSafeInteger(usage.tasks) || usage.tasks < 0))) fail('client-output');
495
+ return Object.freeze({ version: 1, status: value.status, output, usage });
496
+ } catch (error) {
497
+ if (error instanceof RuntimeError) throw error;
498
+ fail('client-output');
499
+ }
500
+ }
501
+
502
+ function resultWithinAllocation(result, allocation, elapsed = 0) {
503
+ return result.usage.tokens <= allocation.tokenLimit
504
+ && decimalCompare(result.usage.costUsd, allocation.costUsd) <= 0
505
+ && (result.usage.timeMinutes ?? elapsed) <= allocation.timeMinutes
506
+ && (result.usage.tasks ?? 1) <= allocation.taskLimit;
507
+ }
508
+
509
+ function classify(error) {
510
+ let code;
511
+ try { code = error?.code; } catch { return 'malformed-output'; }
512
+ if (code === 'ERR_AGENT_TIMEOUT' || code === 'ERR_AGENT_PROVIDER_UNAVAILABLE') return 'provider-transient';
513
+ if (code === 'ERR_AGENT_ABORTED') return 'cancelled';
514
+ return 'malformed-output';
515
+ }
516
+
517
+ function deriveTerminal(state, nowMs) {
518
+ if (state.terminal === 'cancelled' || state.terminal === 'budget-exhausted') return;
519
+ const statuses = state.graph.nodes.map(node => node.status);
520
+ let terminal = null;
521
+ if (statuses.includes('failed')) terminal = 'failed';
522
+ else if (statuses.includes('blocked')) terminal = 'blocked';
523
+ else if (statuses.every(status => ['completed', 'archived'].includes(status))) terminal = 'completed';
524
+ if (!terminal) return;
525
+ state.terminal = terminal;
526
+ if (terminal === 'completed' && state.graph.status === 'running') transition(state, state.graph, 'verifying', nowMs);
527
+ if (state.graph.status !== terminal) transition(state, state.graph, terminal, nowMs);
528
+ }
529
+
530
+ export function createOrchestrator(input) {
531
+ const config = captureOptions(input, ['client', 'now', 'launchFor', 'reservationId', 'retryPolicy', 'prepareWorktree', 'reconcile', 'processStatus', 'launchGraceMs'], ['client', 'now', 'launchFor']);
532
+ if (!config.client || typeof config.client.launch !== 'function' || typeof config.now !== 'function' || typeof config.launchFor !== 'function'
533
+ || (config.prepareWorktree !== undefined && typeof config.prepareWorktree !== 'function')
534
+ || (config.reconcile !== undefined && typeof config.reconcile !== 'function')
535
+ || (config.processStatus !== undefined && typeof config.processStatus !== 'function')) fail('invalid-runtime-input');
536
+ const launchGraceMs = config.launchGraceMs ?? 5_000;
537
+ if (!Number.isSafeInteger(launchGraceMs) || launchGraceMs < 100 || launchGraceMs > 300_000) fail('invalid-runtime-input');
538
+ const retryPolicy = config.retryPolicy ?? createRetryPolicy({ maxAttempts: 3, delaysMs: [0, 0], retryable: ['provider-transient'] });
539
+ const retryDelaysMs = retryPolicySchedule(retryPolicy);
540
+ const canonicalRuntimeState = (state, instanceId) => canonicalState(state, instanceId, retryDelaysMs);
541
+ const runTransaction = (instance, expectedVersion, operation) => transaction(instance, expectedVersion, operation, retryDelaysMs);
542
+ const controllers = new Map();
543
+ const inflight = new Map();
544
+ const resultQueues = new Map();
545
+
546
+ async function settleApprovalFailure(instance, expectedVersion, claim, persisted) {
547
+ let current; let lock;
548
+ try {
549
+ lock = await instance.acquire(); current = canonicalRuntimeState(await instance.read(), instance.id);
550
+ } catch { return; }
551
+ finally { if (lock) await lock.release(); }
552
+ if (persisted(current)) { claim.publish(); return; }
553
+ if (!claim.rollback()) claim.release();
554
+ }
555
+
556
+ async function serializeResult(instanceId, operation) {
557
+ const prior = resultQueues.get(instanceId) ?? Promise.resolve();
558
+ const current = prior.catch(() => {}).then(operation);
559
+ resultQueues.set(instanceId, current);
560
+ try { return await current; }
561
+ finally { if (resultQueues.get(instanceId) === current) resultQueues.delete(instanceId); }
562
+ }
563
+
564
+ async function observeProcess(request) {
565
+ const controller = new AbortController();
566
+ const timeoutToken = Object.freeze(Object.create(null));
567
+ let timer;
568
+ try {
569
+ const provider = Promise.resolve().then(() => config.processStatus(request, { signal: controller.signal }))
570
+ .then(value => Object.freeze({ ok: true, value }), () => Object.freeze({ ok: false }));
571
+ const timeout = new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(timeoutToken); }, Math.min(launchGraceMs, 5_000)); });
572
+ const outcome = await Promise.race([provider, timeout]);
573
+ if (outcome === timeoutToken || outcome.ok !== true || !['running', 'absent'].includes(outcome.value)) fail('invalid-runtime-input');
574
+ return outcome.value;
575
+ } finally { if (timer !== undefined) clearTimeout(timer); }
576
+ }
577
+
578
+ async function activate(instance, inputOptions) {
579
+ const options = captureOptions(inputOptions, ['expectedVersion', 'receipt', 'registry', 'authority', 'expectedApproverId'], ['expectedVersion']);
580
+ if (!options.receipt || !options.registry || !options.authority || !options.expectedApproverId) fail('approval-required');
581
+ const decision = evaluateAuthority(options.authority, { actorId: options.authority.actorId, action: 'activation', resource: instance.id });
582
+ if (decision.policyId !== 'authority.human-gate.activation') fail('approval-required');
583
+ const claim = claimApproval(options.receipt, { subjectId: options.authority.actorId, action: 'activation', resource: instance.id, policyId: 'authority.human-gate.activation' }, { registry: options.registry, expectedApproverId: options.expectedApproverId, requireHumanApprover: true, requireSingleUse: true, nowMs: config.now() });
584
+ if (!claim.valid) fail('approval-required');
585
+ try {
586
+ if (!claim.finalize()) fail('approval-required');
587
+ const persisted = await runTransaction(instance, options.expectedVersion, state => {
588
+ if (state.activated) return null;
589
+ state.activated = true; const nowMs = config.now();
590
+ event(state, nowMs, { actor: { role: 'human', id: options.expectedApproverId }, event: { type: 'approval-recorded', evidenceRefs: [options.receipt.id], approvalReceiptId: options.receipt.id } });
591
+ if (state.graph.status === 'approved') transition(state, state.graph, 'ready', nowMs);
592
+ if (state.graph.status === 'ready') transition(state, state.graph, 'reserved', nowMs);
593
+ if (state.graph.status === 'reserved') transition(state, state.graph, 'running', nowMs);
594
+ return state;
595
+ });
596
+ if (!claim.publish()) fail('approval-required');
597
+ return persisted;
598
+ } catch (error) {
599
+ await settleApprovalFailure(instance, options.expectedVersion, claim, state => state.activated === true && state.events.some(item => item.type === 'approval-recorded' && !item.nodeId && item.approvalReceiptId === options.receipt.id));
600
+ throw error;
601
+ }
602
+ }
603
+
604
+ async function approveNode(instance, inputOptions) {
605
+ const options = captureOptions(inputOptions, ['expectedVersion', 'nodeId', 'evidence', 'receipt', 'registry', 'authority', 'expectedApproverId'], ['expectedVersion', 'nodeId', 'evidence']);
606
+ if (!safeId(options.nodeId) || !options.receipt || !options.registry || !options.authority || !options.expectedApproverId) fail('approval-required');
607
+ const evidence = immutableJson(options.evidence);
608
+ if (!Array.isArray(evidence) || evidence.length < 1 || evidence.length > 64 || evidence.some(item => {
609
+ const keys = item && typeof item === 'object' ? Reflect.ownKeys(item) : [];
610
+ return keys.length !== 2 || !keys.includes('id') || !keys.includes('type') || !safeId(item.id) || !safeId(item.type);
611
+ })) fail('invalid-runtime-input');
612
+ const resource = `${instance.id}:${options.nodeId}`;
613
+ const inspectLock = await instance.acquire(); let inspected;
614
+ try { inspected = canonicalRuntimeState(await instance.read(), instance.id); } finally { await inspectLock.release(); }
615
+ if (inspected.version !== exactVersion(options.expectedVersion)) fail('version-conflict');
616
+ const gate = inspected.graph.nodes.find(item => item.id === options.nodeId)?.approvalGate;
617
+ if (!safeId(gate)) fail('approval-required');
618
+ const policyId = `authority.human-gate.${gate}`;
619
+ const decision = evaluateAuthority(options.authority, { actorId: options.authority.actorId, action: gate, resource });
620
+ if (decision.policyId !== policyId) fail('approval-required');
621
+ const claim = claimApproval(options.receipt, { subjectId: options.authority.actorId, action: gate, resource, policyId }, { registry: options.registry, expectedApproverId: options.expectedApproverId, requireHumanApprover: true, requireSingleUse: true, nowMs: config.now() });
622
+ if (!claim.valid) fail('approval-required');
623
+ try {
624
+ if (!claim.finalize()) fail('approval-required');
625
+ const persisted = await runTransaction(instance, options.expectedVersion, state => {
626
+ const node = state.graph.nodes.find(item => item.id === options.nodeId);
627
+ const nodes = new Map(state.graph.nodes.map(item => [item.id, item]));
628
+ if (node?.approvalGate !== gate || node.status !== 'ready' || !node.dependencies.every(id => nodes.get(id)?.status === 'completed')) fail('approval-required');
629
+ const bindings = evidenceBindings(node, new Map([[options.receipt.id, 'human-approval']]));
630
+ const actualIds = evidence.map(item => item.id).sort(); const expectedIds = [...bindings.keys()].sort();
631
+ const actualTypes = evidence.map(item => item.type).sort(); const expectedTypes = [...bindings.values()].sort();
632
+ if (actualIds.length !== expectedIds.length || actualIds.some((item, index) => item !== expectedIds[index])
633
+ || actualTypes.some((item, index) => item !== expectedTypes[index])
634
+ || evidence.some(item => bindings.get(item.id) !== item.type)
635
+ || !actualIds.includes(options.receipt.id) || !actualTypes.includes('human-approval')) fail('approval-required');
636
+ const nowMs = config.now();
637
+ transition(state, node, 'reserved', nowMs);
638
+ transition(state, node, 'running', nowMs);
639
+ transition(state, node, 'verifying', nowMs);
640
+ state.evidence.push(...[...bindings].map(([id, type]) => ({ id, nodeId: node.id, type, approvalState: 'approved' })));
641
+ event(state, nowMs, { actor: { role: 'human', id: options.expectedApproverId }, event: { type: 'approval-recorded', nodeId: node.id, evidenceRefs: evidence.map(item => item.id).sort(), approvalReceiptId: options.receipt.id } });
642
+ transition(state, node, 'completed', nowMs);
643
+ deriveTerminal(state, nowMs);
644
+ return state;
645
+ });
646
+ if (!claim.publish()) fail('approval-required');
647
+ return persisted;
648
+ } catch (error) {
649
+ await settleApprovalFailure(instance, options.expectedVersion, claim, state => state.graph.nodes.find(item => item.id === options.nodeId)?.status === 'completed'
650
+ && state.events.some(item => item.type === 'approval-recorded' && item.nodeId === options.nodeId && item.approvalReceiptId === options.receipt.id));
651
+ throw error;
652
+ }
653
+ }
654
+
655
+ async function recordHeartbeat(instance, inputOptions) {
656
+ const options = captureOptions(inputOptions, ['expectedVersion', 'heartbeat'], ['expectedVersion', 'heartbeat']);
657
+ const heartbeat = createHeartbeat(options.heartbeat);
658
+ const observedNow = config.now();
659
+ if (!Number.isSafeInteger(observedNow) || observedNow < 0 || heartbeat.timestampMs > observedNow) fail('invalid-runtime-input');
660
+ return runTransaction(instance, options.expectedVersion, state => {
661
+ const node = state.graph.nodes.find(item => item.id === heartbeat.nodeId);
662
+ const intent = state.launchIntents[heartbeat.nodeId];
663
+ const prior = state.heartbeats[heartbeat.nodeId];
664
+ if (heartbeat.instanceId !== instance.id || !node || node.owner.id !== heartbeat.actorId
665
+ || !intent || intent.reservationId !== heartbeat.leaseId || !['committed', 'prepared', 'started'].includes(intent.status)
666
+ || (prior && (heartbeat.sequence <= prior.sequence || heartbeat.timestampMs < prior.timestampMs))) fail('invalid-runtime-input');
667
+ state.heartbeats[heartbeat.nodeId] = heartbeat;
668
+ event(state, heartbeat.timestampMs, { actor: node.owner, event: { type: 'heartbeat', nodeId: node.id, instanceId: heartbeat.instanceId, leaseId: heartbeat.leaseId, heartbeatSequence: heartbeat.sequence, heartbeatIntervalMs: heartbeat.intervalMs } });
669
+ return state;
670
+ });
671
+ }
672
+
673
+ async function commitResult(instance, intent, outcome) {
674
+ let result; let classification;
675
+ if (outcome.error) classification = classify(outcome.error);
676
+ else {
677
+ try { result = captureResult(outcome.result); classification = result.status; }
678
+ catch { classification = 'malformed-output'; }
679
+ }
680
+ for (let attempt = 0; attempt < 4; attempt += 1) {
681
+ const observed = await instance.acquire();
682
+ let version;
683
+ try { version = (await instance.read()).version; } finally { await observed.release(); }
684
+ try {
685
+ return await runTransaction(instance, version, state => {
686
+ if (state.terminal === 'cancelled' || state.launchIntents[intent.nodeId]?.status === 'complete') return null;
687
+ const node = state.graph.nodes.find(item => item.id === intent.nodeId);
688
+ const storedIntent = state.launchIntents[intent.nodeId];
689
+ if (storedIntent?.id !== intent.id || storedIntent.attempt !== intent.attempt) return null;
690
+ const preparationFailure = outcome.phase === 'prepare' && node?.status === 'reserved'
691
+ && storedIntent?.id === intent.id && storedIntent.status === 'committed'
692
+ && storedIntent.preparationClaim?.id === outcome.preparationClaimId;
693
+ if (outcome.phase === 'prepare' && !preparationFailure) return null;
694
+ if (!node || (node.status !== 'running' && !preparationFailure)) return null;
695
+ let retryNowMs;
696
+ state.usage ??= { tokens: 0, costUsd: '0', retries: 0, timeMinutes: 0, taskLimit: 0 };
697
+ if (result) {
698
+ const allocation = state.launchIntents[intent.nodeId]?.allocation ?? node.budget;
699
+ const elapsed = Math.ceil(Math.max(0, config.now() - (state.launchIntents[intent.nodeId]?.startedAtMs ?? config.now())) / 60_000);
700
+ const attemptUsage = { tokens: result.usage.tokens, costUsd: result.usage.costUsd, timeMinutes: result.usage.timeMinutes ?? elapsed, tasks: result.usage.tasks ?? 1 };
701
+ if (result.status !== 'budget-exhausted' && !resultWithinAllocation(result, allocation, elapsed)) { result = undefined; classification = 'malformed-output'; }
702
+ else {
703
+ state.usage.tokens += attemptUsage.tokens;
704
+ state.usage.costUsd = decimalMath(state.usage.costUsd ?? 0, attemptUsage.costUsd);
705
+ state.usage.timeMinutes = (state.usage.timeMinutes ?? 0) + attemptUsage.timeMinutes;
706
+ state.usage.taskLimit = (state.usage.taskLimit ?? 0) + attemptUsage.tasks;
707
+ const prior = state.nodeUsage[node.id] ?? { tokens: 0, costUsd: '0', timeMinutes: 0, tasks: 0 };
708
+ state.nodeUsage[node.id] = { tokens: prior.tokens + attemptUsage.tokens, costUsd: decimalMath(prior.costUsd, attemptUsage.costUsd), timeMinutes: prior.timeMinutes + attemptUsage.timeMinutes, tasks: prior.tasks + attemptUsage.tasks };
709
+ for (const parent of ancestorNodes(state, node)) {
710
+ const delegated = state.delegatedUsage[parent.id] ?? { tokens: 0, costUsd: '0', timeMinutes: 0, tasks: 0 };
711
+ state.delegatedUsage[parent.id] = { tokens: delegated.tokens + attemptUsage.tokens, costUsd: decimalMath(delegated.costUsd, attemptUsage.costUsd), timeMinutes: delegated.timeMinutes + attemptUsage.timeMinutes, tasks: delegated.tasks + attemptUsage.tasks };
712
+ }
713
+ }
714
+ }
715
+ if (state.terminal === 'budget-exhausted') { transition(state, node, 'blocked', config.now()); }
716
+ else if (classification === 'cancelled') { transition(state, node, 'cancelled', config.now()); }
717
+ else if (result?.status === 'success') {
718
+ const exact = [...result.output.evidence].sort();
719
+ const expected = [...node.evidenceRefs].sort();
720
+ if (node.requiredEvidenceTypes.includes('human-approval')
721
+ || exact.length !== expected.length || exact.some((id, index) => id !== expected[index])) {
722
+ transition(state, node, 'blocked', config.now());
723
+ } else {
724
+ transition(state, node, 'verifying', config.now());
725
+ const bindings = evidenceBindings(node);
726
+ state.evidence.push(...exact.map(id => ({ id, nodeId: node.id, type: bindings.get(id), approvalState: 'approved' })));
727
+ event(state, config.now(), { actor: node.owner, event: { type: 'evidence-recorded', nodeId: node.id, evidenceRefs: exact } });
728
+ transition(state, node, 'completed', config.now());
729
+ }
730
+ } else if (result?.status === 'budget-exhausted') {
731
+ transition(state, node, 'blocked', config.now());
732
+ } else {
733
+ const currentAttempt = state.attempts[node.id] ?? 1;
734
+ const decision = retryDecision(retryPolicy, { attempt: currentAttempt, classification: classification === 'retry' ? 'provider-transient' : classification });
735
+ if (decision.retry && availableBudget(state).retries > 0) {
736
+ retryNowMs = config.now();
737
+ state.attempts[node.id] = decision.nextAttempt;
738
+ state.usage.retries += 1;
739
+ delete state.heartbeats[node.id];
740
+ delete state.lastHeartbeatAt[node.id];
741
+ event(state, retryNowMs, { actor: node.owner, event: { type: 'retry', nodeId: node.id, retryReason: classification } });
742
+ if (preparationFailure) {
743
+ state.launchIntents[node.id].retryAtMs = retryNowMs + decision.delayMs;
744
+ transition(state, node, 'ready', retryNowMs);
745
+ } else {
746
+ transition(state, node, 'corrective', retryNowMs);
747
+ state.launchIntents[node.id].retryAtMs = retryNowMs + decision.delayMs;
748
+ if (decision.delayMs === 0) transition(state, node, 'ready', retryNowMs);
749
+ }
750
+ } else transition(state, node, preparationFailure ? 'blocked' : result?.status === 'failed' ? 'failed' : 'blocked', config.now());
751
+ }
752
+ delete state.launchIntents[node.id].preparationClaim;
753
+ state.launchIntents[node.id].status = 'complete';
754
+ if (result?.status === 'budget-exhausted') {
755
+ state.terminal = 'budget-exhausted';
756
+ if (state.graph.status !== 'blocked') transition(state, state.graph, 'blocked', config.now());
757
+ } else deriveTerminal(state, retryNowMs ?? config.now());
758
+ return state;
759
+ });
760
+ } catch (error) {
761
+ if (error instanceof RuntimeError && error.details.reason === 'version-conflict') continue;
762
+ throw error;
763
+ }
764
+ }
765
+ fail('version-conflict');
766
+ }
767
+
768
+ async function claimPreparation(instance, intent, claimId) {
769
+ for (let attempt = 0; attempt < 4; attempt += 1) {
770
+ const lock = await instance.acquire(); let inspected;
771
+ try { inspected = canonicalRuntimeState(await instance.read(), instance.id); } finally { await lock.release(); }
772
+ const observed = inspected.launchIntents[intent.nodeId];
773
+ if (!observed || observed.id !== intent.id || ['prepared', 'started', 'complete'].includes(observed.status)) return null;
774
+ if (observed.status !== 'committed') return null;
775
+ const claimedAtMs = config.now(); iso(claimedAtMs);
776
+ if (observed.preparationClaim) {
777
+ if (observed.preparationClaim.claimedAtMs > claimedAtMs) fail('invalid-runtime-input');
778
+ if (claimedAtMs - observed.preparationClaim.claimedAtMs < launchGraceMs) return null;
779
+ }
780
+ try {
781
+ const state = await runTransaction(instance, inspected.version, current => {
782
+ const stored = current.launchIntents[intent.nodeId];
783
+ if (!stored || stored.id !== intent.id || stored.status !== 'committed') fail('version-conflict');
784
+ stored.preparationClaim = { id: claimId, claimedAtMs }; return current;
785
+ });
786
+ return state.launchIntents[intent.nodeId];
787
+ } catch (error) {
788
+ if (error instanceof RuntimeError && error.details.reason === 'version-conflict') continue;
789
+ throw error;
790
+ }
791
+ }
792
+ return null;
793
+ }
794
+
795
+ async function persistPreparedIntent(instance, intent, claimId, worktree) {
796
+ for (let attempt = 0; attempt < 4; attempt += 1) {
797
+ const lock = await instance.acquire(); let inspected;
798
+ try { inspected = canonicalRuntimeState(await instance.read(), instance.id); } finally { await lock.release(); }
799
+ const observed = inspected.launchIntents[intent.nodeId];
800
+ if (!observed || observed.id !== intent.id || observed.status !== 'committed'
801
+ || observed.preparationClaim?.id !== claimId) return null;
802
+ try {
803
+ const state = await runTransaction(instance, inspected.version, current => {
804
+ const stored = current.launchIntents[intent.nodeId];
805
+ if (!stored || stored.id !== intent.id || stored.status !== 'committed'
806
+ || stored.preparationClaim?.id !== claimId) fail('version-conflict');
807
+ stored.worktree = worktree; delete stored.preparationClaim; stored.status = 'prepared'; return current;
808
+ });
809
+ return state.launchIntents[intent.nodeId];
810
+ } catch (error) {
811
+ if (error instanceof RuntimeError && error.details.reason === 'version-conflict') continue;
812
+ throw error;
813
+ }
814
+ }
815
+ return null;
816
+ }
817
+
818
+ async function markIntentStarted(instance, intent) {
819
+ for (let attempt = 0; attempt < 4; attempt += 1) {
820
+ const lock = await instance.acquire(); let inspected;
821
+ try { inspected = canonicalRuntimeState(await instance.read(), instance.id); } finally { await lock.release(); }
822
+ if (inspected.terminal === 'budget-exhausted') return null;
823
+ const observed = inspected.launchIntents[intent.nodeId];
824
+ if (!observed || observed.id !== intent.id || ['started', 'complete'].includes(observed.status)) return null;
825
+ if (!['committed', 'prepared'].includes(observed.status)) return null;
826
+ try {
827
+ const state = await runTransaction(instance, inspected.version, current => {
828
+ const stored = current.launchIntents[intent.nodeId];
829
+ if (!stored || stored.id !== intent.id || !['committed', 'prepared'].includes(stored.status)) fail('version-conflict');
830
+ const node = current.graph.nodes.find(item => item.id === intent.nodeId);
831
+ if (!node || node.status !== 'reserved') fail('version-conflict');
832
+ stored.status = 'started'; stored.startedAtMs = config.now(); transition(current, node, 'running', stored.startedAtMs); return current;
833
+ });
834
+ return state.launchIntents[intent.nodeId];
835
+ } catch (error) {
836
+ if (error instanceof RuntimeError && error.details.reason === 'version-conflict') continue;
837
+ throw error;
838
+ }
839
+ }
840
+ return null;
841
+ }
842
+
843
+ async function reconcileResult(instance, node, intent, result) {
844
+ if (!config.reconcile || result.status !== 'success'
845
+ || !resultWithinAllocation(result, intent.allocation ?? node.budget,
846
+ Math.ceil(Math.max(0, config.now() - (intent.startedAtMs ?? config.now())) / 60_000))) {
847
+ return { performed: false };
848
+ }
849
+ const lock = await instance.acquire();
850
+ let pending;
851
+ try {
852
+ if ((await instance.read()).terminal === 'budget-exhausted') return { performed: false };
853
+ // Invoke while the terminal check is protected, but await external work
854
+ // after releasing the state lock. Handle rejection immediately.
855
+ pending = Promise.resolve(config.reconcile(clone(node), clone(intent), result))
856
+ .then(report => ({ report }), error => ({ error, failed: true }));
857
+ } finally { await lock.release(); }
858
+ const settled = await pending;
859
+ if (settled.failed) throw settled.error;
860
+ return { performed: true, report: clone(settled.report) };
861
+ }
862
+
863
+ async function launch(instance, intent, node, signal) {
864
+ const key = `${instance.id}:${intent.id}`;
865
+ if (inflight.has(key)) return inflight.get(key);
866
+ const controller = new AbortController(); controllers.set(`${instance.id}:${node.id}`, controller);
867
+ const abort = () => controller.abort();
868
+ signal?.addEventListener('abort', abort, { once: true });
869
+ if (signal?.aborted) abort();
870
+ const promise = (async () => {
871
+ try {
872
+ let outcome; let phase = 'claim'; let preparationClaimId;
873
+ try {
874
+ let preparedIntent = intent;
875
+ if (config.prepareWorktree && intent.status !== 'prepared') {
876
+ preparationClaimId = `prepare-${randomUUID()}`;
877
+ const claimedIntent = await serializeResult(instance.id, () => claimPreparation(instance, intent, preparationClaimId));
878
+ if (claimedIntent === null) return Object.freeze({ coordinated: true });
879
+ phase = 'prepare';
880
+ const worktree = clone(await config.prepareWorktree(clone(node), clone(claimedIntent)));
881
+ phase = 'prepare-commit';
882
+ preparedIntent = await serializeResult(instance.id, () => persistPreparedIntent(instance, claimedIntent, preparationClaimId, worktree));
883
+ }
884
+ if (preparedIntent === null) return Object.freeze({ coordinated: true });
885
+ phase = 'start';
886
+ const startedIntent = await serializeResult(instance.id, () => markIntentStarted(instance, preparedIntent));
887
+ if (startedIntent === null) return Object.freeze({ coordinated: true });
888
+ phase = 'client';
889
+ const result = captureResult(await config.client.launch(config.launchFor(clone(node), clone(startedIntent)), { signal: controller.signal }));
890
+ phase = 'reconcile';
891
+ const reconciliation = await reconcileResult(instance, node, startedIntent, result);
892
+ if (reconciliation.performed) {
893
+ const report = reconciliation.report;
894
+ if (report?.status !== 'integrated') outcome = { result: { version: 1, status: 'blocked', output: { summary: 'Worktree reconciliation requires review.', evidence: [] }, usage: result.usage } };
895
+ else outcome = { result };
896
+ } else outcome = { result };
897
+ }
898
+ catch (error) {
899
+ if (phase === 'claim' || phase === 'prepare-commit') throw error;
900
+ outcome = { error, ...(phase === 'prepare' ? { phase, preparationClaimId } : {}) };
901
+ }
902
+ await serializeResult(instance.id, () => commitResult(instance, intent, outcome));
903
+ return outcome;
904
+ } finally {
905
+ signal?.removeEventListener('abort', abort);
906
+ controllers.delete(`${instance.id}:${node.id}`);
907
+ inflight.delete(key);
908
+ }
909
+ })();
910
+ inflight.set(key, promise);
911
+ return promise;
912
+ }
913
+
914
+ async function tickInternal(instance, inputOptions, launchWork) {
915
+ const options = captureOptions(inputOptions, ['expectedVersion', 'maxActiveNodes', 'signal'], ['expectedVersion']);
916
+ const maxActiveNodes = options.maxActiveNodes ?? 4;
917
+ try { if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) fail('invalid-runtime-input'); }
918
+ catch { fail('invalid-runtime-input'); }
919
+ if (!Number.isSafeInteger(maxActiveNodes) || maxActiveNodes < 1 || maxActiveNodes > 64) fail('invalid-runtime-input');
920
+ const processObservations = new Map();
921
+ const inspectLock = await instance.acquire(); let inspected;
922
+ try { inspected = canonicalRuntimeState(await instance.read(), instance.id); } finally { await inspectLock.release(); }
923
+ const observedNow = config.now();
924
+ if (!Number.isSafeInteger(observedNow) || observedNow < 0
925
+ || Object.values(inspected.launchIntents).some(intent => intent.status === 'started' && intent.startedAtMs > observedNow)) fail('invalid-runtime-input');
926
+ for (const [nodeId, intent] of Object.entries(inspected.launchIntents)) {
927
+ if (intent.status !== 'started' || inspected.heartbeats[nodeId] || !Number.isSafeInteger(intent.startedAtMs)
928
+ || observedNow - intent.startedAtMs < launchGraceMs) continue;
929
+ const key = `${instance.id}:${intent.id}`;
930
+ let status = inflight.has(key) ? 'running' : 'absent';
931
+ if (status === 'absent' && config.processStatus) status = await observeProcess(Object.freeze({ instanceId: instance.id, nodeId, intentId: intent.id, reservationId: intent.reservationId }));
932
+ if (!['running', 'absent'].includes(status)) fail('invalid-runtime-input');
933
+ processObservations.set(nodeId, status);
934
+ }
935
+ const abortNodeIds = new Set();
936
+ const committed = await runTransaction(instance, options.expectedVersion, state => {
937
+ if (TERMINAL.has(state.terminal)) return null;
938
+ if (state.activated !== true) fail('not-activated');
939
+ if (!Array.isArray(state.graph?.nodes) || state.graph.nodes.length > 1000 || !Array.isArray(state.events) || !state.launchIntents || !state.attempts || !Array.isArray(state.evidence)) fail('invalid-runtime-input');
940
+ for (const node of state.graph.nodes) {
941
+ const intent = state.launchIntents[node.id]; const heartbeat = state.heartbeats?.[node.id];
942
+ if (!['reserved', 'running'].includes(node.status) || !['committed', 'prepared', 'started'].includes(intent?.status) || !heartbeat) continue;
943
+ const liveness = heartbeatStatus(heartbeat, { nowMs: config.now(), expected: { instanceId: instance.id, nodeId: node.id, actorId: node.owner.id, leaseId: intent.reservationId } });
944
+ if (liveness.status === 'stalled') { transition(state, node, 'blocked', config.now()); delete intent.preparationClaim; intent.status = 'complete'; abortNodeIds.add(node.id); }
945
+ }
946
+ for (const [nodeId, status] of processObservations) {
947
+ const node = state.graph.nodes.find(item => item.id === nodeId); const intent = state.launchIntents[nodeId];
948
+ if (node?.status === 'running' && intent?.status === 'started' && !state.heartbeats[nodeId]) {
949
+ transition(state, node, 'blocked', config.now()); intent.status = 'complete'; abortNodeIds.add(nodeId);
950
+ }
951
+ }
952
+ for (const node of state.graph.nodes) {
953
+ const intent = state.launchIntents[node.id];
954
+ const retryAtMs = state.retryAtMs?.[node.id] ?? intent?.retryAtMs;
955
+ if (node.status === 'corrective' && Number.isSafeInteger(retryAtMs) && retryAtMs <= config.now()) { transition(state, node, 'ready', config.now()); if (state.retryAtMs) delete state.retryAtMs[node.id]; }
956
+ }
957
+ deriveTerminal(state, config.now());
958
+ if (TERMINAL.has(state.terminal)) return state;
959
+ const recoverable = state.graph.nodes.filter(node => node.status === 'reserved' && ['committed', 'prepared'].includes(state.launchIntents[node.id]?.status));
960
+ const scheduledIds = scheduleReadyNodes(state.graph, { maxActiveNodes }).filter(id => {
961
+ const node = state.graph.nodes.find(item => item.id === id); const intent = state.launchIntents[id];
962
+ const retryAtMs = state.retryAtMs?.[id] ?? intent?.retryAtMs;
963
+ return !node.approvalGate && (!Number.isSafeInteger(retryAtMs) || retryAtMs <= config.now());
964
+ });
965
+ const available = availableBudget(state);
966
+ const launchableIds = [];
967
+ const allocations = new Map();
968
+ const parentAvailability = new Map();
969
+ for (const parent of state.graph.nodes) {
970
+ const availableForChildren = remainingParentBudget(state, parent);
971
+ if (availableForChildren.tokenLimit < 0 || availableForChildren.timeMinutes < 0 || availableForChildren.taskLimit < 0) fail('invalid-runtime-input');
972
+ for (const intent of Object.values(state.launchIntents)) {
973
+ if (!['committed', 'prepared', 'started'].includes(intent.status) || !intent.allocation) continue;
974
+ const active = state.graph.nodes.find(item => item.id === intent.nodeId);
975
+ if (active && ancestorNodes(state, active).some(item => item.id === parent.id)) reserveBudget(availableForChildren, intent.allocation);
976
+ }
977
+ parentAvailability.set(parent.id, availableForChildren);
978
+ }
979
+ for (const id of scheduledIds) {
980
+ const candidate = state.graph.nodes.find(node => node.id === id);
981
+ const allocation = remainingNodeBudget(state, candidate);
982
+ const parents = ancestorNodes(state, candidate);
983
+ if (allocation.tokenLimit < 1 || allocation.timeMinutes < 1 || allocation.taskLimit < 1 || !budgetFits(allocation, available)
984
+ || parents.some(parent => !budgetFits(allocation, parentAvailability.get(parent.id)))) continue;
985
+ launchableIds.push(id);
986
+ allocations.set(id, allocation);
987
+ reserveBudget(available, allocation);
988
+ for (const parent of parents) reserveBudget(parentAvailability.get(parent.id), allocation);
989
+ }
990
+ if (scheduledIds.length > 0 && launchableIds.length === 0 && recoverable.length === 0) {
991
+ state.terminal = 'budget-exhausted';
992
+ if (state.graph.status !== 'blocked') transition(state, state.graph, 'blocked', config.now());
993
+ return state;
994
+ }
995
+ for (const nodeId of launchableIds) {
996
+ const node = state.graph.nodes.find(item => item.id === nodeId);
997
+ const sequence = state.events.length + 1;
998
+ const generated = config.reservationId ? config.reservationId(node.id, sequence) : randomUUID();
999
+ const reservationId = safeId(generated) ? generated : generated.replaceAll('-', '').slice(0, 63).replace(/^[^a-z]+/, 'lease-');
1000
+ if (!safeId(reservationId)) fail('invalid-runtime-input');
1001
+ state.attempts[node.id] ??= 1;
1002
+ transition(state, node, 'reserved', config.now());
1003
+ state.launchIntents[node.id] = { id: launchIntentId(node.id, state.attempts[node.id]), nodeId: node.id, reservationId, attempt: state.attempts[node.id], idempotencyKey: `${instance.id}:${node.id}:${state.attempts[node.id]}`, allocation: allocations.get(node.id), status: 'committed', eventSequence: state.events.length };
1004
+ }
1005
+ return state;
1006
+ });
1007
+ for (const nodeId of abortNodeIds) controllers.get(`${instance.id}:${nodeId}`)?.abort();
1008
+ const launches = committed.graph.nodes
1009
+ .filter(node => node.status === 'reserved' && ['committed', 'prepared'].includes(committed.launchIntents[node.id]?.status))
1010
+ .sort((left, right) => committed.launchIntents[left.id].eventSequence - committed.launchIntents[right.id].eventSequence)
1011
+ .slice(0, maxActiveNodes);
1012
+ if (launchWork) await Promise.all(launches.map(node => launch(instance, committed.launchIntents[node.id], node, options.signal)));
1013
+ const lock = await instance.acquire();
1014
+ let final;
1015
+ try { final = clone(await instance.read()); } finally { await lock.release(); }
1016
+ return Object.freeze({ version: final.version, terminal: final.terminal, launched: Object.freeze(launches.map(node => node.id).sort()) });
1017
+ }
1018
+
1019
+ async function tick(instance, inputOptions) {
1020
+ return tickInternal(instance, inputOptions, true);
1021
+ }
1022
+
1023
+ async function prepareAction(instance, inputOptions) {
1024
+ const options = captureOptions(inputOptions, ['expectedVersion'], ['expectedVersion']);
1025
+ const inspect = async () => {
1026
+ const lock = await instance.acquire();
1027
+ try { return canonicalRuntimeState(await instance.read(), instance.id); }
1028
+ finally { await lock.release(); }
1029
+ };
1030
+ let state = await inspect();
1031
+ if (state.version !== exactVersion(options.expectedVersion)) fail('version-conflict');
1032
+ if (state.terminal === 'budget-exhausted') return Object.freeze({ version: state.version, action: null });
1033
+ let active = state.graph.nodes.find(node => node.status === 'running'
1034
+ && state.launchIntents[node.id]?.status === 'started');
1035
+ if (!active) {
1036
+ await tickInternal(instance, { expectedVersion: state.version, maxActiveNodes: 1 }, false);
1037
+ state = await inspect();
1038
+ active = state.graph.nodes.find(node => node.status === 'reserved'
1039
+ && ['committed', 'prepared'].includes(state.launchIntents[node.id]?.status));
1040
+ if (!active) return Object.freeze({ version: state.version, action: null });
1041
+ let intent = state.launchIntents[active.id];
1042
+ if (config.prepareWorktree && intent.status !== 'prepared') {
1043
+ const claimId = `prepare-${randomUUID()}`;
1044
+ const claimed = await serializeResult(instance.id, () => claimPreparation(instance, intent, claimId));
1045
+ if (claimed === null) fail('version-conflict');
1046
+ const worktree = clone(await config.prepareWorktree(clone(active), clone(claimed)));
1047
+ intent = await serializeResult(instance.id, () => persistPreparedIntent(instance, claimed, claimId, worktree));
1048
+ if (intent === null) fail('version-conflict');
1049
+ }
1050
+ const started = await serializeResult(instance.id, () => markIntentStarted(instance, intent));
1051
+ if (started === null) fail('version-conflict');
1052
+ state = await inspect();
1053
+ active = state.graph.nodes.find(node => node.id === started.nodeId);
1054
+ }
1055
+ const intent = state.launchIntents[active.id];
1056
+ return Object.freeze({
1057
+ version: state.version,
1058
+ action: Object.freeze({
1059
+ node: clone(active),
1060
+ intent: clone(intent),
1061
+ launch: clone(config.launchFor(clone(active), clone(intent))),
1062
+ }),
1063
+ });
1064
+ }
1065
+
1066
+ async function submitAction(instance, inputOptions) {
1067
+ const options = captureOptions(inputOptions, [
1068
+ 'expectedVersion', 'nodeId', 'intentId', 'idempotencyKey', 'reservationId', 'result',
1069
+ ], ['expectedVersion', 'nodeId', 'intentId', 'idempotencyKey', 'reservationId', 'result']);
1070
+ const result = captureResult(options.result);
1071
+ const lock = await instance.acquire();
1072
+ let state;
1073
+ try { state = canonicalRuntimeState(await instance.read(), instance.id); }
1074
+ finally { await lock.release(); }
1075
+ if (state.version !== exactVersion(options.expectedVersion)) fail('version-conflict');
1076
+ const node = state.graph.nodes.find(item => item.id === options.nodeId);
1077
+ const intent = state.launchIntents[options.nodeId];
1078
+ if (!node || node.status !== 'running' || intent?.status !== 'started'
1079
+ || intent.id !== options.intentId || intent.idempotencyKey !== options.idempotencyKey
1080
+ || intent.reservationId !== options.reservationId
1081
+ || intent.worktree?.reservationId !== options.reservationId) fail('version-conflict');
1082
+ let report = null;
1083
+ let outcome = { result };
1084
+ const reconciliation = await reconcileResult(instance, node, intent, result);
1085
+ if (reconciliation.performed) {
1086
+ report = reconciliation.report;
1087
+ if (report?.status !== 'integrated') {
1088
+ outcome = {
1089
+ result: {
1090
+ version: 1,
1091
+ status: 'blocked',
1092
+ output: { summary: 'Worktree reconciliation requires review.', evidence: [] },
1093
+ usage: result.usage,
1094
+ },
1095
+ };
1096
+ }
1097
+ }
1098
+ const committed = await serializeResult(instance.id, () => commitResult(instance, intent, outcome));
1099
+ const settled = committed.graph.nodes.find(item => item.id === node.id);
1100
+ return Object.freeze({
1101
+ version: committed.version,
1102
+ nodeId: node.id,
1103
+ nodeStatus: settled.status,
1104
+ terminal: committed.terminal,
1105
+ report,
1106
+ });
1107
+ }
1108
+
1109
+ async function cancelGoal(instance, inputOptions) {
1110
+ const state = await recoverCancelGoal(instance, inputOptions);
1111
+ for (const [key, controller] of controllers) if (key.startsWith(`${instance.id}:`)) controller.abort();
1112
+ return state;
1113
+ }
1114
+
1115
+ async function cancelNode(instance, inputOptions) {
1116
+ const options = captureOptions(inputOptions, ['expectedVersion', 'nodeId', 'authority', 'nowMs'], ['expectedVersion', 'nodeId', 'authority']);
1117
+ const state = await recoverCancelNode(instance, options);
1118
+ for (const node of state.graph.nodes) if (node.status === 'cancelled') controllers.get(`${instance.id}:${node.id}`)?.abort();
1119
+ return state;
1120
+ }
1121
+
1122
+ async function recoverStalledNode(instance, inputOptions) {
1123
+ const options = captureOptions(inputOptions, ['expectedVersion', 'nodeId', 'authority', 'nowMs', 'reason'], ['expectedVersion', 'nodeId', 'authority', 'nowMs', 'reason']);
1124
+ const state = await recoverStalled(instance, options);
1125
+ controllers.get(`${instance.id}:${options.nodeId}`)?.abort();
1126
+ return state;
1127
+ }
1128
+
1129
+ async function retryNode(instance, inputOptions) { return recoverRetryNode(instance, inputOptions, { retryPolicy }); }
1130
+
1131
+ const runtime = Object.freeze({ activate, approveNode, recordHeartbeat, tick, prepareAction, submitAction, retryNode, recoverStalledNode, cancelNode, cancelGoal });
1132
+ return runtime;
1133
+ }