@kylecheng3146/agent-ops 0.5.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -1
- package/dist/packages/cli/src/args.js +183 -3
- package/dist/packages/cli/src/bin.js +76 -19
- package/dist/packages/cli/src/cli.js +53 -8
- package/dist/packages/cli/src/codex-loop-process.js +19 -2
- package/dist/packages/cli/src/commands/advance.js +42 -5
- package/dist/packages/cli/src/commands/init.js +2 -0
- package/dist/packages/cli/src/commands/office.js +20 -0
- 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 +115 -0
- package/dist/packages/cli/src/commands/task.js +61 -17
- package/dist/packages/cli/src/commands/update.js +8 -0
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +64 -1
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +54 -3
- package/dist/packages/cli/src/loop-entry.js +2 -1
- package/dist/packages/cli/src/office-entry.js +230 -0
- 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 +321 -0
- package/dist/packages/cli/src/run-entry.js +952 -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/packages/cli/src/wizard.js +14 -2
- package/dist/runtime/src/adapters/agy/config.js +4 -3
- package/dist/runtime/src/adapters/agy/events.js +9 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -3
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -3
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/opencode/config.js +5 -4
- package/dist/runtime/src/adapters/opencode/events.js +8 -0
- package/dist/runtime/src/config/explain.js +11 -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/codex-loop.js +4 -0
- 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 +44 -1
- package/dist/runtime/src/install/plan.js +16 -11
- package/dist/runtime/src/install/preauth.js +44 -18
- package/dist/runtime/src/install/profiles.js +8 -2
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/office/collect.js +233 -0
- package/dist/runtime/src/office/page.js +873 -0
- package/dist/runtime/src/office/scene.js +168 -0
- package/dist/runtime/src/office/server.js +139 -0
- package/dist/runtime/src/office/sessions.js +115 -0
- package/dist/runtime/src/office/snapshot.js +107 -0
- package/dist/runtime/src/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +12 -2
- 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/phase.js +37 -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 +433 -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 +80 -9
- 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/harness-adapters.md +113 -1
- 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/harness-adapters.md +93 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +230 -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,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,8 +1,9 @@
|
|
|
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;
|
|
5
|
-
const PROFILE_VALUES = new Set(["advisory", "core", "guardrails", "loop"]);
|
|
6
|
+
const PROFILE_VALUES = new Set(["advisory", "core", "guardrails", "loop", "run"]);
|
|
6
7
|
const EVIDENCE_KINDS = new Set(["exit-code", "file", "test-count"]);
|
|
7
8
|
const SCOPE_VALUES = new Set(["project", "user"]);
|
|
8
9
|
const HARNESS_VALUES = new Set(["agy", "claude", "codex", "opencode"]);
|
|
@@ -329,10 +330,21 @@ export function validateConfig(value) {
|
|
|
329
330
|
if (!isRecord(root.features)) {
|
|
330
331
|
return failure("INVALID_TYPE", "$.features", "features must be an object.");
|
|
331
332
|
}
|
|
332
|
-
const featuresUnknown = unknownFieldFailure(root.features, ["completionGate", "stopVerification"], "$.features");
|
|
333
|
+
const featuresUnknown = unknownFieldFailure(root.features, ["completionGate", "stopVerification", "office"], "$.features");
|
|
333
334
|
if (featuresUnknown !== undefined) {
|
|
334
335
|
return featuresUnknown;
|
|
335
336
|
}
|
|
337
|
+
if (root.features.office !== undefined) {
|
|
338
|
+
if (!isRecord(root.features.office)) {
|
|
339
|
+
return failure("INVALID_TYPE", "$.features.office", "office must be an object.");
|
|
340
|
+
}
|
|
341
|
+
const unknown = unknownFieldFailure(root.features.office, ["enabled"], "$.features.office");
|
|
342
|
+
if (unknown !== undefined)
|
|
343
|
+
return unknown;
|
|
344
|
+
if (typeof root.features.office.enabled !== "boolean") {
|
|
345
|
+
return failure("INVALID_FEATURE", "$.features.office.enabled", "office.enabled must be a boolean.");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
336
348
|
if (!isRecord(root.features.stopVerification)) {
|
|
337
349
|
return failure("INVALID_TYPE", "$.features.stopVerification", "stopVerification must be an object.");
|
|
338
350
|
}
|
|
@@ -356,7 +368,7 @@ export function validateConfig(value) {
|
|
|
356
368
|
if (!isRecord(root.verification)) {
|
|
357
369
|
return failure("INVALID_TYPE", "$.verification", "verification must be an object.");
|
|
358
370
|
}
|
|
359
|
-
const verificationUnknown = unknownFieldFailure(root.verification, ["commands"], "$.verification");
|
|
371
|
+
const verificationUnknown = unknownFieldFailure(root.verification, ["commands", "acceptanceRunners"], "$.verification");
|
|
360
372
|
if (verificationUnknown !== undefined) {
|
|
361
373
|
return verificationUnknown;
|
|
362
374
|
}
|
|
@@ -374,6 +386,20 @@ export function validateConfig(value) {
|
|
|
374
386
|
}
|
|
375
387
|
commandIds.add(command.value.id);
|
|
376
388
|
}
|
|
389
|
+
if (root.verification.acceptanceRunners !== undefined) {
|
|
390
|
+
if (!Array.isArray(root.verification.acceptanceRunners) || root.verification.acceptanceRunners.length > 128)
|
|
391
|
+
return failure("INVALID_ACCEPTANCE_RUNNER", "$.verification.acceptanceRunners", "Expected bounded runner array.");
|
|
392
|
+
const runnerIds = new Set();
|
|
393
|
+
for (const runner of root.verification.acceptanceRunners) {
|
|
394
|
+
const issue = runnerError(runner);
|
|
395
|
+
if (issue !== undefined)
|
|
396
|
+
return failure("INVALID_ACCEPTANCE_RUNNER", "$.verification.acceptanceRunners", issue);
|
|
397
|
+
const runnerId = runner.id;
|
|
398
|
+
if (runnerIds.has(runnerId) || commandIds.has(runnerId))
|
|
399
|
+
return failure("DUPLICATE_ID", "$.verification.acceptanceRunners", "Runner IDs must be unique and distinct from policy commands.");
|
|
400
|
+
runnerIds.add(runnerId);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
377
403
|
if (root.features.stopVerification.enabled === true &&
|
|
378
404
|
root.verification.commands.length === 0) {
|
|
379
405
|
return failure("STOP_VERIFICATION_COMMANDS_REQUIRED", "$.features.stopVerification.enabled", "Stop verification requires at least one verification command.");
|
|
@@ -498,11 +524,11 @@ function validateReviewRole(value, path) {
|
|
|
498
524
|
}
|
|
499
525
|
return success(value);
|
|
500
526
|
}
|
|
501
|
-
function validateCriterion(value, path) {
|
|
527
|
+
export function validateCriterion(value, path) {
|
|
502
528
|
if (!isRecord(value)) {
|
|
503
529
|
return failure("INVALID_TYPE", path, "Expected a criterion object.");
|
|
504
530
|
}
|
|
505
|
-
const unknown = unknownFieldFailure(value, ["description", "id", "verifierIds"], path);
|
|
531
|
+
const unknown = unknownFieldFailure(value, ["description", "id", "verifierIds", "acceptance", "finding"], path);
|
|
506
532
|
if (unknown !== undefined) {
|
|
507
533
|
return unknown;
|
|
508
534
|
}
|
|
@@ -513,20 +539,43 @@ function validateCriterion(value, path) {
|
|
|
513
539
|
return failure("INVALID_DESCRIPTION", `${path}.description`, "Criterion description is required.");
|
|
514
540
|
}
|
|
515
541
|
if (!isStringArray(value.verifierIds) ||
|
|
516
|
-
value.verifierIds.length === 0 ||
|
|
542
|
+
(value.verifierIds.length === 0 && value.acceptance === undefined) ||
|
|
517
543
|
!value.verifierIds.every(isIdentifier)) {
|
|
518
544
|
return failure("INVALID_VERIFIER_REFERENCE", `${path}.verifierIds`, "A criterion requires at least one valid verifier ID.");
|
|
519
545
|
}
|
|
520
546
|
if (!hasUniqueStrings(value.verifierIds)) {
|
|
521
547
|
return failure("DUPLICATE_ID", `${path}.verifierIds`, "Criterion verifier IDs must be unique.");
|
|
522
548
|
}
|
|
549
|
+
if (value.acceptance !== undefined) {
|
|
550
|
+
const issue = acceptanceError(value.acceptance);
|
|
551
|
+
if (issue !== undefined)
|
|
552
|
+
return failure("INVALID_ACCEPTANCE", path + ".acceptance", issue);
|
|
553
|
+
}
|
|
554
|
+
if (value.finding !== undefined) {
|
|
555
|
+
const f = value.finding;
|
|
556
|
+
if (!isRecord(f) || unknownFieldFailure(f, ["pinId", "reportDigest", "findingIndex", "candidateCommit", "criterionIds"], path + ".finding") !== undefined ||
|
|
557
|
+
typeof f.pinId !== "string" || !HASH_PATTERN.test(f.pinId) || typeof f.reportDigest !== "string" || !HASH_PATTERN.test(f.reportDigest) ||
|
|
558
|
+
!Number.isSafeInteger(f.findingIndex) || f.findingIndex < 0 || !commitIdentity(f.candidateCommit) ||
|
|
559
|
+
!isStringArray(f.criterionIds) || !f.criterionIds.every(id => typeof id === "string" && id.split(":").length <= 2 && id.split(":").every(isIdentifier)) || !hasUniqueStrings(f.criterionIds))
|
|
560
|
+
return failure("INVALID_FINDING_REFERENCE", path + ".finding", "Invalid immutable finding identity.");
|
|
561
|
+
if (value.acceptance === undefined || !isRecord(value.acceptance) || value.acceptance.mode === "invariant" ||
|
|
562
|
+
value.acceptance.baselineCommit !== f.candidateCommit)
|
|
563
|
+
return failure("INVALID_FINDING_BASELINE", path + ".finding", "A finding retains its failed-candidate baseline and behavioral or explicit review-only mode.");
|
|
564
|
+
}
|
|
523
565
|
return success(value);
|
|
524
566
|
}
|
|
525
567
|
export function validateTask(value) {
|
|
526
|
-
const root = validateRoot(value, ["criteria", "id", "intent", "parentTaskId", "schemaVersion", "title"], TASK_SCHEMA_VERSION);
|
|
568
|
+
const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
|
|
527
569
|
if (isFailure(root)) {
|
|
528
570
|
return root;
|
|
529
571
|
}
|
|
572
|
+
if (root.contractRevision !== undefined && (!Number.isSafeInteger(root.contractRevision) || root.contractRevision < 0))
|
|
573
|
+
return failure("INVALID_CONTRACT_REVISION", "$.contractRevision", "Invalid contract revision.");
|
|
574
|
+
if (root.goal !== undefined && (!isNonEmptyString(root.goal) || Buffer.byteLength(root.goal, "utf8") > 65536))
|
|
575
|
+
return failure("INVALID_GOAL", "$.goal", "Goal must be non-empty and at most 64 KiB.");
|
|
576
|
+
if (root.schemaVersion === 1 && (root.goal !== undefined || root.contractRevision !== undefined ||
|
|
577
|
+
(Array.isArray(root.criteria) && root.criteria.some(c => isRecord(c) && (c.acceptance !== undefined || c.finding !== undefined)))))
|
|
578
|
+
return failure("WRONG_SCHEMA_VERSION", "$.schemaVersion", "Typed acceptance requires task schema 2.");
|
|
530
579
|
if (!isIdentifier(root.id)) {
|
|
531
580
|
return failure("INVALID_ID", "$.id", "Invalid task ID.");
|
|
532
581
|
}
|
|
@@ -578,6 +627,13 @@ export function validateTaskAgainstConfig(taskValue, configValue) {
|
|
|
578
627
|
return failure("UNKNOWN_VERIFIER_REFERENCE", `$.criteria[${criterionIndex}].verifierIds`, `Unknown verifier reference: ${unknownReference}`);
|
|
579
628
|
}
|
|
580
629
|
}
|
|
630
|
+
const runnerIds = new Set(config.value.verification.acceptanceRunners?.map(r => r.id) ?? []);
|
|
631
|
+
for (const criterion of task.value.criteria) {
|
|
632
|
+
for (const binding of criterion.acceptance?.bindings ?? []) {
|
|
633
|
+
if (!runnerIds.has(binding.runnerId))
|
|
634
|
+
return failure("UNKNOWN_ACCEPTANCE_RUNNER", "$.criteria", "Unknown acceptance runner: " + binding.runnerId);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
581
637
|
return task;
|
|
582
638
|
}
|
|
583
639
|
export function validateEvidence(value) {
|
|
@@ -597,11 +653,26 @@ export function validateEvidence(value) {
|
|
|
597
653
|
"status",
|
|
598
654
|
"taskId",
|
|
599
655
|
"testCount",
|
|
600
|
-
"toolVersions"
|
|
601
|
-
], isRecord(value) && value.schemaVersion === 2 ?
|
|
656
|
+
"toolVersions", "taskContractHash", "acceptance"
|
|
657
|
+
], isRecord(value) && (value.schemaVersion === 2 || value.schemaVersion === 3) ? value.schemaVersion : EVIDENCE_SCHEMA_VERSION);
|
|
602
658
|
if (isFailure(root)) {
|
|
603
659
|
return root;
|
|
604
660
|
}
|
|
661
|
+
if (root.taskContractHash !== undefined && (root.schemaVersion !== 4 || typeof root.taskContractHash !== "string" || !HASH_PATTERN.test(root.taskContractHash)))
|
|
662
|
+
return failure("INVALID_CONTRACT_HASH", "$.taskContractHash", "Contract evidence requires schema 4 and a digest.");
|
|
663
|
+
if (root.acceptance !== undefined) {
|
|
664
|
+
const a = root.acceptance;
|
|
665
|
+
if (root.schemaVersion !== 4 || !isRecord(a) || root.taskContractHash === undefined ||
|
|
666
|
+
unknownFieldFailure(a, ["criterionContractHash", "phase", "commit", "pairedCommit", "bindingHash", "materialDigest", "executionDigest", "executionArtifact", "checks"], "$.acceptance") !== undefined ||
|
|
667
|
+
!["baseline", "candidate"].includes(String(a.phase)) || !commitIdentity(a.commit) || !commitIdentity(a.pairedCommit) ||
|
|
668
|
+
![a.criterionContractHash, a.bindingHash, a.materialDigest, a.executionDigest].every(h => typeof h === "string" && HASH_PATTERN.test(h)) ||
|
|
669
|
+
typeof a.executionArtifact !== "string" || !/^\.agent-ops\/tasks\/acceptance\/[a-f0-9]{64}\.json$/u.test(a.executionArtifact) ||
|
|
670
|
+
!Array.isArray(a.checks) || a.checks.length === 0 || a.checks.length > 512 ||
|
|
671
|
+
a.checks.some(c => !isRecord(c) || unknownFieldFailure(c, ["checkId", "status", "failureClass"], "$.acceptance.checks") !== undefined ||
|
|
672
|
+
!isNonEmptyString(c.checkId) || !["PASS", "FAIL", "UNKNOWN"].includes(String(c.status)) || !isNonEmptyString(c.failureClass)) ||
|
|
673
|
+
new Set(a.checks.map(c => c.checkId)).size !== a.checks.length)
|
|
674
|
+
return failure("INVALID_ACCEPTANCE_EVIDENCE", "$.acceptance", "Invalid phase/check evidence.");
|
|
675
|
+
}
|
|
605
676
|
for (const field of ["taskId", "criterionId", "commandId"]) {
|
|
606
677
|
if (!isIdentifier(root[field])) {
|
|
607
678
|
return failure("INVALID_ID", `$.${field}`, `Invalid ${field}.`);
|