@kylecheng3146/agent-ops 0.5.2 → 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 +64 -0
- package/dist/packages/cli/src/args.js +214 -10
- package/dist/packages/cli/src/bin.js +77 -17
- package/dist/packages/cli/src/cli.js +42 -10
- package/dist/packages/cli/src/commands/advance.js +173 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +201 -34
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +62 -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/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +10 -1
- package/dist/runtime/src/parallel/finish.js +217 -39
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +416 -0
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +153 -0
- package/dist/runtime/src/parallel/service.js +45 -3
- package/dist/runtime/src/review/attestation.js +60 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/render.js +71 -0
- 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 +72 -7
- package/dist/runtime/src/task/completion.js +26 -6
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +361 -10
- 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/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +58 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/en/spec/review.md +24 -2
- package/docs/zh-TW/guides/configuration.md +9 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +268 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { nativeProcessGroupAlive, signalNativeProcessGroup } from "./hosts/util.js";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
export async function nativeProcessIdentity(pid) {
|
|
7
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="], { maxBuffer: 8192 });
|
|
11
|
+
return stdout.trim() || null;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Adapt the native transport without activating a writer before its lease is saved. */
|
|
18
|
+
export class NativeRunTransport {
|
|
19
|
+
transport;
|
|
20
|
+
repository;
|
|
21
|
+
onEvent;
|
|
22
|
+
processIdentity;
|
|
23
|
+
host;
|
|
24
|
+
#sessions = new Map();
|
|
25
|
+
constructor(transport, repository, onEvent = async () => { }, processIdentity = nativeProcessIdentity) {
|
|
26
|
+
this.transport = transport;
|
|
27
|
+
this.repository = repository;
|
|
28
|
+
this.onEvent = onEvent;
|
|
29
|
+
this.processIdentity = processIdentity;
|
|
30
|
+
this.host = transport.kind;
|
|
31
|
+
}
|
|
32
|
+
async start(input) {
|
|
33
|
+
if (input.worktree === null || input.contractHash === null)
|
|
34
|
+
throw new AgentOpsError("RUN_NATIVE_CONTEXT_REQUIRED", "Native workers require a worktree and current contract hash.");
|
|
35
|
+
const saved = (await this.repository.read(input.runId))?.workers.find(w => w.workerId === input.workerId);
|
|
36
|
+
const request = { runId: input.runId, workerId: input.workerId, generation: input.generation,
|
|
37
|
+
contractHash: input.contractHash, cwd: input.worktree,
|
|
38
|
+
env: { AGENT_OPS_SESSION_ID: input.ownerSessionId, AGENT_OPS_HOST: this.host,
|
|
39
|
+
CODEX_THREAD_ID: undefined, AGENT_OPS_AGENT_ID: undefined,
|
|
40
|
+
AGENT_OPS_RUN_ID: input.runId, AGENT_OPS_WORKER_ID: input.workerId,
|
|
41
|
+
AGENT_OPS_WORKER_GENERATION: String(input.generation) },
|
|
42
|
+
sessionId: saved?.nativeSessionId ?? input.ownerSessionId,
|
|
43
|
+
registerProcess: async (pid) => {
|
|
44
|
+
const identity = await this.processIdentity(pid);
|
|
45
|
+
if (identity === null)
|
|
46
|
+
throw new AgentOpsError("RUN_PROCESS_IDENTITY_REQUIRED", "Cannot register provisional native process identity.");
|
|
47
|
+
await this.repository.mutate(input.runId, current => {
|
|
48
|
+
const worker = current.workers.find(w => w.workerId === input.workerId);
|
|
49
|
+
if (current.status !== "active" || current.disableRestart || worker?.status !== "starting" || worker.generation !== input.generation)
|
|
50
|
+
throw new AgentOpsError("RUN_START_DISABLED", "Stop fenced provisional native registration.");
|
|
51
|
+
return { ...current, workers: current.workers.map(w => w.workerId === input.workerId ? { ...w,
|
|
52
|
+
nativeSessionId: saved?.nativeSessionId ?? input.ownerSessionId, processId: pid, processIdentity: identity } : w) };
|
|
53
|
+
});
|
|
54
|
+
} };
|
|
55
|
+
const incompleteCodex = saved?.nativeSessionId != null && this.host === "codex" && saved.nativeJobId === null;
|
|
56
|
+
if (incompleteCodex && (saved.nativeGoalState !== "inactive" || saved.stopIntent?.confirmedDeadAt == null))
|
|
57
|
+
throw new AgentOpsError("RUN_NATIVE_THREAD_REQUIRED", "Missing Codex thread identity needs confirmed provisional process death before fresh initialization.");
|
|
58
|
+
const handle = saved?.nativeSessionId == null || incompleteCodex ? await this.transport.start(request)
|
|
59
|
+
: await this.transport.resume(request, this.host === "codex" ? saved.nativeJobId : saved.nativeSessionId);
|
|
60
|
+
const identity = handle.processId === null ? null : await this.processIdentity(handle.processId);
|
|
61
|
+
if (identity === null) {
|
|
62
|
+
await this.transport.stop(handle, handle);
|
|
63
|
+
throw new AgentOpsError("RUN_PROCESS_IDENTITY_REQUIRED", "Cannot register an unidentified native process.");
|
|
64
|
+
}
|
|
65
|
+
const entry = { handle, observation: { nativeGoalState: "inactive",
|
|
66
|
+
processAlive: true, processIdentity: identity, stateDigest: null } };
|
|
67
|
+
this.#sessions.set(handle.sessionId, entry);
|
|
68
|
+
// Drain output immediately, but the host has no goal and cannot start a turn yet.
|
|
69
|
+
void this.consume(entry).catch(async () => {
|
|
70
|
+
entry.observation = { ...entry.observation, nativeGoalState: "unknown" };
|
|
71
|
+
await this.repository.appendEvent(handle.runId, { type: "diagnostic", code: "RUN_NATIVE_STREAM_FAILED",
|
|
72
|
+
workerId: handle.workerId, taskId: null, detail: "Native event stream failed; completion remains unproven." }).catch(() => { });
|
|
73
|
+
});
|
|
74
|
+
return { nativeSessionId: handle.sessionId, nativeJobId: handle.threadId, processId: handle.processId,
|
|
75
|
+
processIdentity: identity, instance: handle.nativeVersion };
|
|
76
|
+
}
|
|
77
|
+
async activateRegistered(runId, worker, objective) {
|
|
78
|
+
const current = await this.repository.read(runId);
|
|
79
|
+
const saved = current?.workers.find(w => w.workerId === worker.workerId);
|
|
80
|
+
const entry = worker.nativeSessionId === null ? undefined : this.#sessions.get(worker.nativeSessionId);
|
|
81
|
+
if (current?.status !== "active" || saved?.status !== "running" || saved.generation !== worker.generation ||
|
|
82
|
+
saved.nativeSessionId !== worker.nativeSessionId || saved.stopIntent !== null || entry === undefined)
|
|
83
|
+
throw new AgentOpsError("RUN_WORKER_STALE", "Native activation requires a saved current writer lease.");
|
|
84
|
+
await this.transport.activate(entry.handle, objective, entry.handle);
|
|
85
|
+
}
|
|
86
|
+
async inspect(input) {
|
|
87
|
+
const entry = this.#sessions.get(input.nativeSessionId);
|
|
88
|
+
const identity = input.processId === null ? null : await this.processIdentity(input.processId);
|
|
89
|
+
const groupAlive = input.processId !== null && process.platform !== "win32" && nativeProcessGroupAlive(input.processId);
|
|
90
|
+
const alive = identity !== null || groupAlive;
|
|
91
|
+
if (identity !== null && identity !== input.processIdentity)
|
|
92
|
+
throw new AgentOpsError("RUN_PROCESS_IDENTITY_CHANGED", "A native PID has been reused; preserve the worktree and reconcile ownership.");
|
|
93
|
+
return { ...(entry?.observation ?? { nativeGoalState: "unknown", stateDigest: null }),
|
|
94
|
+
processAlive: alive, processIdentity: identity };
|
|
95
|
+
}
|
|
96
|
+
async resume(_input) {
|
|
97
|
+
throw new AgentOpsError("RUN_RECONCILE_REQUIRED", "Resume requires a new fenced lease and persisted native identity before activation.");
|
|
98
|
+
}
|
|
99
|
+
async send(input) {
|
|
100
|
+
const entry = this.#sessions.get(input.nativeSessionId);
|
|
101
|
+
if (entry === undefined || entry.handle.generation !== input.generation)
|
|
102
|
+
throw new AgentOpsError("RUN_WORKER_STALE", "Cannot update an unowned native session.");
|
|
103
|
+
await this.transport.update(entry.handle, { ...entry.handle, objective: input.message });
|
|
104
|
+
entry.observation = { ...entry.observation, nativeGoalState: "active" };
|
|
105
|
+
}
|
|
106
|
+
async stop(input) {
|
|
107
|
+
const runId = input.runId ?? this.#sessions.get(input.nativeSessionId)?.handle.runId;
|
|
108
|
+
const state = runId === undefined ? null : await this.repository.read(runId);
|
|
109
|
+
const worker = state?.workers.find(w => w.nativeSessionId === input.nativeSessionId && w.generation === input.generation);
|
|
110
|
+
if (worker?.proofProcess != null) {
|
|
111
|
+
const proof = worker.proofProcess;
|
|
112
|
+
await this.stop({ ...input, nativeSessionId: "worker-proof:" + worker.workerId, nativeJobId: null, ...proof });
|
|
113
|
+
await this.repository.mutate(state.runId, current => ({ ...current, workers: current.workers.map(w => w.workerId === worker.workerId &&
|
|
114
|
+
w.generation === input.generation && w.proofProcess?.processId === proof.processId ? { ...w, proofProcess: null } : w) }));
|
|
115
|
+
}
|
|
116
|
+
const entry = this.#sessions.get(input.nativeSessionId);
|
|
117
|
+
if (entry === undefined) {
|
|
118
|
+
const observation = await this.inspect(input);
|
|
119
|
+
if (!observation.processAlive)
|
|
120
|
+
return;
|
|
121
|
+
if (input.processId === null || observation.processIdentity === null || observation.processIdentity !== input.processIdentity)
|
|
122
|
+
throw new AgentOpsError("RUN_PROCESS_RECONCILE_REQUIRED", "Cannot identify surviving native descendants; takeover is refused.");
|
|
123
|
+
// A crash lost the in-memory transport, but the saved PID/start time still identifies this owned group.
|
|
124
|
+
const pid = input.processId;
|
|
125
|
+
await signalNativeProcessGroup(pid, "SIGTERM");
|
|
126
|
+
for (let i = 0; i < 30 && (await this.inspect(input)).processAlive; i++)
|
|
127
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
128
|
+
if ((await this.inspect(input)).processAlive) {
|
|
129
|
+
await signalNativeProcessGroup(pid, "SIGKILL");
|
|
130
|
+
for (let i = 0; i < 30 && (await this.inspect(input)).processAlive; i++)
|
|
131
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
132
|
+
}
|
|
133
|
+
if ((await this.inspect(input)).processAlive)
|
|
134
|
+
throw new AgentOpsError("RUN_WRITER_STILL_ALIVE", "The registered process group has not stopped; takeover is refused.");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (entry.handle.generation !== input.generation)
|
|
138
|
+
throw new AgentOpsError("RUN_WORKER_STALE", "Cannot stop a different writer generation.");
|
|
139
|
+
await this.inspect(input);
|
|
140
|
+
await this.transport.stop(entry.handle, entry.handle);
|
|
141
|
+
entry.observation = { ...entry.observation, processAlive: false, nativeGoalState: "cleared" };
|
|
142
|
+
}
|
|
143
|
+
async consume(entry) {
|
|
144
|
+
for await (const event of this.transport.observe(entry.handle)) {
|
|
145
|
+
const state = await this.repository.read(event.runId);
|
|
146
|
+
const worker = state?.workers.find(w => w.workerId === event.workerId);
|
|
147
|
+
if (worker?.generation !== event.generation || worker.nativeSessionId !== entry.handle.sessionId || worker.status === "fenced") {
|
|
148
|
+
await this.repository.appendEvent(event.runId, { type: "diagnostic", code: "RUN_NATIVE_STALE_EVENT", workerId: event.workerId,
|
|
149
|
+
taskId: null, detail: "Late native event rejected; no usage, authorization or delivery was accepted." });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
entry.observation = { ...entry.observation,
|
|
153
|
+
nativeGoalState: ["active", "paused", "complete"].includes(event.nativeStatus)
|
|
154
|
+
? event.nativeStatus : "unknown",
|
|
155
|
+
...(event.type === "closed" ? { processAlive: false } : {}) };
|
|
156
|
+
await this.onEvent(event);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { extractUsage } from "../review/usage.js";
|
|
2
|
+
import { recordUsage } from "./budget.js";
|
|
3
|
+
import { sha256 } from "../fs/hash.js";
|
|
4
|
+
import { readReviewReportArtifact, REVIEW_ATTESTATION_DIRECTORY } from "../review/attestation.js";
|
|
5
|
+
import { readdir } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
const plain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
8
|
+
const count = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : null;
|
|
9
|
+
function appendUsage(current, incoming) {
|
|
10
|
+
const usage = recordUsage(current.usage ?? [], incoming);
|
|
11
|
+
const total = (key) => usage.some(u => u[key] !== null)
|
|
12
|
+
? usage.reduce((sum, u) => sum + (u[key] ?? 0), 0) : null;
|
|
13
|
+
return { ...current, usage, budget: { ...current.budget, usage: { ...current.budget.usage,
|
|
14
|
+
tokens: total("totalTokens"), usd: total("costUsd"), completeness: "partial", source: "native-and-review-epoch-ledger",
|
|
15
|
+
highWaterMark: total("totalTokens") ?? 0 } } };
|
|
16
|
+
}
|
|
17
|
+
/** Copies of a tree report charge each fresh reviewer session only once. */
|
|
18
|
+
export async function recordReviewRunUsage(repository, runId, report) {
|
|
19
|
+
for (const attempt of Array.isArray(report.attempts) ? report.attempts : []) {
|
|
20
|
+
if (typeof attempt.sessionId !== "string" || !plain(attempt.metrics?.usage))
|
|
21
|
+
continue;
|
|
22
|
+
const value = attempt.metrics.usage;
|
|
23
|
+
await repository.mutate(runId, current => appendUsage(current, { source: "review",
|
|
24
|
+
epoch: sha256(attempt.target + ":" + attempt.sessionId), observedAt: report.createdAt,
|
|
25
|
+
inputTokens: value.inputTokens ?? null, outputTokens: value.outputTokens ?? null,
|
|
26
|
+
totalTokens: value.totalTokens ?? null, costUsd: value.costUsd ?? null, completeness: "partial" }));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function recordSavedReviewRunUsage(repository, runId, root) {
|
|
30
|
+
const state = await repository.read(runId);
|
|
31
|
+
if (state === null)
|
|
32
|
+
return;
|
|
33
|
+
let names;
|
|
34
|
+
try {
|
|
35
|
+
names = await readdir(join(root, REVIEW_ATTESTATION_DIRECTORY));
|
|
36
|
+
}
|
|
37
|
+
catch (cause) {
|
|
38
|
+
if (cause.code === "ENOENT")
|
|
39
|
+
return;
|
|
40
|
+
throw cause;
|
|
41
|
+
}
|
|
42
|
+
for (const name of names.filter(name => /^[a-f0-9]{64}\.[a-z][a-z0-9-]{0,127}\.reports\.json$/u.test(name))) {
|
|
43
|
+
const fingerprint = name.slice(0, 64);
|
|
44
|
+
const taskId = name.slice(65, -".reports.json".length);
|
|
45
|
+
const artifact = await readReviewReportArtifact(root, fingerprint, taskId);
|
|
46
|
+
if (artifact !== null && artifact.goalHash === state.goalHash && Date.parse(artifact.createdAt) >= Date.parse(state.createdAt))
|
|
47
|
+
await recordReviewRunUsage(repository, runId, artifact);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Only explicit native counters are consumed. Unknown cost never becomes zero. */
|
|
51
|
+
export async function recordNativeRunUsage(repository, event) {
|
|
52
|
+
if (!plain(event.payload))
|
|
53
|
+
return;
|
|
54
|
+
const state = await repository.read(event.runId);
|
|
55
|
+
const worker = state?.workers.find(w => w.workerId === event.workerId);
|
|
56
|
+
if (state === null || state === undefined || worker === undefined || worker.generation !== event.generation || worker.nativeSessionId === null)
|
|
57
|
+
return;
|
|
58
|
+
let incoming;
|
|
59
|
+
const payload = plain(event.payload.params) ? event.payload.params : event.payload;
|
|
60
|
+
if (event.host === "claude" && event.payload.type === "result" && typeof event.payload.uuid === "string") {
|
|
61
|
+
const usage = extractUsage("claude", JSON.stringify(event.payload), "");
|
|
62
|
+
if (usage !== undefined)
|
|
63
|
+
incoming = { source: "claude", epoch: `${worker.workerId}:${worker.nativeSessionId}:${event.payload.uuid}`,
|
|
64
|
+
inputTokens: usage.inputTokens ?? null, outputTokens: usage.outputTokens ?? null, totalTokens: usage.totalTokens ?? null,
|
|
65
|
+
costUsd: usage.costUsd ?? null, completeness: "partial", observedAt: event.at };
|
|
66
|
+
}
|
|
67
|
+
else if (event.host === "codex" && plain(payload.tokenUsage) && plain(payload.tokenUsage.total)) {
|
|
68
|
+
const usage = payload.tokenUsage.total;
|
|
69
|
+
incoming = { source: "codex", epoch: `${worker.workerId}:${worker.nativeSessionId}:thread`, inputTokens: count(usage.inputTokens),
|
|
70
|
+
outputTokens: count(usage.outputTokens), totalTokens: count(usage.totalTokens), costUsd: null,
|
|
71
|
+
completeness: "partial", observedAt: event.at };
|
|
72
|
+
}
|
|
73
|
+
if (incoming === undefined)
|
|
74
|
+
return;
|
|
75
|
+
await repository.mutate(event.runId, current => {
|
|
76
|
+
const saved = current.workers.find(w => w.workerId === event.workerId);
|
|
77
|
+
if (saved?.generation !== event.generation || saved.nativeSessionId !== worker.nativeSessionId)
|
|
78
|
+
return current;
|
|
79
|
+
return appendUsage(current, incoming);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { canonicalJson } from "../config/hash.js";
|
|
3
|
+
import { sha256 } from "../fs/hash.js";
|
|
4
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
5
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
6
|
+
import { redactSecrets } from "../security/redact.js";
|
|
7
|
+
import { taskContractHash } from "../task/contract.js";
|
|
8
|
+
import { FileEvidenceStore } from "../verify/evidence.js";
|
|
9
|
+
import { deriveFailureObservation } from "./convergence.js";
|
|
10
|
+
/** Preserve actual failed command reports, which are not completion evidence. */
|
|
11
|
+
export async function recordRunVerification(repository, state, workerId, task, report) {
|
|
12
|
+
if (state.policyBinding === undefined)
|
|
13
|
+
return;
|
|
14
|
+
const worker = state.workers.find(w => w.workerId === workerId);
|
|
15
|
+
if (worker === undefined || task.task.id !== report.taskId)
|
|
16
|
+
throw new AgentOpsError("RUN_WORKER_STALE", "Verifier report has no current worker identity.");
|
|
17
|
+
const content = redactSecrets(canonicalJson({ schemaVersion: 1, report, taskContractHash: taskContractHash(task.task),
|
|
18
|
+
policyArtifactDigest: state.policyBinding.artifactDigest }));
|
|
19
|
+
const digest = sha256(content);
|
|
20
|
+
const path = join(state.commonDir, "agent-ops/runs", state.runId, "verification", digest + ".json");
|
|
21
|
+
await repository.mutate(state.runId, async (current) => {
|
|
22
|
+
const saved = current.workers.find(w => w.workerId === workerId);
|
|
23
|
+
if (current.status !== "active" || current.disableRestart || saved?.generation !== worker.generation ||
|
|
24
|
+
current.policyBinding?.artifactDigest !== state.policyBinding.artifactDigest)
|
|
25
|
+
throw new AgentOpsError("RUN_VERIFICATION_STALE", "Verifier observation belongs to a stale run generation or policy.");
|
|
26
|
+
await writePrivateFile(path, content, current.commonDir);
|
|
27
|
+
return { ...current, events: [...current.events, { id: "verify-" + digest, at: new Date().toISOString(), type: "diagnostic",
|
|
28
|
+
code: "RUN_VERIFICATION_OBSERVED", workerId, taskId: report.taskId, detail: canonicalJson({ digest }) }].slice(-2000) };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Read current-contract observations; saved raw source fingerprints never identify failures. */
|
|
32
|
+
export async function observeRunFailure(state, root, records, taskId, code, pendingPinIds) {
|
|
33
|
+
const results = new Map();
|
|
34
|
+
const acceptance = [];
|
|
35
|
+
const evidence = new FileEvidenceStore(root, root);
|
|
36
|
+
for (const record of records.filter(r => r.status !== "archived" && r.supersededBy === undefined)) {
|
|
37
|
+
const events = state.events.filter(e => e.code === "RUN_VERIFICATION_OBSERVED" && e.taskId === record.task.id).reverse();
|
|
38
|
+
for (const event of events) {
|
|
39
|
+
const { digest } = JSON.parse(event.detail);
|
|
40
|
+
if (typeof digest !== "string" || !/^[a-f0-9]{64}$/u.test(digest))
|
|
41
|
+
throw new AgentOpsError("RUN_VERIFICATION_CHANGED", "Verifier observation digest is invalid.");
|
|
42
|
+
const source = await readPrivateFile(join(state.commonDir, "agent-ops/runs", state.runId, "verification", digest + ".json"), state.commonDir);
|
|
43
|
+
if (source === null || sha256(source) !== digest)
|
|
44
|
+
throw new AgentOpsError("RUN_VERIFICATION_CHANGED", "Verifier observation is missing or changed.");
|
|
45
|
+
const saved = JSON.parse(source);
|
|
46
|
+
if (saved.taskContractHash !== taskContractHash(record.task) || saved.policyArtifactDigest !== state.policyBinding?.artifactDigest)
|
|
47
|
+
continue;
|
|
48
|
+
for (const result of saved.report.results)
|
|
49
|
+
results.set(record.task.id + ":" + result.commandId, { ...result, commandId: record.task.id + ":" + result.commandId });
|
|
50
|
+
for (const phase of saved.report.acceptance ?? []) {
|
|
51
|
+
const proof = await evidence.load(phase.reference);
|
|
52
|
+
const criterion = record.task.criteria.find(c => c.id === phase.criterionId);
|
|
53
|
+
if (proof?.acceptance === undefined || proof.taskContractHash !== taskContractHash(record.task) || criterion?.acceptance === undefined || criterion.acceptance.mode === "review-only")
|
|
54
|
+
continue;
|
|
55
|
+
const bindings = criterion.acceptance.bindings.filter(b => b.runnerId === phase.runnerId);
|
|
56
|
+
acceptance.push({ ...phase, criterionId: record.task.id + ":" + phase.criterionId, checks: proof.acceptance.checks,
|
|
57
|
+
requiredCheckIds: bindings.flatMap(b => b.checkIds), redCheckIds: bindings.flatMap(b => b.redCheckIds ?? []) });
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return deriveFailureObservation({ taskId, status: "FAIL", results: results.size === 0 && acceptance.length === 0 && pendingPinIds.length === 0
|
|
63
|
+
? [{ commandId: code, required: true, status: "FAIL", failureClass: code }] : [...results.values()], acceptance }, { pendingPinIds });
|
|
64
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readWorktreeRecord } from "../parallel/service.js";
|
|
2
|
+
function blocked(code, remedy) {
|
|
3
|
+
return { action: "block", status: "FAIL", code, remedy };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A worker may stop after its coordinator-issued handoff is fenced. This is a
|
|
7
|
+
* narrow local delivery gate: it does not complete the task, grant root Stop,
|
|
8
|
+
* or accept a native goal's `complete` status as proof.
|
|
9
|
+
*/
|
|
10
|
+
export async function validateWorkerStopHandoff(repository, input, recordReader = readWorktreeRecord) {
|
|
11
|
+
const record = await recordReader(input.root);
|
|
12
|
+
if (record === null || record.runId !== input.runId || record.workerId !== input.workerId ||
|
|
13
|
+
record.workerGeneration !== input.generation ||
|
|
14
|
+
(record.agentId !== undefined && input.agentId !== undefined && record.agentId !== input.agentId)) {
|
|
15
|
+
return blocked("RUN_WORKER_HANDOFF_UNREGISTERED", "This Stop is not a coordinator-registered run worker handoff. Preserve the task and use the normal completion gate.");
|
|
16
|
+
}
|
|
17
|
+
const state = await repository.read(input.runId);
|
|
18
|
+
if (state === null)
|
|
19
|
+
return blocked("RUN_WORKER_RUN_MISSING", "The run ledger is unavailable; do not stop a writer without reconciliation.");
|
|
20
|
+
const worker = state.workers.find((candidate) => candidate.workerId === input.workerId);
|
|
21
|
+
if (worker?.workerId === state.coordinatorId)
|
|
22
|
+
return null;
|
|
23
|
+
if (worker === undefined || worker.worktree !== input.root || worker.ownerSessionId !== record.ownerSessionId ||
|
|
24
|
+
(input.sessionId !== worker.ownerSessionId && input.sessionId !== worker.nativeSessionId) ||
|
|
25
|
+
(record.coordinatorId !== undefined && record.coordinatorId !== state.coordinatorId))
|
|
26
|
+
return blocked("RUN_WORKER_HANDOFF_UNREGISTERED", "The native actor does not match this registered child checkout.");
|
|
27
|
+
if (worker === undefined || worker.generation !== input.generation)
|
|
28
|
+
return blocked("RUN_WORKER_GENERATION_STALE", "The worker lease is stale; reconcile the run before stopping this session.");
|
|
29
|
+
const intent = worker.stopIntent;
|
|
30
|
+
if (intent === null || intent.reason !== "handoff" || intent.runId !== state.runId ||
|
|
31
|
+
intent.nativeSessionId !== worker.nativeSessionId || intent.generation !== input.generation || intent.workerId !== input.workerId ||
|
|
32
|
+
(input.deliveryDigest !== undefined && intent.deliveryDigest !== input.deliveryDigest) ||
|
|
33
|
+
(input.contractDigest !== undefined && intent.contractDigest !== input.contractDigest)) {
|
|
34
|
+
return blocked("RUN_WORKER_HANDOFF_REQUIRED", "Native completion is not final proof. Ask the coordinator to create a matching handoff intent first.");
|
|
35
|
+
}
|
|
36
|
+
if (intent.confirmedDeadAt === null && (worker.status !== "handing-off" || Date.parse(intent.expiresAt) <= (input.nowMs ?? Date.now()))) {
|
|
37
|
+
return blocked("RUN_WORKER_HANDOFF_PENDING", "A live child needs a current coordinator-issued handoff intent before Stop.");
|
|
38
|
+
}
|
|
39
|
+
if (!["handing-off", "fenced", "delivered", "stopped"].includes(worker.status))
|
|
40
|
+
return blocked("RUN_WORKER_HANDOFF_PENDING", "Stop requires a fenced handoff; native completion alone is insufficient.");
|
|
41
|
+
return {
|
|
42
|
+
action: "continue",
|
|
43
|
+
status: "PASS",
|
|
44
|
+
code: "RUN_WORKER_HANDOFF_ALLOWED",
|
|
45
|
+
decision: "allow",
|
|
46
|
+
remedy: "Local worker handoff accepted. The task remains active until the coordinator completes final verify, dual review, and receipt proof."
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2
|
+
const text = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 4096 && !v.includes("\0");
|
|
3
|
+
const id = (v) => typeof v === "string" && /^[a-z][a-z0-9-]{0,127}$/u.test(v);
|
|
4
|
+
const keys = (v, allowed) => Object.keys(v).every(k => allowed.includes(k));
|
|
5
|
+
const dense = (v) => Array.isArray(v) &&
|
|
6
|
+
Array.from({ length: v.length }, (_, i) => Object.hasOwn(v, i)).every(Boolean);
|
|
7
|
+
const strings = (v) => dense(v) && v.length <= 512 && v.every(text) && new Set(v).size === v.length;
|
|
8
|
+
export const commitIdentity = (v) => typeof v === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(v);
|
|
9
|
+
export function acceptanceSourcePath(v) {
|
|
10
|
+
return text(v) && !v.startsWith("/") && !/[\\<>:"|?*\x00-\x1f\x7f]/u.test(v) &&
|
|
11
|
+
v.split("/").every(p => p !== "" && p !== "." && p !== ".." && !/[. ]$/u.test(p) &&
|
|
12
|
+
!/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/iu.test(p) &&
|
|
13
|
+
![".git", ".agent-ops"].includes(p.toLowerCase()));
|
|
14
|
+
}
|
|
15
|
+
export function materialPath(v) {
|
|
16
|
+
return acceptanceSourcePath(v) && !v.split("/").includes("node_modules") &&
|
|
17
|
+
!/^(?:dist|build|\.tmp)\//u.test(v) &&
|
|
18
|
+
!/^(?:package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.(?:toml|lock)|pyproject\.toml)$/u.test(v);
|
|
19
|
+
}
|
|
20
|
+
export function acceptanceError(v) {
|
|
21
|
+
if (!record(v) || !keys(v, ["mode", "baselineCommit", "bindings", "reviewOnlyReason"]) ||
|
|
22
|
+
!["behavioral", "invariant", "review-only"].includes(String(v.mode)) ||
|
|
23
|
+
!commitIdentity(v.baselineCommit) || !dense(v.bindings) || v.bindings.length > 64)
|
|
24
|
+
return "Acceptance requires a mode, immutable commit SHA and bounded bindings.";
|
|
25
|
+
if (v.mode === "review-only")
|
|
26
|
+
return v.bindings.length === 0 && text(v.reviewOnlyReason)
|
|
27
|
+
? undefined : "Review-only requires an explicit bounded reason and no mechanical bindings.";
|
|
28
|
+
if (v.reviewOnlyReason !== undefined)
|
|
29
|
+
return "Only review-only contracts carry a fallback reason.";
|
|
30
|
+
if (v.bindings.length === 0)
|
|
31
|
+
return "Mechanical acceptance requires a binding.";
|
|
32
|
+
const identities = new Set();
|
|
33
|
+
let red = 0;
|
|
34
|
+
for (const binding of v.bindings) {
|
|
35
|
+
if (!record(binding) || !keys(binding, ["runnerId", "checkIds", "redCheckIds", "materials"]) ||
|
|
36
|
+
!id(binding.runnerId) || !strings(binding.checkIds) || binding.checkIds.length === 0 ||
|
|
37
|
+
!dense(binding.materials) || binding.materials.length === 0 || binding.materials.length > 512)
|
|
38
|
+
return "Binding requires a runner, unique check IDs and explicit materials.";
|
|
39
|
+
if (binding.redCheckIds !== undefined &&
|
|
40
|
+
(!strings(binding.redCheckIds) || binding.redCheckIds.some(c => !binding.checkIds.includes(c))))
|
|
41
|
+
return "Red checks must be unique members of checkIds.";
|
|
42
|
+
red += binding.redCheckIds?.length ?? 0;
|
|
43
|
+
const paths = new Set();
|
|
44
|
+
for (const material of binding.materials) {
|
|
45
|
+
if (!record(material) || !keys(material, ["path", "role"]) || !materialPath(material.path) ||
|
|
46
|
+
!["test", "fixture", "helper"].includes(String(material.role)) || paths.has(material.path))
|
|
47
|
+
return "Materials must be unique safe relative test, fixture or helper files.";
|
|
48
|
+
paths.add(material.path);
|
|
49
|
+
}
|
|
50
|
+
for (const check of binding.checkIds) {
|
|
51
|
+
const identity = binding.runnerId + "\0" + check;
|
|
52
|
+
if (identities.has(identity))
|
|
53
|
+
return "Duplicate runner/check binding.";
|
|
54
|
+
identities.add(identity);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (v.mode === "behavioral" && red === 0)
|
|
58
|
+
return "Behavioral acceptance requires at least one designated red check.";
|
|
59
|
+
if (v.mode === "invariant" && red !== 0)
|
|
60
|
+
return "Invariant acceptance does not designate red checks.";
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
export function runnerError(v) {
|
|
64
|
+
if (!record(v) || !keys(v, ["id", "command", "args", "cwd", "adapter", "timeoutMs", "setup", "build", "testBuild"]) ||
|
|
65
|
+
!id(v.id) || !text(v.command) || !dense(v.args) || v.args.length > 512 ||
|
|
66
|
+
!v.args.every(a => typeof a === "string" && !a.includes("\0") && a.length <= 4096 &&
|
|
67
|
+
(a === "{materials}" || !/[{}]/u.test(a))) ||
|
|
68
|
+
!(v.cwd === "." || materialPath(v.cwd)) ||
|
|
69
|
+
!["generic", "node", "jest", "vitest", "pytest", "rust"].includes(String(v.adapter)))
|
|
70
|
+
return "Acceptance runner requires fixed executable/argv, safe cwd and supported adapter.";
|
|
71
|
+
const timeout = (t) => t === undefined ||
|
|
72
|
+
(Number.isSafeInteger(t) && t > 0 && t <= 3_600_000);
|
|
73
|
+
if (!timeout(v.timeoutMs))
|
|
74
|
+
return "Runner timeout is invalid.";
|
|
75
|
+
for (const phase of ["setup", "build", "testBuild"]) {
|
|
76
|
+
if (v[phase] === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
if (!dense(v[phase]) || v[phase].length > 64)
|
|
79
|
+
return "Runner setup/build must be bounded.";
|
|
80
|
+
for (const step of v[phase]) {
|
|
81
|
+
if (!record(step) || !keys(step, ["command", "args", "timeoutMs"]) || !text(step.command) ||
|
|
82
|
+
!dense(step.args) || step.args.length > 512 ||
|
|
83
|
+
!step.args.every(a => typeof a === "string" && !a.includes("\0") && a.length <= 4096) ||
|
|
84
|
+
!timeout(step.timeoutMs))
|
|
85
|
+
return "Setup/build steps require fixed command/argv and timeout.";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
export function isAcceptance(v) { return acceptanceError(v) === undefined; }
|
|
91
|
+
export function isAcceptanceRunner(v) { return runnerError(v) === undefined; }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
2
|
+
import { acceptanceError, runnerError, commitIdentity } from "./acceptance.js";
|
|
2
3
|
const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
|
|
3
4
|
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
4
5
|
const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)?$/i;
|
|
@@ -356,7 +357,7 @@ export function validateConfig(value) {
|
|
|
356
357
|
if (!isRecord(root.verification)) {
|
|
357
358
|
return failure("INVALID_TYPE", "$.verification", "verification must be an object.");
|
|
358
359
|
}
|
|
359
|
-
const verificationUnknown = unknownFieldFailure(root.verification, ["commands"], "$.verification");
|
|
360
|
+
const verificationUnknown = unknownFieldFailure(root.verification, ["commands", "acceptanceRunners"], "$.verification");
|
|
360
361
|
if (verificationUnknown !== undefined) {
|
|
361
362
|
return verificationUnknown;
|
|
362
363
|
}
|
|
@@ -374,6 +375,20 @@ export function validateConfig(value) {
|
|
|
374
375
|
}
|
|
375
376
|
commandIds.add(command.value.id);
|
|
376
377
|
}
|
|
378
|
+
if (root.verification.acceptanceRunners !== undefined) {
|
|
379
|
+
if (!Array.isArray(root.verification.acceptanceRunners) || root.verification.acceptanceRunners.length > 128)
|
|
380
|
+
return failure("INVALID_ACCEPTANCE_RUNNER", "$.verification.acceptanceRunners", "Expected bounded runner array.");
|
|
381
|
+
const runnerIds = new Set();
|
|
382
|
+
for (const runner of root.verification.acceptanceRunners) {
|
|
383
|
+
const issue = runnerError(runner);
|
|
384
|
+
if (issue !== undefined)
|
|
385
|
+
return failure("INVALID_ACCEPTANCE_RUNNER", "$.verification.acceptanceRunners", issue);
|
|
386
|
+
const runnerId = runner.id;
|
|
387
|
+
if (runnerIds.has(runnerId) || commandIds.has(runnerId))
|
|
388
|
+
return failure("DUPLICATE_ID", "$.verification.acceptanceRunners", "Runner IDs must be unique and distinct from policy commands.");
|
|
389
|
+
runnerIds.add(runnerId);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
377
392
|
if (root.features.stopVerification.enabled === true &&
|
|
378
393
|
root.verification.commands.length === 0) {
|
|
379
394
|
return failure("STOP_VERIFICATION_COMMANDS_REQUIRED", "$.features.stopVerification.enabled", "Stop verification requires at least one verification command.");
|
|
@@ -498,11 +513,11 @@ function validateReviewRole(value, path) {
|
|
|
498
513
|
}
|
|
499
514
|
return success(value);
|
|
500
515
|
}
|
|
501
|
-
function validateCriterion(value, path) {
|
|
516
|
+
export function validateCriterion(value, path) {
|
|
502
517
|
if (!isRecord(value)) {
|
|
503
518
|
return failure("INVALID_TYPE", path, "Expected a criterion object.");
|
|
504
519
|
}
|
|
505
|
-
const unknown = unknownFieldFailure(value, ["description", "id", "verifierIds"], path);
|
|
520
|
+
const unknown = unknownFieldFailure(value, ["description", "id", "verifierIds", "acceptance", "finding"], path);
|
|
506
521
|
if (unknown !== undefined) {
|
|
507
522
|
return unknown;
|
|
508
523
|
}
|
|
@@ -513,20 +528,43 @@ function validateCriterion(value, path) {
|
|
|
513
528
|
return failure("INVALID_DESCRIPTION", `${path}.description`, "Criterion description is required.");
|
|
514
529
|
}
|
|
515
530
|
if (!isStringArray(value.verifierIds) ||
|
|
516
|
-
value.verifierIds.length === 0 ||
|
|
531
|
+
(value.verifierIds.length === 0 && value.acceptance === undefined) ||
|
|
517
532
|
!value.verifierIds.every(isIdentifier)) {
|
|
518
533
|
return failure("INVALID_VERIFIER_REFERENCE", `${path}.verifierIds`, "A criterion requires at least one valid verifier ID.");
|
|
519
534
|
}
|
|
520
535
|
if (!hasUniqueStrings(value.verifierIds)) {
|
|
521
536
|
return failure("DUPLICATE_ID", `${path}.verifierIds`, "Criterion verifier IDs must be unique.");
|
|
522
537
|
}
|
|
538
|
+
if (value.acceptance !== undefined) {
|
|
539
|
+
const issue = acceptanceError(value.acceptance);
|
|
540
|
+
if (issue !== undefined)
|
|
541
|
+
return failure("INVALID_ACCEPTANCE", path + ".acceptance", issue);
|
|
542
|
+
}
|
|
543
|
+
if (value.finding !== undefined) {
|
|
544
|
+
const f = value.finding;
|
|
545
|
+
if (!isRecord(f) || unknownFieldFailure(f, ["pinId", "reportDigest", "findingIndex", "candidateCommit", "criterionIds"], path + ".finding") !== undefined ||
|
|
546
|
+
typeof f.pinId !== "string" || !HASH_PATTERN.test(f.pinId) || typeof f.reportDigest !== "string" || !HASH_PATTERN.test(f.reportDigest) ||
|
|
547
|
+
!Number.isSafeInteger(f.findingIndex) || f.findingIndex < 0 || !commitIdentity(f.candidateCommit) ||
|
|
548
|
+
!isStringArray(f.criterionIds) || !f.criterionIds.every(id => typeof id === "string" && id.split(":").length <= 2 && id.split(":").every(isIdentifier)) || !hasUniqueStrings(f.criterionIds))
|
|
549
|
+
return failure("INVALID_FINDING_REFERENCE", path + ".finding", "Invalid immutable finding identity.");
|
|
550
|
+
if (value.acceptance === undefined || !isRecord(value.acceptance) || value.acceptance.mode === "invariant" ||
|
|
551
|
+
value.acceptance.baselineCommit !== f.candidateCommit)
|
|
552
|
+
return failure("INVALID_FINDING_BASELINE", path + ".finding", "A finding retains its failed-candidate baseline and behavioral or explicit review-only mode.");
|
|
553
|
+
}
|
|
523
554
|
return success(value);
|
|
524
555
|
}
|
|
525
556
|
export function validateTask(value) {
|
|
526
|
-
const root = validateRoot(value, ["criteria", "id", "parentTaskId", "schemaVersion", "title"], TASK_SCHEMA_VERSION);
|
|
557
|
+
const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
|
|
527
558
|
if (isFailure(root)) {
|
|
528
559
|
return root;
|
|
529
560
|
}
|
|
561
|
+
if (root.contractRevision !== undefined && (!Number.isSafeInteger(root.contractRevision) || root.contractRevision < 0))
|
|
562
|
+
return failure("INVALID_CONTRACT_REVISION", "$.contractRevision", "Invalid contract revision.");
|
|
563
|
+
if (root.goal !== undefined && (!isNonEmptyString(root.goal) || Buffer.byteLength(root.goal, "utf8") > 65536))
|
|
564
|
+
return failure("INVALID_GOAL", "$.goal", "Goal must be non-empty and at most 64 KiB.");
|
|
565
|
+
if (root.schemaVersion === 1 && (root.goal !== undefined || root.contractRevision !== undefined ||
|
|
566
|
+
(Array.isArray(root.criteria) && root.criteria.some(c => isRecord(c) && (c.acceptance !== undefined || c.finding !== undefined)))))
|
|
567
|
+
return failure("WRONG_SCHEMA_VERSION", "$.schemaVersion", "Typed acceptance requires task schema 2.");
|
|
530
568
|
if (!isIdentifier(root.id)) {
|
|
531
569
|
return failure("INVALID_ID", "$.id", "Invalid task ID.");
|
|
532
570
|
}
|
|
@@ -539,6 +577,11 @@ export function validateTask(value) {
|
|
|
539
577
|
if (!isNonEmptyString(root.title)) {
|
|
540
578
|
return failure("INVALID_TITLE", "$.title", "Task title is required.");
|
|
541
579
|
}
|
|
580
|
+
if (root.intent !== undefined &&
|
|
581
|
+
(typeof root.intent !== "string" || root.intent.trim() === "" ||
|
|
582
|
+
root.intent.includes("\0") || Buffer.byteLength(root.intent, "utf8") > 8192)) {
|
|
583
|
+
return failure("INVALID_INTENT", "$.intent", "Task intent must be non-empty and at most 8 KiB.");
|
|
584
|
+
}
|
|
542
585
|
if (!Array.isArray(root.criteria) ||
|
|
543
586
|
root.criteria.length < 2 ||
|
|
544
587
|
root.criteria.length > 5) {
|
|
@@ -573,6 +616,13 @@ export function validateTaskAgainstConfig(taskValue, configValue) {
|
|
|
573
616
|
return failure("UNKNOWN_VERIFIER_REFERENCE", `$.criteria[${criterionIndex}].verifierIds`, `Unknown verifier reference: ${unknownReference}`);
|
|
574
617
|
}
|
|
575
618
|
}
|
|
619
|
+
const runnerIds = new Set(config.value.verification.acceptanceRunners?.map(r => r.id) ?? []);
|
|
620
|
+
for (const criterion of task.value.criteria) {
|
|
621
|
+
for (const binding of criterion.acceptance?.bindings ?? []) {
|
|
622
|
+
if (!runnerIds.has(binding.runnerId))
|
|
623
|
+
return failure("UNKNOWN_ACCEPTANCE_RUNNER", "$.criteria", "Unknown acceptance runner: " + binding.runnerId);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
576
626
|
return task;
|
|
577
627
|
}
|
|
578
628
|
export function validateEvidence(value) {
|
|
@@ -592,11 +642,26 @@ export function validateEvidence(value) {
|
|
|
592
642
|
"status",
|
|
593
643
|
"taskId",
|
|
594
644
|
"testCount",
|
|
595
|
-
"toolVersions"
|
|
596
|
-
], isRecord(value) && value.schemaVersion === 2 ?
|
|
645
|
+
"toolVersions", "taskContractHash", "acceptance"
|
|
646
|
+
], isRecord(value) && (value.schemaVersion === 2 || value.schemaVersion === 3) ? value.schemaVersion : EVIDENCE_SCHEMA_VERSION);
|
|
597
647
|
if (isFailure(root)) {
|
|
598
648
|
return root;
|
|
599
649
|
}
|
|
650
|
+
if (root.taskContractHash !== undefined && (root.schemaVersion !== 4 || typeof root.taskContractHash !== "string" || !HASH_PATTERN.test(root.taskContractHash)))
|
|
651
|
+
return failure("INVALID_CONTRACT_HASH", "$.taskContractHash", "Contract evidence requires schema 4 and a digest.");
|
|
652
|
+
if (root.acceptance !== undefined) {
|
|
653
|
+
const a = root.acceptance;
|
|
654
|
+
if (root.schemaVersion !== 4 || !isRecord(a) || root.taskContractHash === undefined ||
|
|
655
|
+
unknownFieldFailure(a, ["criterionContractHash", "phase", "commit", "pairedCommit", "bindingHash", "materialDigest", "executionDigest", "executionArtifact", "checks"], "$.acceptance") !== undefined ||
|
|
656
|
+
!["baseline", "candidate"].includes(String(a.phase)) || !commitIdentity(a.commit) || !commitIdentity(a.pairedCommit) ||
|
|
657
|
+
![a.criterionContractHash, a.bindingHash, a.materialDigest, a.executionDigest].every(h => typeof h === "string" && HASH_PATTERN.test(h)) ||
|
|
658
|
+
typeof a.executionArtifact !== "string" || !/^\.agent-ops\/tasks\/acceptance\/[a-f0-9]{64}\.json$/u.test(a.executionArtifact) ||
|
|
659
|
+
!Array.isArray(a.checks) || a.checks.length === 0 || a.checks.length > 512 ||
|
|
660
|
+
a.checks.some(c => !isRecord(c) || unknownFieldFailure(c, ["checkId", "status", "failureClass"], "$.acceptance.checks") !== undefined ||
|
|
661
|
+
!isNonEmptyString(c.checkId) || !["PASS", "FAIL", "UNKNOWN"].includes(String(c.status)) || !isNonEmptyString(c.failureClass)) ||
|
|
662
|
+
new Set(a.checks.map(c => c.checkId)).size !== a.checks.length)
|
|
663
|
+
return failure("INVALID_ACCEPTANCE_EVIDENCE", "$.acceptance", "Invalid phase/check evidence.");
|
|
664
|
+
}
|
|
600
665
|
for (const field of ["taskId", "criterionId", "commandId"]) {
|
|
601
666
|
if (!isIdentifier(root[field])) {
|
|
602
667
|
return failure("INVALID_ID", `$.${field}`, `Invalid ${field}.`);
|