@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.
- package/README.md +48 -0
- package/dist/packages/cli/src/args.js +165 -2
- package/dist/packages/cli/src/bin.js +64 -17
- package/dist/packages/cli/src/cli.js +33 -6
- package/dist/packages/cli/src/commands/advance.js +23 -3
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +100 -9
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +61 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +288 -10
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +71 -5
- package/dist/runtime/src/parallel/service.js +41 -1
- package/dist/runtime/src/review/attestation.js +36 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +67 -7
- package/dist/runtime/src/task/completion.js +14 -2
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/service.js +316 -13
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/spec/acceptance-and-evidence.md +48 -0
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +265 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
7
|
+
import { budgetExpired } from "./scheduler.js";
|
|
8
|
+
/** Writer slots are deliberately bounded; the coordinator may occupy one. */
|
|
9
|
+
export const MAX_ACTIVE_WORKERS = 2;
|
|
10
|
+
const RUN_ID = /^[a-z][a-z0-9-]{7,63}$/u;
|
|
11
|
+
const ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
|
|
12
|
+
const SHA = /^[a-f0-9]{40,64}$/u;
|
|
13
|
+
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
14
|
+
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
|
|
15
|
+
const MAX_EVENTS = 2_000;
|
|
16
|
+
const MAX_GOAL_LENGTH = 64_000;
|
|
17
|
+
const MAX_TIME_BUDGET_MS = 24 * 60 * 60 * 1_000;
|
|
18
|
+
function invalid(message) {
|
|
19
|
+
throw new AgentOpsError("RUN_STATE_INVALID", message);
|
|
20
|
+
}
|
|
21
|
+
function bounded(value, label, max = 256) {
|
|
22
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max || value.includes("\0")) {
|
|
23
|
+
return invalid(`${label} must be a bounded non-empty string.`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function iso(value, label) {
|
|
28
|
+
const text = bounded(value, label, 64);
|
|
29
|
+
if (!Number.isFinite(Date.parse(text)))
|
|
30
|
+
return invalid(`${label} must be an ISO timestamp.`);
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
function nullableBounded(value, label, max = 256) {
|
|
34
|
+
return value === null ? null : bounded(value, label, max);
|
|
35
|
+
}
|
|
36
|
+
function clone(value) {
|
|
37
|
+
return structuredClone(value);
|
|
38
|
+
}
|
|
39
|
+
function validUsage(value) {
|
|
40
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
41
|
+
return invalid("Run usage is invalid.");
|
|
42
|
+
const item = value;
|
|
43
|
+
if (!["complete", "partial", "unknown"].includes(String(item.completeness)) ||
|
|
44
|
+
(item.tokens !== null && (!Number.isSafeInteger(item.tokens) || item.tokens < 0)) ||
|
|
45
|
+
(item.usd !== null && (typeof item.usd !== "number" || !Number.isFinite(item.usd) || item.usd < 0)) ||
|
|
46
|
+
(item.source !== null && typeof item.source !== "string") ||
|
|
47
|
+
!Number.isSafeInteger(item.epoch) || item.epoch < 0 ||
|
|
48
|
+
!Number.isSafeInteger(item.highWaterMark) || item.highWaterMark < 0) {
|
|
49
|
+
return invalid("Run usage contains an invalid or negative value.");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
tokens: item.tokens,
|
|
53
|
+
usd: item.usd,
|
|
54
|
+
completeness: item.completeness,
|
|
55
|
+
source: item.source,
|
|
56
|
+
epoch: item.epoch,
|
|
57
|
+
highWaterMark: item.highWaterMark
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
61
|
+
const boundedArray = (value, limit) => Array.isArray(value) && value.length <= limit;
|
|
62
|
+
function validPolicyBinding(value) {
|
|
63
|
+
if (!plain(value) || Object.keys(value).sort().join(",") !== "artifactDigest,configHash,expiresAt,runtimeHash")
|
|
64
|
+
return false;
|
|
65
|
+
return SHA256.test(String(value.configHash)) && SHA256.test(String(value.runtimeHash)) &&
|
|
66
|
+
SHA256.test(String(value.artifactDigest)) && typeof value.expiresAt === "string" &&
|
|
67
|
+
value.expiresAt.length <= 64 && ISO_TIMESTAMP.test(value.expiresAt) &&
|
|
68
|
+
Number.isFinite(Date.parse(value.expiresAt));
|
|
69
|
+
}
|
|
70
|
+
function validStateShape(value) {
|
|
71
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
72
|
+
return false;
|
|
73
|
+
const item = value;
|
|
74
|
+
return item.schemaVersion === 1 && typeof item.runId === "string" && RUN_ID.test(item.runId) &&
|
|
75
|
+
typeof item.root === "string" && typeof item.commonDir === "string" &&
|
|
76
|
+
typeof item.targetBranch === "string" && typeof item.goal === "string" && item.goal.length <= MAX_GOAL_LENGTH &&
|
|
77
|
+
typeof item.goalHash === "string" && SHA.test(item.goalHash) &&
|
|
78
|
+
(item.host === "claude" || item.host === "codex") && ID.test(String(item.coordinatorId)) &&
|
|
79
|
+
ID.test(String(item.ownerSessionId)) && (item.nativeInstance === null || typeof item.nativeInstance === "string") &&
|
|
80
|
+
(item.rootTaskId === null || typeof item.rootTaskId === "string") &&
|
|
81
|
+
(item.currentContractHash === null || (typeof item.currentContractHash === "string" && SHA.test(item.currentContractHash))) &&
|
|
82
|
+
Number.isSafeInteger(item.contractRevision) && item.contractRevision >= 0 &&
|
|
83
|
+
["active", "awaiting-input", "paused", "stopping", "complete", "blocked", "budget-limited", "failed"].includes(String(item.status)) &&
|
|
84
|
+
typeof item.budget === "object" && item.budget !== null && Array.isArray(item.tasks) &&
|
|
85
|
+
Array.isArray(item.workers) && Array.isArray(item.questions) && Array.isArray(item.controls) &&
|
|
86
|
+
Array.isArray(item.events) && item.events.length <= MAX_EVENTS && Number.isSafeInteger(item.jobs) &&
|
|
87
|
+
item.jobs >= 1 && item.jobs <= 2;
|
|
88
|
+
}
|
|
89
|
+
/** Validate persisted run state before it can drive a writer or a recovery. */
|
|
90
|
+
export function assertRunState(value) {
|
|
91
|
+
if (!validStateShape(value))
|
|
92
|
+
return invalid("Persisted run state has an unsupported shape.");
|
|
93
|
+
const state = value;
|
|
94
|
+
if ((state.revision !== undefined && (!Number.isSafeInteger(state.revision) || state.revision < 0)) ||
|
|
95
|
+
!boundedArray(state.tasks, 512) || !boundedArray(state.workers, 512) || !boundedArray(state.questions, 128) ||
|
|
96
|
+
!boundedArray(state.controls, 4096) || state.tasks.some(task => !plain(task) || !Array.isArray(task.dependencies) ||
|
|
97
|
+
task.dependencies.length > 512 || !["planned", "ready", "running", "awaiting-delivery", "delivered", "blocked", "complete"].includes(String(task.status))) ||
|
|
98
|
+
state.workers.some(worker => !plain(worker) || !["assigned", "starting", "running", "idle", "handing-off", "fenced", "delivered", "stopped", "blocked"].includes(String(worker.status))) ||
|
|
99
|
+
state.events.some(event => !plain(event)) || !Array.isArray(state.budget.activeIntervals) ||
|
|
100
|
+
state.budget.activeIntervals.some(interval => !plain(interval)))
|
|
101
|
+
return invalid("Run state contains malformed nested records.");
|
|
102
|
+
if (!RUN_ID.test(state.runId) || !SHA.test(state.goalHash) || state.goal.length > MAX_GOAL_LENGTH ||
|
|
103
|
+
sha256(state.goal) !== state.goalHash || !Number.isSafeInteger(state.budget.limitMs) ||
|
|
104
|
+
state.budget.limitMs <= 0 || state.budget.limitMs > MAX_TIME_BUDGET_MS ||
|
|
105
|
+
!Number.isSafeInteger(state.budget.accumulatedMs) || state.budget.accumulatedMs < 0 ||
|
|
106
|
+
!Array.isArray(state.budget.activeIntervals) || state.budget.activeIntervals.some((interval) => !Number.isSafeInteger(interval.startMs) || interval.startMs < 0 ||
|
|
107
|
+
(interval.endMs !== null && (!Number.isSafeInteger(interval.endMs) || interval.endMs < interval.startMs))) ||
|
|
108
|
+
state.tasks.some((task) => !ID.test(task.taskId) || task.dependencies.some((dep) => !ID.test(dep))) ||
|
|
109
|
+
state.workers.some((worker) => !ID.test(worker.workerId) || !ID.test(worker.taskId) || !ID.test(worker.ownerSessionId) ||
|
|
110
|
+
!Number.isSafeInteger(worker.generation) || worker.generation < 1 || worker.host !== state.host) ||
|
|
111
|
+
state.events.some((event) => !ID.test(event.id) || !ID.test(event.code) || !Number.isFinite(Date.parse(event.at))) ||
|
|
112
|
+
!Number.isFinite(Date.parse(state.createdAt)) || !Number.isFinite(Date.parse(state.updatedAt))) {
|
|
113
|
+
return invalid("Persisted run state failed identity, budget, or ownership validation.");
|
|
114
|
+
}
|
|
115
|
+
validUsage(state.budget.usage);
|
|
116
|
+
if (state.policyBinding !== undefined && !validPolicyBinding(state.policyBinding))
|
|
117
|
+
return invalid("Invalid run policy binding.");
|
|
118
|
+
if (state.proofProcess != null && (!plain(state.proofProcess) || !Number.isSafeInteger(state.proofProcess.processId) ||
|
|
119
|
+
state.proofProcess.processId < 1 || typeof state.proofProcess.processIdentity !== "string" ||
|
|
120
|
+
state.proofProcess.processIdentity.length === 0 || state.proofProcess.processIdentity.length > 256))
|
|
121
|
+
return invalid("Invalid proof process ownership.");
|
|
122
|
+
if (state.workers.some(w => w.proofProcess != null && (!plain(w.proofProcess) || !Number.isSafeInteger(w.proofProcess.processId) ||
|
|
123
|
+
w.proofProcess.processId < 1 || typeof w.proofProcess.processIdentity !== "string" || w.proofProcess.processIdentity.length === 0 || w.proofProcess.processIdentity.length > 256)))
|
|
124
|
+
return invalid("Invalid worker proof process ownership.");
|
|
125
|
+
if (state.integration !== null && (!plain(state.integration) || !ID.test(state.integration.transactionId) ||
|
|
126
|
+
!["prepared", "target-moved", "tasks-completed", "receipt-written", "cleaned", "blocked"].includes(state.integration.status) ||
|
|
127
|
+
!/^[a-f0-9]{40,64}$/u.test(state.integration.expectedTarget) || !/^[a-f0-9]{40,64}$/u.test(state.integration.candidate) ||
|
|
128
|
+
!boundedArray(state.integration.completedSteps, 1024) || state.integration.completedSteps.some(step => typeof step !== "string" || step.length > 256) ||
|
|
129
|
+
!boundedArray(state.integration.childDeliveries, 512) || state.integration.childDeliveries.some(commit => typeof commit !== "string" || !/^[a-f0-9]{40,64}$/u.test(commit)) ||
|
|
130
|
+
(state.integration.proofDigest !== undefined && !SHA.test(state.integration.proofDigest)) ||
|
|
131
|
+
(state.integration.receiptDigest !== null && !SHA.test(state.integration.receiptDigest)) ||
|
|
132
|
+
(state.integration.receiptPath !== null && typeof state.integration.receiptPath !== "string") ||
|
|
133
|
+
!Number.isFinite(Date.parse(state.integration.updatedAt))))
|
|
134
|
+
return invalid("Invalid integration transaction.");
|
|
135
|
+
if (state.usage !== undefined && (!Array.isArray(state.usage) || state.usage.length > 4096 || state.usage.some(item => !plain(item) || !["claude", "codex", "review", "verify"].includes(String(item.source)) || typeof item.epoch !== "string" || item.epoch.length > 256 ||
|
|
136
|
+
!["complete", "partial", "unknown"].includes(String(item.completeness)) || (typeof item.observedAt !== "string" || !Number.isFinite(Date.parse(item.observedAt))) ||
|
|
137
|
+
[item.inputTokens, item.outputTokens, item.totalTokens, item.costUsd].some(n => n !== null && (typeof n !== "number" || !Number.isFinite(n) || n < 0)))))
|
|
138
|
+
return invalid("Invalid usage epoch ledger.");
|
|
139
|
+
}
|
|
140
|
+
function event(input, now) {
|
|
141
|
+
return { ...input, id: randomUUID(), at: now };
|
|
142
|
+
}
|
|
143
|
+
export function createRunState(input) {
|
|
144
|
+
const now = input.now ?? new Date().toISOString();
|
|
145
|
+
const goal = bounded(input.goal.trim(), "goal", MAX_GOAL_LENGTH);
|
|
146
|
+
if (input.jobs !== undefined && (!Number.isSafeInteger(input.jobs) || input.jobs < 1 || input.jobs > 2)) {
|
|
147
|
+
throw new AgentOpsError("RUN_JOBS_INVALID", "A run supports one or two implementation workers.");
|
|
148
|
+
}
|
|
149
|
+
const limitMs = input.timeBudgetMs ?? 60 * 60 * 1_000;
|
|
150
|
+
if (!Number.isSafeInteger(limitMs) || limitMs <= 0 || limitMs > MAX_TIME_BUDGET_MS) {
|
|
151
|
+
throw new AgentOpsError("RUN_BUDGET_INVALID", "Run time budget must be a positive value no greater than 24 hours.");
|
|
152
|
+
}
|
|
153
|
+
const runId = input.runId ?? `run-${randomUUID().replaceAll("-", "").slice(0, 16)}`;
|
|
154
|
+
if (!RUN_ID.test(runId))
|
|
155
|
+
throw new AgentOpsError("RUN_ID_INVALID", "Run id must contain 8-64 lowercase letters, digits, and hyphens.");
|
|
156
|
+
const usage = { tokens: null, usd: null, completeness: "unknown", source: null, epoch: 0, highWaterMark: 0 };
|
|
157
|
+
const state = {
|
|
158
|
+
schemaVersion: 1,
|
|
159
|
+
revision: 0,
|
|
160
|
+
runId,
|
|
161
|
+
root: bounded(input.root, "root", 4096),
|
|
162
|
+
commonDir: bounded(input.commonDir, "commonDir", 4096),
|
|
163
|
+
targetBranch: bounded(input.targetBranch, "targetBranch", 256),
|
|
164
|
+
goal,
|
|
165
|
+
goalHash: sha256(goal),
|
|
166
|
+
host: input.host,
|
|
167
|
+
coordinatorId: `coordinator-${runId}`,
|
|
168
|
+
ownerSessionId: bounded(input.ownerSessionId, "ownerSessionId"),
|
|
169
|
+
nativeInstance: null,
|
|
170
|
+
rootTaskId: input.rootTaskId ?? null,
|
|
171
|
+
currentContractHash: input.contractHash ?? null,
|
|
172
|
+
contractRevision: 0,
|
|
173
|
+
status: "active",
|
|
174
|
+
createdAt: iso(now, "createdAt"),
|
|
175
|
+
updatedAt: iso(now, "updatedAt"),
|
|
176
|
+
bootIdentity: bounded(input.bootIdentity ?? "unknown", "bootIdentity"),
|
|
177
|
+
loginDomain: bounded(input.loginDomain ?? "unknown", "loginDomain"),
|
|
178
|
+
awaitingResume: false,
|
|
179
|
+
disableRestart: false,
|
|
180
|
+
budget: {
|
|
181
|
+
limitMs,
|
|
182
|
+
activeIntervals: [],
|
|
183
|
+
accumulatedMs: 0,
|
|
184
|
+
startedAt: iso(now, "budget.startedAt"),
|
|
185
|
+
lastObservedAt: iso(now, "budget.lastObservedAt"),
|
|
186
|
+
usage
|
|
187
|
+
},
|
|
188
|
+
usage: [],
|
|
189
|
+
jobs: input.jobs ?? 2,
|
|
190
|
+
tasks: [],
|
|
191
|
+
workers: [],
|
|
192
|
+
questions: [],
|
|
193
|
+
controls: [],
|
|
194
|
+
events: [event({ type: "created", code: "RUN_CREATED", workerId: null, taskId: null, detail: null }, now)],
|
|
195
|
+
integration: null
|
|
196
|
+
};
|
|
197
|
+
// The helper above intentionally only creates an event. Validate the actual state once.
|
|
198
|
+
assertRunState(state);
|
|
199
|
+
return state;
|
|
200
|
+
}
|
|
201
|
+
export class FileRunRepository {
|
|
202
|
+
#directory;
|
|
203
|
+
#anchor;
|
|
204
|
+
#now;
|
|
205
|
+
constructor(directory, anchor, now = () => new Date().toISOString()) {
|
|
206
|
+
this.#directory = directory;
|
|
207
|
+
this.#anchor = anchor;
|
|
208
|
+
this.#now = now;
|
|
209
|
+
}
|
|
210
|
+
#path(runId) {
|
|
211
|
+
if (!RUN_ID.test(runId))
|
|
212
|
+
throw new AgentOpsError("RUN_ID_INVALID", "Invalid run id.");
|
|
213
|
+
return join(this.#directory, `${runId}.json`);
|
|
214
|
+
}
|
|
215
|
+
async read(runId) {
|
|
216
|
+
const source = await readPrivateFile(this.#path(runId), this.#anchor);
|
|
217
|
+
if (source === null)
|
|
218
|
+
return null;
|
|
219
|
+
let value;
|
|
220
|
+
try {
|
|
221
|
+
value = JSON.parse(source);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
throw new AgentOpsError("RUN_STATE_INVALID", `Run ${runId} contains invalid JSON.`, { cause: error });
|
|
225
|
+
}
|
|
226
|
+
assertRunState(value);
|
|
227
|
+
return clone(value);
|
|
228
|
+
}
|
|
229
|
+
async write(state) {
|
|
230
|
+
assertRunState(state);
|
|
231
|
+
await mkdir(this.#directory, { recursive: true });
|
|
232
|
+
const path = this.#path(state.runId);
|
|
233
|
+
await withPrivateFileLock(path, this.#anchor, async () => {
|
|
234
|
+
const current = await this.read(state.runId);
|
|
235
|
+
if (current !== null && (current.revision ?? 0) !== (state.revision ?? 0))
|
|
236
|
+
throw new AgentOpsError("RUN_STATE_STALE", "Run changed since this snapshot was read.");
|
|
237
|
+
if (current !== null && (state.root !== current.root || state.commonDir !== current.commonDir ||
|
|
238
|
+
state.targetBranch !== current.targetBranch || state.goal !== current.goal || state.host !== current.host ||
|
|
239
|
+
state.ownerSessionId !== current.ownerSessionId || state.coordinatorId !== current.coordinatorId))
|
|
240
|
+
throw new AgentOpsError("RUN_IDENTITY_IMMUTABLE", "Run goal, target and ownership cannot change in a snapshot write.");
|
|
241
|
+
if (current?.status === "complete" && (state.status !== "complete" || !state.disableRestart || state.awaitingResume))
|
|
242
|
+
throw new AgentOpsError("RUN_ALREADY_COMPLETE", "Final completion is immutable; cleanup cannot revive or downgrade this run.");
|
|
243
|
+
const updated = { ...state, revision: current === null ? (state.revision ?? 0) : (current.revision ?? 0) + 1 };
|
|
244
|
+
await writePrivateFile(path, `${JSON.stringify(updated, null, 2)}\n`, this.#anchor);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
async create(state) {
|
|
248
|
+
assertRunState(state);
|
|
249
|
+
const path = this.#path(state.runId);
|
|
250
|
+
await mkdir(this.#directory, { recursive: true });
|
|
251
|
+
await withPrivateFileLock(path, this.#anchor, async () => {
|
|
252
|
+
if (await readPrivateFile(path, this.#anchor) !== null) {
|
|
253
|
+
throw new AgentOpsError("RUN_EXISTS", `Run already exists: ${state.runId}`);
|
|
254
|
+
}
|
|
255
|
+
await writePrivateFile(path, `${JSON.stringify(state, null, 2)}\n`, this.#anchor);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async mutate(runId, action) {
|
|
259
|
+
const path = this.#path(runId);
|
|
260
|
+
return await withPrivateFileLock(path, this.#anchor, async () => {
|
|
261
|
+
const current = await this.read(runId);
|
|
262
|
+
if (current === null)
|
|
263
|
+
throw new AgentOpsError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
264
|
+
const next = await action(current);
|
|
265
|
+
assertRunState(next);
|
|
266
|
+
if (next.runId !== current.runId || next.root !== current.root || next.commonDir !== current.commonDir ||
|
|
267
|
+
next.targetBranch !== current.targetBranch || next.goal !== current.goal || next.goalHash !== current.goalHash ||
|
|
268
|
+
next.host !== current.host || next.ownerSessionId !== current.ownerSessionId || next.coordinatorId !== current.coordinatorId)
|
|
269
|
+
throw new AgentOpsError("RUN_IDENTITY_IMMUTABLE", "Run goal, target and ownership cannot change during a mutation.");
|
|
270
|
+
if (current.status === "complete" && (next.status !== "complete" || !next.disableRestart || next.awaitingResume))
|
|
271
|
+
throw new AgentOpsError("RUN_ALREADY_COMPLETE", "Final completion is immutable; cleanup cannot revive or downgrade this run.");
|
|
272
|
+
const updated = { ...next, revision: (current.revision ?? 0) + 1, updatedAt: iso(this.#now(), "updatedAt") };
|
|
273
|
+
await writePrivateFile(path, `${JSON.stringify(updated, null, 2)}\n`, this.#anchor);
|
|
274
|
+
return clone(updated);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async list() {
|
|
278
|
+
let names;
|
|
279
|
+
try {
|
|
280
|
+
names = await readdir(this.#directory);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (error.code === "ENOENT")
|
|
284
|
+
return [];
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
const states = [];
|
|
288
|
+
for (const name of names.filter((candidate) => candidate.endsWith(".json"))) {
|
|
289
|
+
const id = basename(name, ".json");
|
|
290
|
+
const state = await this.read(id);
|
|
291
|
+
if (state !== null)
|
|
292
|
+
states.push(state);
|
|
293
|
+
}
|
|
294
|
+
return states.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
295
|
+
}
|
|
296
|
+
async appendEvent(runId, input) {
|
|
297
|
+
return await this.mutate(runId, (state) => {
|
|
298
|
+
const at = input.at ?? this.#now();
|
|
299
|
+
const nextEvents = [...state.events, event({
|
|
300
|
+
type: input.type,
|
|
301
|
+
code: input.code,
|
|
302
|
+
workerId: input.workerId,
|
|
303
|
+
taskId: input.taskId,
|
|
304
|
+
detail: input.detail
|
|
305
|
+
}, at)].slice(-MAX_EVENTS);
|
|
306
|
+
return { ...state, events: nextEvents };
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Small service facade used by the CLI and supervisor. Native host execution is
|
|
312
|
+
* injected; this layer owns persistent run identity and never treats a host's
|
|
313
|
+
* successful exit as agent-ops completion.
|
|
314
|
+
*/
|
|
315
|
+
export class RunService {
|
|
316
|
+
#repository;
|
|
317
|
+
#now;
|
|
318
|
+
constructor(repository, now = () => new Date().toISOString()) {
|
|
319
|
+
this.#repository = repository;
|
|
320
|
+
this.#now = now;
|
|
321
|
+
}
|
|
322
|
+
async start(input) {
|
|
323
|
+
const state = createRunState({ ...input, now: input.now ?? this.#now() });
|
|
324
|
+
if (this.#repository.create !== undefined)
|
|
325
|
+
await this.#repository.create(state);
|
|
326
|
+
else {
|
|
327
|
+
if (await this.#repository.read(state.runId) !== null) {
|
|
328
|
+
throw new AgentOpsError("RUN_EXISTS", `Run already exists: ${state.runId}`);
|
|
329
|
+
}
|
|
330
|
+
await this.#repository.write(state);
|
|
331
|
+
}
|
|
332
|
+
return { state, message: `Run ${state.runId} started; final proof is still required.` };
|
|
333
|
+
}
|
|
334
|
+
async status(runId) {
|
|
335
|
+
const state = await this.#repository.read(runId);
|
|
336
|
+
if (state === null)
|
|
337
|
+
throw new AgentOpsError("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
338
|
+
return state;
|
|
339
|
+
}
|
|
340
|
+
async logs(runId) {
|
|
341
|
+
return (await this.status(runId)).events;
|
|
342
|
+
}
|
|
343
|
+
async stop(runId, reason = "user requested stop") {
|
|
344
|
+
const state = await this.#repository.mutate(runId, (current) => {
|
|
345
|
+
if (current.status === "complete")
|
|
346
|
+
throw new AgentOpsError("RUN_ALREADY_COMPLETE", "A completed run cannot be changed into a stopped run.");
|
|
347
|
+
return {
|
|
348
|
+
...current,
|
|
349
|
+
status: "stopping",
|
|
350
|
+
disableRestart: true,
|
|
351
|
+
controls: [...current.controls, {
|
|
352
|
+
action: "stop", actor: "user", reason, requestedAt: this.#now(), expiresAt: null, appliedAt: null
|
|
353
|
+
}],
|
|
354
|
+
events: [...current.events, event({ type: "control", code: "RUN_STOP_REQUESTED", workerId: null, taskId: null, detail: reason }, this.#now())].slice(-MAX_EVENTS)
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
return { state, message: `Run ${runId} is stopping; native sessions remain pending until reconciled.` };
|
|
358
|
+
}
|
|
359
|
+
async pause(runId, reason = "user requested pause") {
|
|
360
|
+
const state = await this.#repository.mutate(runId, (current) => ({
|
|
361
|
+
...current,
|
|
362
|
+
status: "paused",
|
|
363
|
+
controls: [...current.controls, {
|
|
364
|
+
action: "pause", actor: "user", reason, requestedAt: this.#now(), expiresAt: null, appliedAt: null
|
|
365
|
+
}]
|
|
366
|
+
}));
|
|
367
|
+
return { state, message: `Run ${runId} is paused.` };
|
|
368
|
+
}
|
|
369
|
+
async resume(runId) {
|
|
370
|
+
const state = await this.#repository.mutate(runId, (current) => {
|
|
371
|
+
const recoverable = new Set(["RUN_RECOVERY_DIRTY", "RUN_NATIVE_VERSION_CHANGED", "RUN_RUNTIME_CHANGED", "RUN_REPO_UNTRUSTED",
|
|
372
|
+
"RUN_NATIVE_UNAVAILABLE", "RUN_RESTART_STORM", "RUN_POLICY_RECOVERY_REQUIRED", "RUN_POLICY_EXPIRED", "RUN_SETUP_RECOVERY_REQUIRED", "RUN_NATIVE_START_FAILED"]);
|
|
373
|
+
const blocker = [...current.events].reverse().find(e => recoverable.has(e.code) || ["RUN_NO_PROGRESS_REPEAT", "RUN_COMMAND_DENIED"].includes(e.code));
|
|
374
|
+
const terminalBlocker = current.events.some(e => e.code === "RUN_COMMAND_DENIED") ||
|
|
375
|
+
current.workers.some(w => w.workerId === current.coordinatorId && w.status === "blocked" && w.lastFailure !== null && w.lastFailure.failureClass !== "native-start");
|
|
376
|
+
if (current.status === "complete" || (current.integration?.proofDigest === undefined &&
|
|
377
|
+
(terminalBlocker || (current.status === "blocked" && !recoverable.has(blocker?.code ?? ""))))) {
|
|
378
|
+
throw new AgentOpsError("RUN_NOT_RESUMABLE", `Run ${runId} is ${current.status}.`);
|
|
379
|
+
}
|
|
380
|
+
if (current.integration?.proofDigest === undefined && budgetExpired(current, Date.parse(this.#now())))
|
|
381
|
+
throw new AgentOpsError("RUN_BUDGET_EXHAUSTED", "Explicit resume preserves the whole-run budget; no execution allowance remains.");
|
|
382
|
+
const recover = ["blocked", "paused"].includes(current.status) && recoverable.has(blocker?.code ?? "");
|
|
383
|
+
return {
|
|
384
|
+
...current,
|
|
385
|
+
...(recover ? { workers: current.workers.map(w => w.status === "blocked" && (w.lastFailure === null || w.lastFailure.failureClass === "native-start") ? { ...w, status: "stopped" } : w),
|
|
386
|
+
tasks: current.tasks.map(t => t.status === "blocked" && recoverable.has(t.blockedReason ?? "") ? { ...t, status: "ready", blockedReason: null } : t) } : {}),
|
|
387
|
+
status: "active",
|
|
388
|
+
awaitingResume: false,
|
|
389
|
+
disableRestart: false,
|
|
390
|
+
controls: [...current.controls, {
|
|
391
|
+
action: "resume", actor: "user", reason: null, requestedAt: this.#now(), expiresAt: null, appliedAt: null
|
|
392
|
+
}]
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
return { state, message: `Run ${runId} resumed; reconciliation is required before starting writers.` };
|
|
396
|
+
}
|
|
397
|
+
async respond(runId, questionId, answer) {
|
|
398
|
+
const cleanQuestion = bounded(questionId, "questionId");
|
|
399
|
+
const cleanAnswer = bounded(answer.trim(), "answer", MAX_GOAL_LENGTH);
|
|
400
|
+
const state = await this.#repository.mutate(runId, (current) => {
|
|
401
|
+
const question = current.questions.find((item) => item.questionId === cleanQuestion);
|
|
402
|
+
if (current.status !== "awaiting-input" || current.disableRestart)
|
|
403
|
+
throw new AgentOpsError("RUN_NOT_AWAITING_INPUT", "Respond cannot restart a stopped or terminal run.");
|
|
404
|
+
if (question === undefined)
|
|
405
|
+
throw new AgentOpsError("RUN_QUESTION_NOT_FOUND", `Question not found: ${cleanQuestion}`);
|
|
406
|
+
if (question.answeredAt !== null)
|
|
407
|
+
throw new AgentOpsError("RUN_QUESTION_ANSWERED", `Question already answered: ${cleanQuestion}`);
|
|
408
|
+
const answeredAt = this.#now();
|
|
409
|
+
return {
|
|
410
|
+
...current,
|
|
411
|
+
status: "active",
|
|
412
|
+
questions: current.questions.map((item) => item.questionId === cleanQuestion
|
|
413
|
+
? { ...item, answeredAt, answerDigest: sha256(cleanAnswer) }
|
|
414
|
+
: item),
|
|
415
|
+
controls: [...current.controls, {
|
|
416
|
+
action: "respond", actor: "user", reason: `answer:${sha256(cleanAnswer)}`, requestedAt: answeredAt, expiresAt: null, appliedAt: answeredAt
|
|
417
|
+
}]
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
return { state, message: `Recorded answer ${cleanQuestion}; the coordinator may resume planning.` };
|
|
421
|
+
}
|
|
422
|
+
async list() {
|
|
423
|
+
return await this.#repository.list();
|
|
424
|
+
}
|
|
425
|
+
}
|