@kylecheng3146/agent-ops 0.5.3 → 0.6.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 (93) hide show
  1. package/README.md +48 -0
  2. package/dist/packages/cli/src/args.js +165 -2
  3. package/dist/packages/cli/src/bin.js +64 -17
  4. package/dist/packages/cli/src/cli.js +33 -6
  5. package/dist/packages/cli/src/commands/advance.js +23 -3
  6. package/dist/packages/cli/src/commands/review-show.js +1 -1
  7. package/dist/packages/cli/src/commands/review.js +100 -9
  8. package/dist/packages/cli/src/commands/run.js +111 -0
  9. package/dist/packages/cli/src/commands/task.js +61 -17
  10. package/dist/packages/cli/src/commands/verify.js +1 -0
  11. package/dist/packages/cli/src/context.js +63 -1
  12. package/dist/packages/cli/src/hook-process.js +31 -1
  13. package/dist/packages/cli/src/owned-run-step.js +65 -0
  14. package/dist/packages/cli/src/parallel-deps.js +18 -5
  15. package/dist/packages/cli/src/run-deps.js +314 -0
  16. package/dist/packages/cli/src/run-entry.js +943 -0
  17. package/dist/packages/cli/src/run-setup-entry.js +32 -0
  18. package/dist/packages/cli/src/run-step-entry.js +58 -0
  19. package/dist/runtime/src/config/explain.js +6 -0
  20. package/dist/runtime/src/config/hash.js +1 -1
  21. package/dist/runtime/src/config/merge.js +9 -1
  22. package/dist/runtime/src/contracts.js +2 -2
  23. package/dist/runtime/src/hooks/completion-gate.js +8 -2
  24. package/dist/runtime/src/install/doctor.js +2 -0
  25. package/dist/runtime/src/parallel/finish.js +85 -22
  26. package/dist/runtime/src/parallel/guard.js +5 -1
  27. package/dist/runtime/src/parallel/integrate.js +288 -10
  28. package/dist/runtime/src/parallel/manage.js +4 -1
  29. package/dist/runtime/src/parallel/receipt.js +71 -5
  30. package/dist/runtime/src/parallel/service.js +41 -1
  31. package/dist/runtime/src/review/attestation.js +36 -11
  32. package/dist/runtime/src/review/execute.js +24 -1
  33. package/dist/runtime/src/review/packet.js +35 -1
  34. package/dist/runtime/src/review/runner.js +12 -1
  35. package/dist/runtime/src/review/scope.js +18 -1
  36. package/dist/runtime/src/review/slots.js +35 -10
  37. package/dist/runtime/src/run/authorization.js +60 -0
  38. package/dist/runtime/src/run/budget.js +30 -0
  39. package/dist/runtime/src/run/controls.js +139 -0
  40. package/dist/runtime/src/run/convergence.js +177 -0
  41. package/dist/runtime/src/run/hosts/claude.js +180 -0
  42. package/dist/runtime/src/run/hosts/codex.js +279 -0
  43. package/dist/runtime/src/run/hosts/index.js +4 -0
  44. package/dist/runtime/src/run/hosts/protocol.js +254 -0
  45. package/dist/runtime/src/run/hosts/types.js +1 -0
  46. package/dist/runtime/src/run/hosts/util.js +136 -0
  47. package/dist/runtime/src/run/integration.js +204 -0
  48. package/dist/runtime/src/run/macOS.js +189 -0
  49. package/dist/runtime/src/run/ownership.js +22 -0
  50. package/dist/runtime/src/run/planning.js +25 -0
  51. package/dist/runtime/src/run/policy-transition.js +1076 -0
  52. package/dist/runtime/src/run/policy.js +176 -0
  53. package/dist/runtime/src/run/ratchet.js +71 -0
  54. package/dist/runtime/src/run/scheduler.js +260 -0
  55. package/dist/runtime/src/run/service.js +425 -0
  56. package/dist/runtime/src/run/supervisor.js +635 -0
  57. package/dist/runtime/src/run/transport.js +159 -0
  58. package/dist/runtime/src/run/types.js +1 -0
  59. package/dist/runtime/src/run/usage.js +81 -0
  60. package/dist/runtime/src/run/verification.js +64 -0
  61. package/dist/runtime/src/run/worker-gate.js +48 -0
  62. package/dist/runtime/src/schema/acceptance.js +91 -0
  63. package/dist/runtime/src/schema/validate.js +67 -7
  64. package/dist/runtime/src/task/completion.js +14 -2
  65. package/dist/runtime/src/task/contract.js +17 -0
  66. package/dist/runtime/src/task/pin-finding.js +81 -0
  67. package/dist/runtime/src/task/service.js +316 -13
  68. package/dist/runtime/src/task/store.js +57 -7
  69. package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
  70. package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
  71. package/dist/runtime/src/verify/acceptance-replay.js +239 -0
  72. package/dist/runtime/src/verify/adapters/index.js +5 -0
  73. package/dist/runtime/src/verify/adapters/jest.js +142 -0
  74. package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
  75. package/dist/runtime/src/verify/adapters/node.js +254 -0
  76. package/dist/runtime/src/verify/adapters/pytest.js +188 -0
  77. package/dist/runtime/src/verify/adapters/rust.js +84 -0
  78. package/dist/runtime/src/verify/adapters/shared.js +82 -0
  79. package/dist/runtime/src/verify/adapters/vitest.js +218 -0
  80. package/dist/runtime/src/verify/evidence.js +5 -2
  81. package/dist/runtime/src/verify/service.js +24 -8
  82. package/dist/runtime/src/verify/source-fingerprint.js +1 -1
  83. package/dist/runtime/src/verify/spawn.js +13 -2
  84. package/docs/en/spec/acceptance-and-evidence.md +48 -0
  85. package/docs/en/spec/loop-engineering.md +49 -0
  86. package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
  87. package/docs/zh-TW/spec/loop-engineering.md +22 -0
  88. package/package.json +1 -1
  89. package/schemas/config.schema.json +221 -19
  90. package/schemas/evidence.schema.json +129 -6
  91. package/schemas/task.schema.json +265 -6
  92. package/templates/acceptance/agent_ops_acceptance.rs +36 -0
  93. package/templates/acceptance/agent_ops_pytest.py +46 -0
@@ -0,0 +1,176 @@
1
+ import { readdir, readFile, lstat } from "node:fs/promises";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { canonicalJson, calculateConfigHash } from "../config/hash.js";
5
+ import { mergeConfigLayers } from "../config/merge.js";
6
+ import { sha256 } from "../fs/hash.js";
7
+ import { AgentOpsError } from "../fs/paths.js";
8
+ import { evaluateGuardrail } from "../guardrails/evaluate.js";
9
+ import { validateConfig } from "../schema/validate.js";
10
+ import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
11
+ import { redactSecrets } from "../security/redact.js";
12
+ import { activeWallTimeMs } from "./scheduler.js";
13
+ function fail(code, message) { throw new AgentOpsError(code, message); }
14
+ const plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15
+ function validApprovals(value) {
16
+ return Array.isArray(value) && value.length <= 100 && value.every(a => plain(a) &&
17
+ Object.keys(a).every(key => ["capabilityId", "classification", "reason"].includes(key)) &&
18
+ typeof a.capabilityId === "string" && /^(?:command|runner|setup):[^\s\0]{1,128}$/u.test(a.capabilityId) &&
19
+ a.classification === "non-dangerous" && typeof a.reason === "string" && a.reason.trim().length > 0 &&
20
+ a.reason.length <= 4096 && !a.reason.includes("\0"));
21
+ }
22
+ function validBaseBinding(value, root) {
23
+ return plain(value) && Object.keys(value).every(key => ["canonicalPath", "remoteIdentity", "configHash", "runtimeHash"].includes(key)) &&
24
+ value.canonicalPath === root && isAbsolute(root) && typeof value.remoteIdentity === "string" && value.remoteIdentity.length > 0 &&
25
+ typeof value.configHash === "string" && /^[a-f0-9]{64}$/u.test(value.configHash) && typeof value.runtimeHash === "string" && /^[a-f0-9]{64}$/u.test(value.runtimeHash);
26
+ }
27
+ /** Hash executable toolkit modules, rather than only its package version. */
28
+ export async function runRuntimeHash() {
29
+ const root = fileURLToPath(new URL("../../../", import.meta.url));
30
+ const entries = [];
31
+ async function walk(relative) {
32
+ for (const name of (await readdir(join(root, relative))).sort()) {
33
+ const path = join(relative, name), absolute = join(root, path), stat = await lstat(absolute);
34
+ if (stat.isSymbolicLink())
35
+ fail("RUN_RUNTIME_CHANGED", "Run runtime cannot contain symlinks.");
36
+ if (stat.isDirectory())
37
+ await walk(path);
38
+ else if (stat.isFile() && name.endsWith(".js"))
39
+ entries.push({ path, digest: sha256(await readFile(absolute)) });
40
+ }
41
+ }
42
+ await walk("runtime");
43
+ await walk("packages/cli");
44
+ return sha256(canonicalJson(entries));
45
+ }
46
+ function capabilities(config) {
47
+ return new Map([
48
+ ...config.verification.commands.map(command => ["command:" + command.id, command]),
49
+ ...(config.verification.acceptanceRunners ?? []).map(runner => ["runner:" + runner.id, runner]),
50
+ ...(config.worktree?.setup ?? []).map((command, index) => ["setup:" + index, command])
51
+ ]);
52
+ }
53
+ /** A renewal preserves earlier assessments; it does not turn them into user trust. */
54
+ export async function runPolicyAssessment(state, policy, capabilityId) {
55
+ const definition = capabilities(policy.config).get(capabilityId);
56
+ if (definition === undefined)
57
+ fail("RUN_POLICY_APPROVAL_REQUIRED", "Execution capability is absent from the current policy.");
58
+ if (canonicalJson(capabilities(policy.baseConfig).get(capabilityId) ?? null) === canonicalJson(definition))
59
+ return null;
60
+ let current = policy;
61
+ for (let depth = 0; depth < 128; depth++) {
62
+ const assessment = current.approvals.find(a => a.capabilityId === capabilityId);
63
+ if (assessment !== undefined && canonicalJson(capabilities(current.config).get(capabilityId) ?? null) === canonicalJson(definition))
64
+ return assessment;
65
+ if (current.previousDigest === null)
66
+ break;
67
+ const source = await readPrivateFile(join(state.commonDir, "agent-ops/runs", state.runId, "policies", current.previousDigest + ".json"), state.commonDir);
68
+ if (source === null || sha256(source) !== current.previousDigest)
69
+ fail("RUN_POLICY_CHANGED", "Historical capability assessment is missing or changed.");
70
+ const previous = JSON.parse(source);
71
+ current = (await readRunPolicy({ ...state, policyBinding: { artifactDigest: current.previousDigest, configHash: calculateConfigHash(previous.config),
72
+ runtimeHash: previous.runtimeHash, expiresAt: previous.expiresAt } }, previous.runtimeHash, Date.now(), true));
73
+ }
74
+ return fail("RUN_POLICY_APPROVAL_REQUIRED", "The current capability lacks a matching historical run assessment.");
75
+ }
76
+ /** Existing monotonic policy merging remains authoritative during a run. */
77
+ export function validateRunPolicyChange(previous, candidate, approvals) {
78
+ if (!validateConfig(previous).ok || !validateConfig(candidate).ok)
79
+ fail("RUN_POLICY_INVALID", "Proposed run policy is invalid.");
80
+ if (!validApprovals(approvals))
81
+ fail("RUN_POLICY_APPROVAL_REQUIRED", "Policy assessments require strict bounded capability identities and reasons.");
82
+ for (const field of ["features", "reviewRoles", "securityExceptions"])
83
+ if (canonicalJson(previous[field] ?? null) !== canonicalJson(candidate[field] ?? null))
84
+ fail("RUN_POLICY_GUARDRAIL_CHANGED", "A run cannot change completion, review or security policy.");
85
+ if (previous.worktree?.mode !== candidate.worktree?.mode)
86
+ fail("RUN_POLICY_GUARDRAIL_CHANGED", "A run cannot change worktree isolation.");
87
+ const config = mergeConfigLayers([{ source: "user", sourcePath: "run baseline", config: previous },
88
+ { source: "project", sourcePath: "coordinator proposal", config: candidate }]).config;
89
+ const before = capabilities(previous), after = capabilities(config);
90
+ const changed = [...after].filter(([id, value]) => canonicalJson(value) !== canonicalJson(before.get(id) ?? null));
91
+ if (new Set(approvals.map(a => a.capabilityId)).size !== approvals.length || approvals.length !== changed.length)
92
+ fail("RUN_POLICY_APPROVAL_REQUIRED", "Every changed execution capability needs its own run-scoped review.");
93
+ for (const [id, value] of changed) {
94
+ const approval = approvals.find(a => a.capabilityId === id);
95
+ if (approval?.classification !== "non-dangerous" || typeof approval.reason !== "string" ||
96
+ approval.reason.trim().length === 0 || approval.reason.length > 4096 || approval.reason.includes("\0"))
97
+ fail("RUN_POLICY_APPROVAL_REQUIRED", "Changed runner, command and setup definitions require a non-dangerous assessment and reason.");
98
+ const definition = value;
99
+ for (const command of [definition, ...(definition.setup ?? []), ...(definition.build ?? []), ...(definition.testBuild ?? [])]) {
100
+ const decision = evaluateGuardrail({ kind: "command", command: command.command, args: command.args, scope: "." });
101
+ if (decision.action !== "allow")
102
+ fail("RUN_POLICY_COMMAND_DENIED", "A guarded capability needs explicit user approval; a run cannot grant it automatically.");
103
+ }
104
+ }
105
+ if (evaluateGuardrail({ kind: "content", content: canonicalJson(config), scope: "." }).action === "block")
106
+ fail("RUN_POLICY_SECRET", "Policy must use secret references, not literal credentials.");
107
+ return config;
108
+ }
109
+ export async function readRunPolicy(state, runtimeHash, now = Date.now(), allowExpired = false) {
110
+ const binding = state.policyBinding;
111
+ if (binding === undefined)
112
+ return null;
113
+ const path = join(state.commonDir, "agent-ops", "runs", state.runId, "policies", binding.artifactDigest + ".json");
114
+ const source = await readPrivateFile(path, state.commonDir);
115
+ if (source === null || Buffer.byteLength(source) > 512 * 1024 || sha256(source) !== binding.artifactDigest)
116
+ fail("RUN_POLICY_CHANGED", "Run policy artifact is missing or changed.");
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(source);
120
+ }
121
+ catch {
122
+ return fail("RUN_POLICY_INVALID", "Run policy JSON is invalid.");
123
+ }
124
+ if (!plain(parsed) || Object.keys(parsed).some(key => !["schemaVersion", "runId", "config", "baseConfig", "baseTrustBinding", "runtimeHash", "expiresAt", "approvals", "previousDigest"].includes(key)) ||
125
+ !validBaseBinding(parsed.baseTrustBinding, state.root) || !validApprovals(parsed.approvals) ||
126
+ !(parsed.previousDigest === null || (typeof parsed.previousDigest === "string" && /^[a-f0-9]{64}$/u.test(parsed.previousDigest))))
127
+ fail("RUN_POLICY_INVALID", "Run policy artifact has an invalid structure or trust origin.");
128
+ const artifact = parsed;
129
+ if (artifact.schemaVersion !== 1 || artifact.runId !== state.runId || !validateConfig(artifact.config).ok ||
130
+ !validateConfig(artifact.baseConfig).ok || calculateConfigHash(artifact.config) !== binding.configHash ||
131
+ artifact.runtimeHash !== binding.runtimeHash || artifact.expiresAt !== binding.expiresAt ||
132
+ artifact.baseTrustBinding?.configHash !== calculateConfigHash(artifact.baseConfig))
133
+ fail("RUN_POLICY_INVALID", "Run policy does not match its ledger binding.");
134
+ if (runtimeHash !== binding.runtimeHash)
135
+ fail("RUN_RUNTIME_CHANGED", "The executable run runtime changed; preserve the checkout and revalidate before resume.");
136
+ if (!allowExpired && Date.parse(binding.expiresAt) <= now)
137
+ fail("RUN_POLICY_EXPIRED", "Run-scoped execution authorization expired.");
138
+ return artifact;
139
+ }
140
+ /** No user/repository trust record is created or changed. */
141
+ export async function bindRunPolicy(repository, runId, input) {
142
+ const state = await repository.read(runId);
143
+ if (state === null)
144
+ return fail("RUN_NOT_FOUND", "Run does not exist.");
145
+ const now = input.now ?? Date.now();
146
+ if (!Number.isFinite(now) || !validBaseBinding(input.baseTrustBinding, state.root))
147
+ fail("RUN_POLICY_INVALID", "Policy requires the original canonical repository trust binding and a valid clock.");
148
+ const previous = await readRunPolicy(state, input.resumeRuntime === true ? state.policyBinding?.runtimeHash ?? input.runtimeHash : input.runtimeHash, now, true);
149
+ if ((state.policyBinding?.artifactDigest ?? undefined) !== input.expectedDigest)
150
+ fail("RUN_POLICY_STALE", "The run policy changed before this assessment.");
151
+ if (state.status !== "active" || state.disableRestart || state.awaitingResume)
152
+ fail("RUN_POLICY_DISABLED", "Only an active run can acquire execution authorization.");
153
+ if (state.proofProcess != null)
154
+ fail("RUN_POLICY_WRITER_ACTIVE", "Confirm the registered proof process is dead before rebinding execution policy.");
155
+ if (previous !== null && state.workers.some(w => w.proofProcess != null || !["stopped", "fenced", "delivered", "blocked"].includes(w.status) ||
156
+ (w.processId !== null && w.stopIntent?.confirmedDeadAt == null)))
157
+ fail("RUN_POLICY_WRITER_ACTIVE", "Stop and confirm every writer before changing its execution policy.");
158
+ const config = previous === null ? input.config : validateRunPolicyChange(previous.config, input.config, input.approvals ?? []);
159
+ if (!validateConfig(config).ok || !/^[a-f0-9]{64}$/u.test(input.runtimeHash) ||
160
+ (previous === null && input.baseTrustBinding.configHash !== calculateConfigHash(config)))
161
+ fail("RUN_POLICY_INVALID", "Run policy or runtime identity is invalid.");
162
+ const remaining = state.budget.limitMs - activeWallTimeMs(state.budget.activeIntervals, now);
163
+ if (remaining <= 0)
164
+ fail("RUN_BUDGET_EXHAUSTED", "No active execution budget remains.");
165
+ const artifact = { schemaVersion: 1, runId, config, baseConfig: previous?.baseConfig ?? config,
166
+ baseTrustBinding: previous?.baseTrustBinding ?? input.baseTrustBinding, runtimeHash: input.runtimeHash,
167
+ expiresAt: new Date(now + remaining).toISOString(), approvals: (input.approvals ?? []).map(a => ({ ...a, reason: redactSecrets(a.reason) })), previousDigest: previous === null ? null : state.policyBinding.artifactDigest };
168
+ const content = canonicalJson(artifact), digest = sha256(content);
169
+ await writePrivateFile(join(state.commonDir, "agent-ops", "runs", runId, "policies", digest + ".json"), content, state.commonDir);
170
+ return await repository.mutate(runId, current => {
171
+ if (current.revision !== state.revision || (current.policyBinding?.artifactDigest ?? undefined) !== input.expectedDigest || current.status !== "active" || current.disableRestart)
172
+ return fail("RUN_POLICY_STALE", "The run stopped or its policy changed during assessment.");
173
+ return { ...current, policyBinding: { configHash: calculateConfigHash(config), runtimeHash: input.runtimeHash,
174
+ artifactDigest: digest, expiresAt: artifact.expiresAt } };
175
+ });
176
+ }
@@ -0,0 +1,71 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { sha256 } from "../fs/hash.js";
4
+ import { readPrivateFile } from "../security/permissions.js";
5
+ import { REVIEW_ATTESTATION_DIRECTORY, reviewReportDigest } from "../review/attestation.js";
6
+ import { validateReviewReport } from "../review/report.js";
7
+ import { goalHash } from "../task/contract.js";
8
+ import { loadFindingPin } from "../task/pin-finding.js";
9
+ const plain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10
+ /** Failed blocking findings need an explicit pin or a recorded recurrence before the next final gate. */
11
+ export async function pendingFindingPins(root, tasks, rootTaskId) {
12
+ const owner = tasks.find(record => record.task.id === rootTaskId);
13
+ if (owner === undefined)
14
+ return [];
15
+ const pending = new Set();
16
+ const currentTasks = tasks.filter(record => record.status !== "archived" && record.supersededBy === undefined);
17
+ const directory = join(root, REVIEW_ATTESTATION_DIRECTORY);
18
+ let names;
19
+ try {
20
+ names = await readdir(directory);
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ for (const name of names.filter(n => /^[a-f0-9]{64}(?:\.[a-z][a-z0-9-]{0,127})?\.reports\.json$/u.test(n))) {
26
+ const source = await readPrivateFile(join(directory, name), root);
27
+ if (source === null || Buffer.byteLength(source) > 512 * 1024)
28
+ continue;
29
+ let artifact;
30
+ try {
31
+ artifact = JSON.parse(source);
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ if (!plain(artifact) || artifact.schemaVersion !== 2 || artifact.status !== "FAIL" || artifact.goalHash !== goalHash(owner.task) ||
37
+ !plain(artifact.taskContracts) || !Object.keys(artifact.taskContracts).some(id => tasks.some(record => record.task.id === id)))
38
+ continue;
39
+ for (const report of [artifact.report, plain(artifact.adversarial) ? artifact.adversarial.report : undefined]) {
40
+ if (!plain(report) || !Array.isArray(report.results) || report.results.some(r => !plain(r) || typeof r.criterionId !== "string"))
41
+ continue;
42
+ const checked = validateReviewReport(report, report.results.map(r => r.criterionId));
43
+ if (!checked.ok)
44
+ continue;
45
+ const digest = reviewReportDigest(checked.value);
46
+ for (const [index, finding] of checked.value.findings.entries()) {
47
+ if (!finding.blocking)
48
+ continue;
49
+ const reference = digest + ":" + index;
50
+ const pin = sha256(reference);
51
+ let pinned = currentTasks.some(record => record.task.criteria.some(c => c.finding?.pinId === pin));
52
+ if (!pinned)
53
+ for (const record of currentTasks) {
54
+ for (const criterion of record.task.criteria.filter(c => c.finding !== undefined)) {
55
+ if (!record.revisions?.some(revision => revision.diagnostics.includes("finding:" + reference) &&
56
+ revision.diagnostics.includes("pin:" + criterion.finding.pinId)))
57
+ continue;
58
+ try {
59
+ await loadFindingPin(root, record, reference, criterion, tasks);
60
+ pinned = true;
61
+ }
62
+ catch { /* A claimed recurrence without saved source authority cannot discharge a finding. */ }
63
+ }
64
+ }
65
+ if (!pinned)
66
+ pending.add(reference);
67
+ }
68
+ }
69
+ }
70
+ return [...pending].sort();
71
+ }
@@ -0,0 +1,260 @@
1
+ import { AgentOpsError } from "../fs/paths.js";
2
+ import { MAX_ACTIVE_WORKERS } from "./service.js";
3
+ const ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
4
+ function error(code, message) {
5
+ return new AgentOpsError(code, message);
6
+ }
7
+ function replaceTask(state, taskId, update) {
8
+ const index = state.tasks.findIndex((task) => task.taskId === taskId);
9
+ if (index < 0)
10
+ throw error("RUN_TASK_NOT_FOUND", `Run task not found: ${taskId}`);
11
+ const tasks = [...state.tasks];
12
+ tasks[index] = { ...tasks[index], ...update };
13
+ return { ...state, tasks };
14
+ }
15
+ function dependencyState(tasks, task) {
16
+ const byId = new Map(tasks.map((candidate) => [candidate.taskId, candidate]));
17
+ let waiting = false;
18
+ for (const dependency of task.dependencies) {
19
+ const parent = byId.get(dependency);
20
+ if (parent === undefined || parent.status === "blocked")
21
+ return "blocked";
22
+ if (!["delivered", "complete"].includes(parent.status))
23
+ waiting = true;
24
+ }
25
+ return waiting ? "waiting" : "ready";
26
+ }
27
+ /** Validate the explicit dependency DAG; parentTaskId alone is not enough. */
28
+ export function validateRunDag(tasks) {
29
+ const ids = new Set();
30
+ for (const task of tasks) {
31
+ if (!ID.test(task.taskId) || ids.has(task.taskId))
32
+ throw error("RUN_DAG_INVALID", `Task identity is duplicated or invalid: ${task.taskId}`);
33
+ ids.add(task.taskId);
34
+ if (task.dependencies.some((dependency) => dependency === task.taskId || !ID.test(dependency))) {
35
+ throw error("RUN_DAG_INVALID", `Task ${task.taskId} has an invalid self or malformed dependency.`);
36
+ }
37
+ }
38
+ for (const task of tasks) {
39
+ for (const dependency of task.dependencies) {
40
+ if (!ids.has(dependency))
41
+ throw error("RUN_DAG_MISSING_DEPENDENCY", `Task ${task.taskId} depends on missing ${dependency}.`);
42
+ }
43
+ }
44
+ const visiting = new Set();
45
+ const visited = new Set();
46
+ const byId = new Map(tasks.map((task) => [task.taskId, task]));
47
+ const visit = (id) => {
48
+ if (visiting.has(id))
49
+ throw error("RUN_DAG_CYCLE", `Run task dependency cycle includes ${id}.`);
50
+ if (visited.has(id))
51
+ return;
52
+ visiting.add(id);
53
+ for (const dependency of byId.get(id).dependencies)
54
+ visit(dependency);
55
+ visiting.delete(id);
56
+ visited.add(id);
57
+ };
58
+ for (const task of tasks)
59
+ visit(task.taskId);
60
+ }
61
+ export function planSchedule(state) {
62
+ validateRunDag(state.tasks);
63
+ const running = state.tasks.filter((task) => task.status === "running" || task.status === "awaiting-delivery");
64
+ const blocked = state.tasks.filter((task) => task.status === "blocked");
65
+ const ready = state.tasks.filter((task) => ["planned", "ready"].includes(task.status) && dependencyState(state.tasks, task) === "ready");
66
+ return {
67
+ ready,
68
+ running,
69
+ blocked,
70
+ availableSlots: Math.max(0, Math.min(state.jobs, MAX_ACTIVE_WORKERS) - running.length)
71
+ };
72
+ }
73
+ /**
74
+ * Compute whole-run active wall time as the union of intervals. This prevents
75
+ * two parallel writers from doubling the 60-minute run budget.
76
+ */
77
+ export function activeWallTimeMs(intervals, nowMs) {
78
+ if (!Number.isFinite(nowMs) || nowMs < 0)
79
+ throw error("RUN_CLOCK_INVALID", "Budget clock is invalid.");
80
+ const sorted = intervals
81
+ .map(({ startMs, endMs }) => ({ startMs, endMs: endMs ?? nowMs }))
82
+ .filter(({ startMs, endMs }) => Number.isFinite(startMs) && Number.isFinite(endMs) && startMs >= 0 && endMs >= startMs)
83
+ .sort((left, right) => left.startMs - right.startMs);
84
+ let total = 0;
85
+ let start = null;
86
+ let end = 0;
87
+ for (const interval of sorted) {
88
+ if (start === null) {
89
+ start = interval.startMs;
90
+ end = interval.endMs;
91
+ continue;
92
+ }
93
+ if (interval.startMs <= end) {
94
+ end = Math.max(end, interval.endMs);
95
+ continue;
96
+ }
97
+ total += end - start;
98
+ start = interval.startMs;
99
+ end = interval.endMs;
100
+ }
101
+ return start === null ? 0 : total + end - start;
102
+ }
103
+ export function budgetExpired(state, now = Date.now()) {
104
+ return activeWallTimeMs(state.budget.activeIntervals, now) >= state.budget.limitMs;
105
+ }
106
+ /** Two consecutive identical failure rounds without a useful change stop one child. */
107
+ export function noProgressForTwoRounds(rounds) {
108
+ if (rounds.length < 2)
109
+ return false;
110
+ const current = rounds[rounds.length - 1];
111
+ const previous = rounds[rounds.length - 2];
112
+ return !current.usefulProgress && !previous.usefulProgress &&
113
+ current.taskId === previous.taskId &&
114
+ current.failureClass === previous.failureClass &&
115
+ current.failureId === previous.failureId &&
116
+ current.diagnosticDigest === previous.diagnosticDigest;
117
+ }
118
+ /** Persistent scheduler operations. Native writers are started by supervisor. */
119
+ export class RunScheduler {
120
+ #repository;
121
+ #now;
122
+ constructor(options) {
123
+ this.#repository = options.repository;
124
+ this.#now = options.now ?? (() => new Date().toISOString());
125
+ }
126
+ async addTasks(runId, tasks) {
127
+ return await this.#repository.mutate(runId, (current) => {
128
+ if (current.status !== "active" || current.disableRestart)
129
+ throw error("RUN_NOT_ACTIVE", "Only an active run can plan workers.");
130
+ const combined = [...current.tasks, ...tasks.map((task) => ({ ...task, status: "planned", workerId: null, deliveryDigest: null, sourceCommit: null, blockedReason: null }))];
131
+ validateRunDag(combined);
132
+ if (combined.length > 512)
133
+ throw error("RUN_DAG_INVALID", "Run task limit exceeded.");
134
+ return { ...current, tasks: combined };
135
+ });
136
+ }
137
+ async plan(runId) {
138
+ const state = await this.#repository.read(runId);
139
+ if (state === null)
140
+ throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
141
+ return planSchedule(state);
142
+ }
143
+ async markReady(runId) {
144
+ return await this.#repository.mutate(runId, (current) => {
145
+ validateRunDag(current.tasks);
146
+ let next = current;
147
+ for (const task of [...next.tasks]) {
148
+ if (!["planned", "ready"].includes(task.status))
149
+ continue;
150
+ const state = dependencyState(next.tasks, task);
151
+ if (state === "blocked")
152
+ next = replaceTask(next, task.taskId, { status: "blocked", blockedReason: "dependency-blocked" });
153
+ else if (state === "ready")
154
+ next = replaceTask(next, task.taskId, { status: "ready" });
155
+ }
156
+ return next;
157
+ });
158
+ }
159
+ async markDelivered(runId, taskId, deliveryDigest, sourceCommit) {
160
+ return await this.#repository.mutate(runId, (current) => {
161
+ const task = current.tasks.find((candidate) => candidate.taskId === taskId);
162
+ if (task === undefined)
163
+ throw error("RUN_TASK_NOT_FOUND", `Run task not found: ${taskId}`);
164
+ if (task.status === "delivered" && task.deliveryDigest === deliveryDigest && task.sourceCommit === sourceCommit)
165
+ return current;
166
+ if (!["running", "awaiting-delivery", "ready"].includes(task.status))
167
+ throw error("RUN_TASK_STATE_INVALID", `Task ${taskId} cannot be delivered from ${task.status}.`);
168
+ return replaceTask(current, taskId, { status: "delivered", deliveryDigest, sourceCommit });
169
+ });
170
+ }
171
+ async completeTask(runId, taskId) {
172
+ return await this.#repository.mutate(runId, (current) => {
173
+ const task = current.tasks.find((candidate) => candidate.taskId === taskId);
174
+ if (task === undefined || task.status !== "delivered")
175
+ throw error("RUN_TASK_NOT_DELIVERED", `Task ${taskId} is not delivered.`);
176
+ return replaceTask(current, taskId, { status: "complete" });
177
+ });
178
+ }
179
+ async blockTaskAndDependents(runId, taskId, reason, onlyWhileActive = false) {
180
+ const boundedReason = reason.slice(0, 4096);
181
+ return await this.#repository.mutate(runId, (current) => {
182
+ if (onlyWhileActive && (current.status !== "active" || current.disableRestart))
183
+ return current;
184
+ const ids = new Set([taskId]);
185
+ let changed = true;
186
+ while (changed) {
187
+ changed = false;
188
+ for (const task of current.tasks) {
189
+ if (task.dependencies.some((dependency) => ids.has(dependency)) && !ids.has(task.taskId)) {
190
+ ids.add(task.taskId);
191
+ changed = true;
192
+ }
193
+ }
194
+ }
195
+ let next = current;
196
+ for (const id of ids) {
197
+ const task = next.tasks.find((candidate) => candidate.taskId === id);
198
+ if (task !== undefined && task.status !== "complete")
199
+ next = replaceTask(next, id, { status: "blocked", blockedReason: boundedReason });
200
+ }
201
+ return next;
202
+ });
203
+ }
204
+ /** Return only tasks which are safe to assign in this scheduling turn. */
205
+ async next(runId) {
206
+ const plan = await this.plan(runId);
207
+ return plan.ready.slice(0, plan.availableSlots);
208
+ }
209
+ /** Useful for status output and for native adapters that report worker state. */
210
+ async workerCount(runId) {
211
+ const state = await this.#repository.read(runId);
212
+ if (state === null)
213
+ throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
214
+ return state.workers.filter((worker) => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)).length;
215
+ }
216
+ async openBudgetInterval(runId, startMs) {
217
+ if (!Number.isSafeInteger(startMs) || startMs < 0)
218
+ throw error("RUN_CLOCK_INVALID", "Budget interval start is invalid.");
219
+ return await this.#repository.mutate(runId, (current) => {
220
+ if (current.budget.activeIntervals.some((interval) => interval.startMs === startMs && interval.endMs === null))
221
+ return current;
222
+ return {
223
+ ...current,
224
+ budget: {
225
+ ...current.budget,
226
+ activeIntervals: [...current.budget.activeIntervals, { startMs, endMs: null }]
227
+ }
228
+ };
229
+ });
230
+ }
231
+ async closeBudgetInterval(runId, startMs, endMs) {
232
+ if (!Number.isSafeInteger(startMs) || !Number.isSafeInteger(endMs) || startMs < 0 || endMs < startMs)
233
+ throw error("RUN_CLOCK_INVALID", "Budget interval is invalid.");
234
+ return await this.#repository.mutate(runId, (current) => {
235
+ let closed = false;
236
+ const intervals = current.budget.activeIntervals.map((interval) => {
237
+ if (!closed && interval.startMs === startMs && interval.endMs === null) {
238
+ closed = true;
239
+ return { ...interval, endMs };
240
+ }
241
+ return interval;
242
+ });
243
+ const accumulatedMs = activeWallTimeMs(intervals, endMs);
244
+ return { ...current, budget: { ...current.budget, activeIntervals: intervals, accumulatedMs, lastObservedAt: new Date(endMs).toISOString() } };
245
+ });
246
+ }
247
+ async refreshBudget(runId, nowMs = Date.now()) {
248
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0)
249
+ throw error("RUN_CLOCK_INVALID", "Budget clock is invalid.");
250
+ return await this.#repository.mutate(runId, (current) => {
251
+ const accumulatedMs = activeWallTimeMs(current.budget.activeIntervals, nowMs);
252
+ const expired = accumulatedMs >= current.budget.limitMs;
253
+ return {
254
+ ...current,
255
+ status: expired && ["active", "awaiting-input"].includes(current.status) ? "budget-limited" : current.status,
256
+ budget: { ...current.budget, accumulatedMs, lastObservedAt: new Date(nowMs).toISOString() }
257
+ };
258
+ });
259
+ }
260
+ }