@kylecheng3146/agent-ops 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/packages/cli/src/args.js +165 -2
- package/dist/packages/cli/src/bin.js +64 -17
- package/dist/packages/cli/src/cli.js +33 -6
- package/dist/packages/cli/src/commands/advance.js +23 -3
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +100 -9
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +61 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +288 -10
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +71 -5
- package/dist/runtime/src/parallel/service.js +41 -1
- package/dist/runtime/src/review/attestation.js +36 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +67 -7
- package/dist/runtime/src/task/completion.js +14 -2
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/service.js +316 -13
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/spec/acceptance-and-evidence.md +48 -0
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +265 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { AgentOpsError } from "../../fs/paths.js";
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function parseLine(line, source) {
|
|
7
|
+
let parsed;
|
|
8
|
+
try {
|
|
9
|
+
parsed = JSON.parse(line);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
throw new AgentOpsError("NATIVE_PROTOCOL_INVALID", `Invalid JSON from ${source}.`, { cause: error });
|
|
13
|
+
}
|
|
14
|
+
if (!isRecord(parsed)) {
|
|
15
|
+
throw new AgentOpsError("NATIVE_PROTOCOL_INVALID", `Native ${source} message is not an object.`);
|
|
16
|
+
}
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
/** JSONL request/notification channel used by the Codex app server. */
|
|
20
|
+
export class JsonRpcSession {
|
|
21
|
+
child;
|
|
22
|
+
requestTimeoutMs;
|
|
23
|
+
lines;
|
|
24
|
+
pending = new Map();
|
|
25
|
+
events = [];
|
|
26
|
+
waiters = [];
|
|
27
|
+
nextId = 1;
|
|
28
|
+
closed = null;
|
|
29
|
+
constructor(child, requestTimeoutMs = 30_000) {
|
|
30
|
+
this.child = child;
|
|
31
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
32
|
+
this.lines = createInterface({ input: child.stdout });
|
|
33
|
+
this.lines.on("line", (line) => this.receive(line, "stdout"));
|
|
34
|
+
child.once("error", (error) => this.close(error));
|
|
35
|
+
child.once("exit", (code, signal) => {
|
|
36
|
+
this.close(new AgentOpsError("NATIVE_PROCESS_CLOSED", `Native process closed (code=${String(code)}, signal=${String(signal)}).`));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async request(method, params) {
|
|
40
|
+
if (this.closed !== null) {
|
|
41
|
+
throw this.closed;
|
|
42
|
+
}
|
|
43
|
+
const id = this.nextId++;
|
|
44
|
+
const request = { jsonrpc: "2.0", id, method, params };
|
|
45
|
+
const response = new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
this.pending.delete(id);
|
|
48
|
+
reject(new AgentOpsError("NATIVE_REQUEST_TIMEOUT", `Native request timed out: ${method}`));
|
|
49
|
+
}, this.requestTimeoutMs);
|
|
50
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
51
|
+
});
|
|
52
|
+
this.write(request);
|
|
53
|
+
return response;
|
|
54
|
+
}
|
|
55
|
+
notify(method, params = {}) {
|
|
56
|
+
if (this.closed !== null) {
|
|
57
|
+
throw this.closed;
|
|
58
|
+
}
|
|
59
|
+
this.write({ jsonrpc: "2.0", method, params });
|
|
60
|
+
}
|
|
61
|
+
async nextEvent(signal) {
|
|
62
|
+
if (this.events.length > 0) {
|
|
63
|
+
return this.events.shift() ?? null;
|
|
64
|
+
}
|
|
65
|
+
if (this.closed !== null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const waiter = { resolve, reject, signal };
|
|
70
|
+
if (signal !== undefined) {
|
|
71
|
+
if (signal.aborted) {
|
|
72
|
+
resolve(null);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const onAbort = () => {
|
|
76
|
+
const index = this.waiters.indexOf(waiter);
|
|
77
|
+
if (index >= 0) {
|
|
78
|
+
this.waiters.splice(index, 1);
|
|
79
|
+
}
|
|
80
|
+
resolve(null);
|
|
81
|
+
};
|
|
82
|
+
waiter.onAbort = onAbort;
|
|
83
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
84
|
+
}
|
|
85
|
+
this.waiters.push(waiter);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
close(error = null) {
|
|
89
|
+
if (this.closed !== null) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.closed = error ?? new AgentOpsError("NATIVE_PROCESS_CLOSED", "Native process closed.");
|
|
93
|
+
this.lines.close();
|
|
94
|
+
for (const pending of this.pending.values()) {
|
|
95
|
+
clearTimeout(pending.timer);
|
|
96
|
+
pending.reject(this.closed);
|
|
97
|
+
}
|
|
98
|
+
this.pending.clear();
|
|
99
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
100
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
|
|
101
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
102
|
+
}
|
|
103
|
+
waiter.resolve(null);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
receive(line, source) {
|
|
107
|
+
if (line.trim().length === 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let message;
|
|
111
|
+
try {
|
|
112
|
+
message = parseLine(line, source);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
this.close(error);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const id = message.id;
|
|
119
|
+
if (typeof id === "number" && Number.isSafeInteger(id)) {
|
|
120
|
+
const pending = this.pending.get(id);
|
|
121
|
+
if (pending === undefined) {
|
|
122
|
+
this.enqueue(message);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.pending.delete(id);
|
|
126
|
+
clearTimeout(pending.timer);
|
|
127
|
+
if (isRecord(message.error)) {
|
|
128
|
+
pending.reject(new AgentOpsError("NATIVE_REQUEST_FAILED", String(message.error.message ?? "Native request failed"), { cause: message.error }));
|
|
129
|
+
}
|
|
130
|
+
else if (isRecord(message.result)) {
|
|
131
|
+
pending.resolve(message.result);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
pending.resolve(message);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this.enqueue(message);
|
|
139
|
+
}
|
|
140
|
+
write(message) {
|
|
141
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
142
|
+
}
|
|
143
|
+
enqueue(message) {
|
|
144
|
+
const waiter = this.waiters.shift();
|
|
145
|
+
if (waiter !== undefined) {
|
|
146
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
|
|
147
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
148
|
+
}
|
|
149
|
+
waiter.resolve(message);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
this.events.push(message);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Newline-delimited stream used by Claude Code's stream-json mode. */
|
|
157
|
+
export class JsonLineStream {
|
|
158
|
+
child;
|
|
159
|
+
lines;
|
|
160
|
+
events = [];
|
|
161
|
+
waiters = [];
|
|
162
|
+
closed = false;
|
|
163
|
+
constructor(child) {
|
|
164
|
+
this.child = child;
|
|
165
|
+
this.lines = createInterface({ input: child.stdout });
|
|
166
|
+
this.lines.on("line", (line) => {
|
|
167
|
+
if (line.trim().length === 0) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
this.enqueue(parseLine(line, "stdout"));
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
this.close(error);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
child.once("error", (error) => this.close(error));
|
|
178
|
+
child.once("exit", () => this.close());
|
|
179
|
+
}
|
|
180
|
+
async next(signal) {
|
|
181
|
+
if (this.events.length > 0) {
|
|
182
|
+
return this.events.shift() ?? null;
|
|
183
|
+
}
|
|
184
|
+
if (this.closed) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const waiter = { resolve, reject, signal };
|
|
189
|
+
if (signal !== undefined) {
|
|
190
|
+
if (signal.aborted) {
|
|
191
|
+
resolve(null);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const onAbort = () => {
|
|
195
|
+
const index = this.waiters.indexOf(waiter);
|
|
196
|
+
if (index >= 0) {
|
|
197
|
+
this.waiters.splice(index, 1);
|
|
198
|
+
}
|
|
199
|
+
resolve(null);
|
|
200
|
+
};
|
|
201
|
+
waiter.onAbort = onAbort;
|
|
202
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
203
|
+
}
|
|
204
|
+
this.waiters.push(waiter);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
requeue(message) {
|
|
208
|
+
this.events.unshift(message);
|
|
209
|
+
}
|
|
210
|
+
close(_error) {
|
|
211
|
+
if (this.closed) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.closed = true;
|
|
215
|
+
this.lines.close();
|
|
216
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
217
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
|
|
218
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
219
|
+
}
|
|
220
|
+
waiter.resolve(null);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
enqueue(message) {
|
|
224
|
+
const waiter = this.waiters.shift();
|
|
225
|
+
if (waiter !== undefined) {
|
|
226
|
+
if (waiter.signal !== undefined && waiter.onAbort !== undefined) {
|
|
227
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
228
|
+
}
|
|
229
|
+
waiter.resolve(message);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
this.events.push(message);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export function asNativePayload(value) {
|
|
237
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
if (typeof value === "number") {
|
|
241
|
+
return Number.isFinite(value) ? value : null;
|
|
242
|
+
}
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
return value.map((item) => asNativePayload(item));
|
|
245
|
+
}
|
|
246
|
+
if (typeof value === "object") {
|
|
247
|
+
const object = {};
|
|
248
|
+
for (const [key, item] of Object.entries(value)) {
|
|
249
|
+
object[key] = asNativePayload(item);
|
|
250
|
+
}
|
|
251
|
+
return object;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
3
|
+
import { AgentOpsError } from "../../fs/paths.js";
|
|
4
|
+
import { asNativePayload } from "./protocol.js";
|
|
5
|
+
export function spawnNative(command, args, cwd, env, options) {
|
|
6
|
+
const spawn = options.spawn ?? ((name, argv, spawnOptions) => nodeSpawn(name, [...argv], spawnOptions));
|
|
7
|
+
const child = spawn(command, args, {
|
|
8
|
+
cwd,
|
|
9
|
+
env: { ...process.env, ...options.env, ...env },
|
|
10
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
11
|
+
detached: process.platform !== "win32"
|
|
12
|
+
});
|
|
13
|
+
// Native clients can emit diagnostics independently of their structured
|
|
14
|
+
// stdout protocol. Drain stderr so a long-lived writer cannot deadlock on a
|
|
15
|
+
// full pipe; structured lifecycle events remain the source of truth.
|
|
16
|
+
child.stderr.resume();
|
|
17
|
+
return child;
|
|
18
|
+
}
|
|
19
|
+
export function assertContext(handle, context) {
|
|
20
|
+
if (handle.runId !== context.runId ||
|
|
21
|
+
handle.workerId !== context.workerId ||
|
|
22
|
+
handle.generation !== context.generation ||
|
|
23
|
+
handle.contractHash !== context.contractHash) {
|
|
24
|
+
throw new AgentOpsError("NATIVE_STALE_WRITER", "The native action belongs to a stale run worker generation.");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function assertProcessRunning(child) {
|
|
28
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
29
|
+
throw new AgentOpsError("NATIVE_PROCESS_CLOSED", "The native worker process is already closed.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function actionResult(accepted, nativeStatus, payload) {
|
|
33
|
+
return {
|
|
34
|
+
actionId: randomUUID(),
|
|
35
|
+
accepted,
|
|
36
|
+
nativeStatus,
|
|
37
|
+
payload: asNativePayload(payload)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function event(handle, type, nativeStatus, payload, now) {
|
|
41
|
+
return {
|
|
42
|
+
type,
|
|
43
|
+
host: handle.kind,
|
|
44
|
+
runId: handle.runId,
|
|
45
|
+
workerId: handle.workerId,
|
|
46
|
+
generation: handle.generation,
|
|
47
|
+
contractHash: handle.contractHash,
|
|
48
|
+
at: now().toISOString(),
|
|
49
|
+
nativeStatus,
|
|
50
|
+
payload: asNativePayload(payload),
|
|
51
|
+
proof: false
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function waitForExit(child, timeoutMs = 10_000) {
|
|
55
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await new Promise((resolve) => {
|
|
59
|
+
let settled = false;
|
|
60
|
+
const settle = () => {
|
|
61
|
+
if (settled)
|
|
62
|
+
return;
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
const timer = setTimeout(settle, timeoutMs);
|
|
68
|
+
child.once("exit", settle);
|
|
69
|
+
child.once("error", settle);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function commandParts(options, fallbackCommand, fallbackArgs) {
|
|
73
|
+
return {
|
|
74
|
+
command: options.command ?? fallbackCommand,
|
|
75
|
+
args: options.args ?? fallbackArgs
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Native writers own a process group, including their managed command children. */
|
|
79
|
+
export function nativeProcessGroupAlive(pid) {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(-pid, 0);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
if (cause.code === "ESRCH")
|
|
86
|
+
return false;
|
|
87
|
+
// Permission errors cannot establish death.
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export async function signalNativeProcessGroup(pid, signal) {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(process.platform === "win32" ? pid : -pid, signal);
|
|
94
|
+
}
|
|
95
|
+
catch (cause) {
|
|
96
|
+
const code = cause.code;
|
|
97
|
+
if (code === "ESRCH")
|
|
98
|
+
return;
|
|
99
|
+
if (code === "EPERM" && process.platform !== "win32") {
|
|
100
|
+
// macOS can report EPERM while an already-killed group is being reaped.
|
|
101
|
+
// Permission failure is safe to dismiss only after the kernel confirms absence.
|
|
102
|
+
for (let i = 0; i < 30 && nativeProcessGroupAlive(pid); i++)
|
|
103
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
104
|
+
if (!nativeProcessGroupAlive(pid))
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
throw cause;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export async function stopNativeProcess(child, signal) {
|
|
111
|
+
const pid = child.pid;
|
|
112
|
+
if (pid === undefined)
|
|
113
|
+
throw new AgentOpsError("NATIVE_PROCESS_UNKNOWN", "Native process has no registered PID.");
|
|
114
|
+
const send = async (s) => {
|
|
115
|
+
if (process.platform !== "win32")
|
|
116
|
+
return await signalNativeProcessGroup(pid, s);
|
|
117
|
+
try {
|
|
118
|
+
child.kill(s);
|
|
119
|
+
}
|
|
120
|
+
catch (cause) {
|
|
121
|
+
if (cause.code !== "ESRCH")
|
|
122
|
+
throw cause;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
await send(signal);
|
|
126
|
+
await waitForExit(child, 3000);
|
|
127
|
+
if (process.platform !== "win32" && nativeProcessGroupAlive(pid)) {
|
|
128
|
+
await send("SIGKILL");
|
|
129
|
+
for (let i = 0; i < 30 && nativeProcessGroupAlive(pid); i++)
|
|
130
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
131
|
+
if (nativeProcessGroupAlive(pid))
|
|
132
|
+
throw new AgentOpsError("NATIVE_PROCESS_STILL_ALIVE", "Managed native descendants have not stopped; takeover is refused.");
|
|
133
|
+
}
|
|
134
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
135
|
+
throw new AgentOpsError("NATIVE_PROCESS_STILL_ALIVE", "Native stop did not confirm process death.");
|
|
136
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { canonicalJson } from "../config/hash.js";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
7
|
+
import { FileRunRepository } from "./service.js";
|
|
8
|
+
function ledger(commonDir) { return new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir); }
|
|
9
|
+
function proofPath(commonDir, runId) { return join(commonDir, "agent-ops", "runs", runId, "integration-proof.json"); }
|
|
10
|
+
function integrationIdentity(integration) {
|
|
11
|
+
return {
|
|
12
|
+
transactionId: integration.transactionId,
|
|
13
|
+
expectedTarget: integration.expectedTarget,
|
|
14
|
+
candidate: integration.candidate
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** The binding intentionally excludes receiptDigest to avoid a receipt/state cycle. */
|
|
18
|
+
export function integrationReceiptBindingDigest(integration) {
|
|
19
|
+
return sha256(canonicalJson(integrationIdentity(integration)));
|
|
20
|
+
}
|
|
21
|
+
export function integrationReceiptBinding(integration) {
|
|
22
|
+
return { ...integrationIdentity(integration), digest: integrationReceiptBindingDigest(integration) };
|
|
23
|
+
}
|
|
24
|
+
const plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
/** Validate the immutable run transaction fields embedded in a final receipt. */
|
|
26
|
+
export function validateRunIntegrationReceipt(integration, receipt) {
|
|
27
|
+
const binding = receipt.integrationJournal;
|
|
28
|
+
if (receipt.candidateHead !== integration.candidate || receipt.targetCommit !== integration.expectedTarget ||
|
|
29
|
+
!plain(binding) || binding.transactionId !== integration.transactionId ||
|
|
30
|
+
binding.expectedTarget !== integration.expectedTarget || binding.candidate !== integration.candidate ||
|
|
31
|
+
binding.digest !== integrationReceiptBindingDigest(integration)) {
|
|
32
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECEIPT_INVALID", "The final receipt is not bound to the sealed integration transaction.");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function readSealedProof(commonDir, runId, integration) {
|
|
36
|
+
const source = await readPrivateFile(proofPath(commonDir, runId), commonDir);
|
|
37
|
+
if (source === null || integration.proofDigest === undefined || sha256(source) !== integration.proofDigest) {
|
|
38
|
+
throw new AgentOpsError("RUN_INTEGRATION_PROOF_CHANGED", "The sealed integration proof is missing or changed.");
|
|
39
|
+
}
|
|
40
|
+
let value;
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(source);
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
throw new AgentOpsError("RUN_INTEGRATION_PROOF_CHANGED", "The sealed integration proof is not valid JSON.", { cause });
|
|
46
|
+
}
|
|
47
|
+
if (!plain(value) || value.target !== integration.expectedTarget || value.head !== integration.candidate) {
|
|
48
|
+
throw new AgentOpsError("RUN_INTEGRATION_PROOF_CHANGED", "Integration proof no longer matches the frozen target and candidate.");
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
/** Seal the exact final gate before any target mutation. Recovery cannot invent a new proof. */
|
|
53
|
+
export async function prepareRunIntegration(commonDir, record, proof) {
|
|
54
|
+
if (record.runId === undefined)
|
|
55
|
+
return;
|
|
56
|
+
const repository = ledger(commonDir);
|
|
57
|
+
const state = await repository.read(record.runId);
|
|
58
|
+
if (state === null || state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId ||
|
|
59
|
+
state.status !== "active" || state.disableRestart || state.workers.some(worker => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)))
|
|
60
|
+
throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "Final integration requires the stopped registered coordinator and current active run.");
|
|
61
|
+
if (state.integration !== null) {
|
|
62
|
+
if (state.integration.status !== "prepared")
|
|
63
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Reconcile the existing integration before preparing another candidate.");
|
|
64
|
+
const sealed = await readSealedProof(commonDir, record.runId, state.integration);
|
|
65
|
+
if (canonicalJson(sealed) !== canonicalJson(proof))
|
|
66
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The existing sealed integration cannot be replaced by a new proof.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const source = JSON.stringify(proof);
|
|
70
|
+
await repository.mutate(record.runId, async (current) => {
|
|
71
|
+
if (current.status !== "active" || current.disableRestart || current.revision !== state.revision)
|
|
72
|
+
throw new AgentOpsError("RUN_INTEGRATION_STALE", "Run changed while sealing final proof.");
|
|
73
|
+
await writePrivateFile(proofPath(commonDir, record.runId), source, commonDir);
|
|
74
|
+
return { ...current, integration: { transactionId: randomUUID(), status: "prepared", expectedTarget: proof.target,
|
|
75
|
+
candidate: proof.head, proofDigest: sha256(source), completedSteps: [], receiptPath: null, receiptDigest: null,
|
|
76
|
+
updatedAt: new Date().toISOString(), childDeliveries: proof.children.map(child => child.commit) } };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export async function readRunIntegrationProof(commonDir, record) {
|
|
80
|
+
if (record.runId === undefined)
|
|
81
|
+
return null;
|
|
82
|
+
const state = await ledger(commonDir).read(record.runId);
|
|
83
|
+
if (state?.integration == null)
|
|
84
|
+
return null;
|
|
85
|
+
if (state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId)
|
|
86
|
+
throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "Recovery coordinator does not match the sealed transaction.");
|
|
87
|
+
return await readSealedProof(commonDir, record.runId, state.integration);
|
|
88
|
+
}
|
|
89
|
+
/** A failed pre-merge CAS may be retried only after archiving the unused sealed transaction. */
|
|
90
|
+
export async function abandonPreparedRunIntegration(commonDir, record, git) {
|
|
91
|
+
if (record.runId === undefined)
|
|
92
|
+
return;
|
|
93
|
+
const repository = ledger(commonDir);
|
|
94
|
+
const state = await repository.read(record.runId);
|
|
95
|
+
const transaction = state?.integration;
|
|
96
|
+
if (state === null || transaction == null)
|
|
97
|
+
return;
|
|
98
|
+
if (transaction.status !== "prepared" || state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId ||
|
|
99
|
+
state.workers.some(worker => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)))
|
|
100
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Only a stopped, unused prepared transaction may be superseded.");
|
|
101
|
+
const target = await git(state.root, ["rev-parse", state.targetBranch + "^{commit}"]);
|
|
102
|
+
const commit = target.stdout.trim();
|
|
103
|
+
const ancestry = await git(state.root, ["merge-base", "--is-ancestor", transaction.candidate, commit]);
|
|
104
|
+
if (target.exitCode !== 0 || commit === transaction.expectedTarget || commit === transaction.candidate || ancestry.exitCode !== 1)
|
|
105
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The old candidate may have reached the target; preserve and recover its sealed transaction.");
|
|
106
|
+
const proof = await readSealedProof(commonDir, record.runId, transaction);
|
|
107
|
+
await repository.mutate(record.runId, async (current) => {
|
|
108
|
+
const actual = await git(state.root, ["rev-parse", state.targetBranch + "^{commit}"]);
|
|
109
|
+
if (current.revision !== state.revision || actual.exitCode !== 0 || actual.stdout.trim() !== commit)
|
|
110
|
+
throw new AgentOpsError("RUN_INTEGRATION_STALE", "Run or target changed while superseding an unused transaction.");
|
|
111
|
+
const path = join(commonDir, "agent-ops", "runs", record.runId, "integration-history", transaction.transactionId + ".json");
|
|
112
|
+
await writePrivateFile(path, JSON.stringify({ transaction, proof, observedTarget: commit }), commonDir);
|
|
113
|
+
return { ...current, integration: null };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Read the binding used by a finish receipt without trusting caller-supplied transaction fields. */
|
|
117
|
+
export async function readRunIntegrationReceiptBinding(commonDir, record, expected) {
|
|
118
|
+
if (record.runId === undefined)
|
|
119
|
+
return null;
|
|
120
|
+
const state = await ledger(commonDir).read(record.runId);
|
|
121
|
+
if (state === null || state.integration === null)
|
|
122
|
+
throw new AgentOpsError("RUN_INTEGRATION_REQUIRED", "A run-owned final receipt requires a sealed integration transaction.");
|
|
123
|
+
if (state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId)
|
|
124
|
+
throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "The integration transaction belongs to another coordinator.");
|
|
125
|
+
await readSealedProof(commonDir, record.runId, state.integration);
|
|
126
|
+
if (state.integration.expectedTarget !== expected.target || state.integration.candidate !== expected.candidate)
|
|
127
|
+
throw new AgentOpsError("RUN_INTEGRATION_PROOF_CHANGED", "The receipt candidate does not match the sealed integration transaction.");
|
|
128
|
+
return integrationReceiptBinding(state.integration);
|
|
129
|
+
}
|
|
130
|
+
export async function markRunIntegration(commonDir, record, status, step, receipt) {
|
|
131
|
+
if (record.runId === undefined)
|
|
132
|
+
return;
|
|
133
|
+
await ledger(commonDir).mutate(record.runId, current => {
|
|
134
|
+
if (current.integration === null || current.ownerSessionId !== record.sessionId || current.coordinatorId !== record.workerId)
|
|
135
|
+
throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "The integration transaction is missing or belongs to another coordinator.");
|
|
136
|
+
return { ...current, integration: { ...current.integration, status, updatedAt: new Date().toISOString(),
|
|
137
|
+
completedSteps: [...new Set([...current.integration.completedSteps, step])],
|
|
138
|
+
...(receipt === undefined ? {} : { receiptPath: receipt.path, receiptDigest: receipt.digest }) } };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Finish bookkeeping after a coordinator checkout vanished. This only verifies
|
|
143
|
+
* sealed artifacts and advances the ledger; it never recreates a checkout or
|
|
144
|
+
* starts a native writer.
|
|
145
|
+
*/
|
|
146
|
+
export async function recoverRunIntegrationAfterCleanup(commonDir, runId, git) {
|
|
147
|
+
const repository = ledger(commonDir);
|
|
148
|
+
const state = await repository.read(runId);
|
|
149
|
+
if (state === null || state.integration === null)
|
|
150
|
+
throw new AgentOpsError("RUN_INTEGRATION_REQUIRED", "There is no sealed integration to recover.");
|
|
151
|
+
const integration = state.integration;
|
|
152
|
+
if (state.workers.some(worker => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)))
|
|
153
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Integration cleanup cannot be recovered while a writer is active.");
|
|
154
|
+
await readSealedProof(commonDir, runId, integration);
|
|
155
|
+
if (integration.status !== "cleaned" &&
|
|
156
|
+
(integration.status !== "receipt-written" || !integration.completedSteps.includes("receipt") ||
|
|
157
|
+
!integration.completedSteps.includes("note"))) {
|
|
158
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Recovery requires a recorded receipt and Git note before cleanup can be completed.");
|
|
159
|
+
}
|
|
160
|
+
if (integration.receiptPath === null || integration.receiptDigest === null)
|
|
161
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The integration has no sealed receipt.");
|
|
162
|
+
const source = await readPrivateFile(integration.receiptPath, commonDir);
|
|
163
|
+
if (source === null || sha256(source) !== integration.receiptDigest)
|
|
164
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECEIPT_INVALID", "The recorded integration receipt is missing or changed.");
|
|
165
|
+
let receipt;
|
|
166
|
+
try {
|
|
167
|
+
const value = JSON.parse(source);
|
|
168
|
+
if (!plain(value))
|
|
169
|
+
throw new Error("receipt is not an object");
|
|
170
|
+
receipt = value;
|
|
171
|
+
}
|
|
172
|
+
catch (cause) {
|
|
173
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECEIPT_INVALID", "The recorded integration receipt is not valid JSON.", { cause });
|
|
174
|
+
}
|
|
175
|
+
if (receipt.sessionId !== state.ownerSessionId)
|
|
176
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECEIPT_INVALID", "The receipt belongs to another run owner.");
|
|
177
|
+
validateRunIntegrationReceipt(integration, receipt);
|
|
178
|
+
const note = await git(state.root, ["notes", "--ref=agent-ops", "show", integration.candidate]);
|
|
179
|
+
const noteText = note.stdout;
|
|
180
|
+
if (note.exitCode !== 0 || !noteText.includes(`session: ${state.ownerSessionId}`) ||
|
|
181
|
+
!noteText.includes(`receipt: ${integration.receiptPath}`) ||
|
|
182
|
+
!noteText.includes(`receipt-sha256: ${integration.receiptDigest}`)) {
|
|
183
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The sealed receipt is not bound by the Git note for the candidate.");
|
|
184
|
+
}
|
|
185
|
+
const target = await git(state.root, ["rev-parse", state.targetBranch + "^{commit}"]);
|
|
186
|
+
const exact = target.exitCode === 0 && target.stdout.trim() === integration.candidate;
|
|
187
|
+
const historic = !exact && target.exitCode === 0 && integration.completedSteps.includes("target") &&
|
|
188
|
+
integration.completedSteps.includes("receipt") && integration.completedSteps.includes("note") &&
|
|
189
|
+
(await git(state.root, ["merge-base", "--is-ancestor", integration.candidate, target.stdout.trim()])).exitCode === 0;
|
|
190
|
+
if (!exact && !historic)
|
|
191
|
+
throw new AgentOpsError("RUN_INTEGRATION_TARGET_CHANGED", "The target is inconsistent with the sealed, receipt-bound integration history.");
|
|
192
|
+
await repository.mutate(runId, current => {
|
|
193
|
+
if (current.integration === null || current.integration.transactionId !== integration.transactionId ||
|
|
194
|
+
current.integration.candidate !== integration.candidate || current.integration.expectedTarget !== integration.expectedTarget)
|
|
195
|
+
throw new AgentOpsError("RUN_INTEGRATION_STALE", "The integration changed during recovery.");
|
|
196
|
+
if (current.integration.status === "cleaned")
|
|
197
|
+
return current;
|
|
198
|
+
if (current.integration.status !== "receipt-written")
|
|
199
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The integration is not at the cleanup recovery boundary.");
|
|
200
|
+
return { ...current, integration: { ...current.integration, status: "cleaned",
|
|
201
|
+
completedSteps: [...new Set([...current.integration.completedSteps, "coordinator"])], updatedAt: new Date().toISOString() } };
|
|
202
|
+
});
|
|
203
|
+
return { transactionId: integration.transactionId, receiptPath: integration.receiptPath, receiptDigest: integration.receiptDigest };
|
|
204
|
+
}
|