impel-cli 0.20.1 → 0.20.2-beta.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/package.json +1 -1
- package/src/agents.js +409 -184
- package/src/apps.js +5 -3
- package/src/commands/mcp.js +167 -2
- package/src/nativeAgentTransport.js +1962 -0
- package/src/selfInvocation.js +28 -0
|
@@ -0,0 +1,1962 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CONFIG_DIR,
|
|
7
|
+
normalizeGatewayUrl,
|
|
8
|
+
redactCredentialText,
|
|
9
|
+
redactSecretText,
|
|
10
|
+
} from "./config.js";
|
|
11
|
+
import {
|
|
12
|
+
NATIVE_AGENT_LIST_TOOL,
|
|
13
|
+
NATIVE_AGENT_READ_TOOL,
|
|
14
|
+
NATIVE_AGENT_RECOVER_TOOL,
|
|
15
|
+
NATIVE_AGENT_RESUME_TOOL,
|
|
16
|
+
NATIVE_AGENT_RUN_TOOL,
|
|
17
|
+
NATIVE_AGENT_START_TOOL,
|
|
18
|
+
nativeAgentPolicyFingerprint,
|
|
19
|
+
normalizeNativeAgentCatalog,
|
|
20
|
+
} from "./agents.js";
|
|
21
|
+
import { normalizeTenantId } from "./tenants.js";
|
|
22
|
+
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
23
|
+
|
|
24
|
+
export { NATIVE_AGENT_RECOVER_TOOL, NATIVE_AGENT_RESUME_TOOL, NATIVE_AGENT_RUN_TOOL };
|
|
25
|
+
export const NATIVE_AGENT_HANDLE_SCHEMA = "impel.native-agent-run.v1";
|
|
26
|
+
export const NATIVE_AGENT_RESULT_SCHEMA = "impel.native-agent-result.v1";
|
|
27
|
+
export const NATIVE_AGENT_RECOVERY_SCHEMA = "impel.native-agent-recovery.v1";
|
|
28
|
+
|
|
29
|
+
const STATE_SCHEMA = "impel.native-agent-state.v1";
|
|
30
|
+
const DELETION_SCHEMA = "impel.native-agent-deletion.v1";
|
|
31
|
+
const FENCE_MARKER_SCHEMA = "impel.native-agent-fence.v1";
|
|
32
|
+
const SAFE_BINDING_RE = /^[A-Za-z0-9_.:-]{1,160}$/u;
|
|
33
|
+
const SAFE_FINGERPRINT_RE = /^[a-f0-9]{64}$/u;
|
|
34
|
+
const SAFE_INVOCATION_RE = /^[a-f0-9-]{36}$/u;
|
|
35
|
+
const DEFAULT_WAIT_SECONDS = 20;
|
|
36
|
+
const MAX_WAIT_SECONDS = 50;
|
|
37
|
+
const DEFAULT_ATTACHMENT_WINDOW_MS = 55_000;
|
|
38
|
+
const DEFAULT_UPSTREAM_TIMEOUT_MS = 55_000;
|
|
39
|
+
const DEFAULT_MAX_POLLS = 8;
|
|
40
|
+
const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
41
|
+
const DEFAULT_MAX_RETAINED_RUNS = 500;
|
|
42
|
+
const DEFAULT_MAX_RECOVERY_RESULTS = 20;
|
|
43
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
44
|
+
const DEFAULT_MAX_STATE_BYTES = 512 * 1024;
|
|
45
|
+
const DEFAULT_LOCK_LEASE_MS = 75_000;
|
|
46
|
+
const DEFAULT_ADMISSION_LEASE_MS = 10_000;
|
|
47
|
+
const STATE_COMMIT_LEASE_MARGIN_MS = 5_000;
|
|
48
|
+
const CIBI_START_IN_PROGRESS_CODE = "eve_run_drain_start_in_progress";
|
|
49
|
+
const CIBI_START_IN_PROGRESS_MESSAGE = "The durable Eve drain workflow is still being enqueued; retry this idempotent start.";
|
|
50
|
+
const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled", "canceled"]);
|
|
51
|
+
|
|
52
|
+
function stableValue(value) {
|
|
53
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
54
|
+
if (!value || typeof value !== "object") return value;
|
|
55
|
+
const result = {};
|
|
56
|
+
for (const key of Object.keys(value).sort()) result[key] = stableValue(value[key]);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stableJson(value) {
|
|
61
|
+
return JSON.stringify(stableValue(value));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function revisionCoordinatesForName(name) {
|
|
65
|
+
const match = /^(\d{16})-(\d{16})-[a-f0-9]{32}\.json$/u.exec(name);
|
|
66
|
+
if (!match || path.basename(name) !== name) return null;
|
|
67
|
+
const coordinates = [Number(match[1]), Number(match[2])];
|
|
68
|
+
return coordinates.every(Number.isSafeInteger) ? coordinates : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sha256(value) {
|
|
72
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function redactOpaqueValue(value, opaque) {
|
|
76
|
+
if (typeof value === "string") return opaque ? value.replaceAll(opaque, "[REDACTED IDEMPOTENCY KEY]") : value;
|
|
77
|
+
if (Array.isArray(value)) return value.map((item) => redactOpaqueValue(item, opaque));
|
|
78
|
+
if (!value || typeof value !== "object") return value;
|
|
79
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactOpaqueValue(item, opaque)]));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function diagnosticValue(value, opaque, depth = 0) {
|
|
83
|
+
if (depth > 6) return "[TRUNCATED]";
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
return redactOpaqueValue(redactSecretText(value), opaque).slice(0, 4_000);
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
return value.slice(0, 50).map((item) => diagnosticValue(item, opaque, depth + 1));
|
|
89
|
+
}
|
|
90
|
+
if (!value || typeof value !== "object") return value;
|
|
91
|
+
return Object.fromEntries(
|
|
92
|
+
Object.entries(value)
|
|
93
|
+
.slice(0, 50)
|
|
94
|
+
.map(([key, item]) => [key, diagnosticValue(item, opaque, depth + 1)]),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function diagnosticFor(error, opaque) {
|
|
99
|
+
const diagnostic = {
|
|
100
|
+
message: diagnosticValue(error?.message || error || "native-agent attachment failed", opaque),
|
|
101
|
+
};
|
|
102
|
+
if (error?.code !== undefined) diagnostic.code = diagnosticValue(error.code, opaque);
|
|
103
|
+
if (error?.data !== undefined) diagnostic.data = diagnosticValue(error.data, opaque);
|
|
104
|
+
return diagnostic;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function replaceObject(target, source) {
|
|
108
|
+
for (const key of Object.keys(target)) delete target[key];
|
|
109
|
+
Object.assign(target, source);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function requestFingerprintFor({ tenantId, agentId, scopeParam, policyFingerprint, startArguments }) {
|
|
113
|
+
return sha256(stableJson({
|
|
114
|
+
tenantId,
|
|
115
|
+
agentId,
|
|
116
|
+
scopeParam,
|
|
117
|
+
policyFingerprint,
|
|
118
|
+
arguments: startArguments,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function exactObject(value, allowed, label) {
|
|
123
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
124
|
+
throw new Error(`${label} must be an object`);
|
|
125
|
+
}
|
|
126
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
127
|
+
if (unknown.length) throw new Error(`${label} contains unsupported properties`);
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function boundedString(value, label, max, { nonblank = true } = {}) {
|
|
132
|
+
if (typeof value !== "string" || value.length > max || (nonblank && !value.trim())) {
|
|
133
|
+
throw new Error(`${label} is invalid`);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function contextKeys(value) {
|
|
139
|
+
if (value === undefined) return [];
|
|
140
|
+
if (!Array.isArray(value) || value.length > 30) throw new Error("contextKeys is invalid");
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
return value.map((item) => {
|
|
143
|
+
const key = boundedString(item, "contextKeys", 160);
|
|
144
|
+
if (seen.has(key)) throw new Error("contextKeys contains a duplicate");
|
|
145
|
+
seen.add(key);
|
|
146
|
+
return key;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function abortError() {
|
|
151
|
+
const error = new Error("native-agent attachment cancelled");
|
|
152
|
+
error.name = "AbortError";
|
|
153
|
+
return error;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function throwIfAborted(signal) {
|
|
157
|
+
if (signal?.aborted) throw abortError();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function boundedAttachmentSignal(parentSignal, timeoutMs) {
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
let timedOut = false;
|
|
163
|
+
const onAbort = () => controller.abort();
|
|
164
|
+
parentSignal?.addEventListener("abort", onAbort, { once: true });
|
|
165
|
+
const timeout = setTimeout(() => {
|
|
166
|
+
timedOut = true;
|
|
167
|
+
controller.abort();
|
|
168
|
+
}, timeoutMs);
|
|
169
|
+
if (parentSignal?.aborted) controller.abort();
|
|
170
|
+
return {
|
|
171
|
+
signal: controller.signal,
|
|
172
|
+
timedOut: () => timedOut,
|
|
173
|
+
dispose: () => {
|
|
174
|
+
clearTimeout(timeout);
|
|
175
|
+
parentSignal?.removeEventListener("abort", onAbort);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function processIsAlive(pid) {
|
|
181
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return false;
|
|
182
|
+
try {
|
|
183
|
+
process.kill(pid, 0);
|
|
184
|
+
return true;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// EPERM means the process exists but is owned by another principal.
|
|
187
|
+
return error?.code !== "ESRCH";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function privateDirectory(directory) {
|
|
192
|
+
if (fs.existsSync(directory)) {
|
|
193
|
+
const stat = fs.lstatSync(directory);
|
|
194
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
195
|
+
throw new Error(`refusing to use unsafe native-agent run directory ${directory}`);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
199
|
+
const stat = fs.lstatSync(directory);
|
|
200
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
201
|
+
throw new Error(`refusing to use unsafe native-agent run directory ${directory}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
fs.chmodSync(directory, 0o700);
|
|
206
|
+
} catch {
|
|
207
|
+
// chmod is best effort on Windows.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export class NativeAgentStateSizeError extends Error {
|
|
212
|
+
constructor(message = "native-agent run state exceeds the configured size limit") {
|
|
213
|
+
super(message);
|
|
214
|
+
this.name = "NativeAgentStateSizeError";
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function safeStateJson(state, maxBytes) {
|
|
219
|
+
const serialized = `${JSON.stringify(state, null, 2)}\n`;
|
|
220
|
+
if (redactCredentialText(serialized) !== serialized) {
|
|
221
|
+
throw new Error("refusing to persist a credential in native-agent run state");
|
|
222
|
+
}
|
|
223
|
+
if (Buffer.byteLength(serialized, "utf8") > maxBytes) {
|
|
224
|
+
throw new NativeAgentStateSizeError();
|
|
225
|
+
}
|
|
226
|
+
return serialized;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function atomicPrivateWrite(filePath, state, maxBytes, { beforeRename } = {}) {
|
|
230
|
+
privateDirectory(path.dirname(filePath));
|
|
231
|
+
if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) {
|
|
232
|
+
throw new Error("refusing to write symlinked native-agent run state");
|
|
233
|
+
}
|
|
234
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
235
|
+
try {
|
|
236
|
+
fs.writeFileSync(temporaryPath, safeStateJson(state, maxBytes), { mode: 0o600, flag: "wx" });
|
|
237
|
+
beforeRename?.();
|
|
238
|
+
renameWithWindowsRetry(temporaryPath, filePath);
|
|
239
|
+
try {
|
|
240
|
+
fs.chmodSync(filePath, 0o600);
|
|
241
|
+
} catch {
|
|
242
|
+
// chmod is best effort on Windows.
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
try {
|
|
246
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
247
|
+
} catch {
|
|
248
|
+
// The rename removes the temporary file on the normal path.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readPrivateState(filePath, maxBytes) {
|
|
254
|
+
const stat = fs.lstatSync(filePath);
|
|
255
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > maxBytes) {
|
|
256
|
+
throw new Error("native-agent run state is unsafe or invalid");
|
|
257
|
+
}
|
|
258
|
+
let state;
|
|
259
|
+
try {
|
|
260
|
+
state = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
261
|
+
} catch {
|
|
262
|
+
throw new Error("native-agent run state is invalid");
|
|
263
|
+
}
|
|
264
|
+
if (!state || typeof state !== "object" || Array.isArray(state) || state.schema !== STATE_SCHEMA) {
|
|
265
|
+
throw new Error("native-agent run state is invalid");
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
exactObject(state, [
|
|
269
|
+
"schema",
|
|
270
|
+
"invocationId",
|
|
271
|
+
"tenantId",
|
|
272
|
+
"agentId",
|
|
273
|
+
"scopeParam",
|
|
274
|
+
"policyFingerprint",
|
|
275
|
+
"requestFingerprint",
|
|
276
|
+
"idempotencyKey",
|
|
277
|
+
"startArguments",
|
|
278
|
+
"upstreamRunId",
|
|
279
|
+
"status",
|
|
280
|
+
"startAttempts",
|
|
281
|
+
"createdAt",
|
|
282
|
+
"updatedAt",
|
|
283
|
+
"fence",
|
|
284
|
+
"sequence",
|
|
285
|
+
"lastTransportError",
|
|
286
|
+
"result",
|
|
287
|
+
"output",
|
|
288
|
+
"error",
|
|
289
|
+
], "native-agent run state");
|
|
290
|
+
if (!SAFE_INVOCATION_RE.test(state.invocationId)
|
|
291
|
+
|| normalizeTenantId(state.tenantId) !== state.tenantId
|
|
292
|
+
|| !SAFE_BINDING_RE.test(state.agentId)
|
|
293
|
+
|| !SAFE_BINDING_RE.test(state.scopeParam)
|
|
294
|
+
|| !SAFE_FINGERPRINT_RE.test(state.policyFingerprint)
|
|
295
|
+
|| !SAFE_FINGERPRINT_RE.test(state.requestFingerprint)
|
|
296
|
+
|| typeof state.idempotencyKey !== "string"
|
|
297
|
+
|| !/^[A-Za-z0-9._:-]{16,200}$/u.test(state.idempotencyKey)
|
|
298
|
+
|| typeof state.status !== "string"
|
|
299
|
+
|| !state.status
|
|
300
|
+
|| !Number.isSafeInteger(state.startAttempts)
|
|
301
|
+
|| state.startAttempts < 0
|
|
302
|
+
|| !Number.isFinite(Date.parse(state.createdAt))
|
|
303
|
+
|| !Number.isFinite(Date.parse(state.updatedAt))
|
|
304
|
+
|| (state.fence !== undefined && (!Number.isSafeInteger(state.fence) || state.fence < 0))
|
|
305
|
+
|| (state.sequence !== undefined && (!Number.isSafeInteger(state.sequence) || state.sequence < 0))
|
|
306
|
+
|| (state.upstreamRunId !== null && (typeof state.upstreamRunId !== "string" || !state.upstreamRunId))) {
|
|
307
|
+
throw new Error("invalid field");
|
|
308
|
+
}
|
|
309
|
+
exactObject(state.startArguments, [
|
|
310
|
+
"agentId",
|
|
311
|
+
"scopeParam",
|
|
312
|
+
"task",
|
|
313
|
+
"context",
|
|
314
|
+
"contextKeys",
|
|
315
|
+
"confirmedSideEffects",
|
|
316
|
+
"idempotencyKey",
|
|
317
|
+
], "native-agent start arguments");
|
|
318
|
+
boundedString(state.startArguments.task, "task", 40_000);
|
|
319
|
+
if (state.startArguments.context !== undefined) {
|
|
320
|
+
boundedString(state.startArguments.context, "context", 40_000, { nonblank: false });
|
|
321
|
+
}
|
|
322
|
+
contextKeys(state.startArguments.contextKeys);
|
|
323
|
+
if (state.startArguments.confirmedSideEffects !== undefined
|
|
324
|
+
&& typeof state.startArguments.confirmedSideEffects !== "boolean") {
|
|
325
|
+
throw new Error("invalid side-effect confirmation");
|
|
326
|
+
}
|
|
327
|
+
if (state.startArguments.agentId !== state.agentId
|
|
328
|
+
|| state.startArguments.scopeParam !== state.scopeParam
|
|
329
|
+
|| state.startArguments.idempotencyKey !== state.idempotencyKey
|
|
330
|
+
|| requestFingerprintFor(state) !== state.requestFingerprint) {
|
|
331
|
+
throw new Error("fingerprint mismatch");
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
throw new Error("native-agent run state failed integrity validation");
|
|
335
|
+
}
|
|
336
|
+
return state;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function handleForState(state) {
|
|
340
|
+
return {
|
|
341
|
+
schema: NATIVE_AGENT_HANDLE_SCHEMA,
|
|
342
|
+
tenantId: state.tenantId,
|
|
343
|
+
agentId: state.agentId,
|
|
344
|
+
scopeParam: state.scopeParam,
|
|
345
|
+
policyFingerprint: state.policyFingerprint,
|
|
346
|
+
invocationId: state.invocationId,
|
|
347
|
+
requestFingerprint: state.requestFingerprint,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function normalizeHandle(value) {
|
|
352
|
+
exactObject(value, [
|
|
353
|
+
"schema",
|
|
354
|
+
"tenantId",
|
|
355
|
+
"agentId",
|
|
356
|
+
"scopeParam",
|
|
357
|
+
"policyFingerprint",
|
|
358
|
+
"invocationId",
|
|
359
|
+
"requestFingerprint",
|
|
360
|
+
], "native-agent handle");
|
|
361
|
+
if (value.schema !== NATIVE_AGENT_HANDLE_SCHEMA) throw new Error("native-agent handle schema is invalid");
|
|
362
|
+
const tenantId = normalizeTenantId(value.tenantId);
|
|
363
|
+
for (const [field, item] of Object.entries({ agentId: value.agentId, scopeParam: value.scopeParam })) {
|
|
364
|
+
if (typeof item !== "string" || !SAFE_BINDING_RE.test(item)) {
|
|
365
|
+
throw new Error(`native-agent handle ${field} is invalid`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const field of ["policyFingerprint", "requestFingerprint"]) {
|
|
369
|
+
if (typeof value[field] !== "string" || !SAFE_FINGERPRINT_RE.test(value[field])) {
|
|
370
|
+
throw new Error(`native-agent handle ${field} is invalid`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (typeof value.invocationId !== "string" || !SAFE_INVOCATION_RE.test(value.invocationId)) {
|
|
374
|
+
throw new Error("native-agent handle invocationId is invalid");
|
|
375
|
+
}
|
|
376
|
+
return { ...value, tenantId };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function preservedOutput(payload) {
|
|
380
|
+
if (payload && Object.hasOwn(payload, "output")) return payload.output;
|
|
381
|
+
if (payload && Object.hasOwn(payload, "result")) return payload.result;
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function resultForState(state) {
|
|
386
|
+
if (state.status === "succeeded") {
|
|
387
|
+
const finalText = state.result?.finalText;
|
|
388
|
+
if (typeof finalText !== "string") {
|
|
389
|
+
// A crash can occur after the upstream identity is made durable but
|
|
390
|
+
// before its terminal payload is committed. Reattach instead of turning
|
|
391
|
+
// that narrow window into an unrecoverable terminal snapshot.
|
|
392
|
+
return handleForState(state);
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
schema: NATIVE_AGENT_RESULT_SCHEMA,
|
|
396
|
+
status: "succeeded",
|
|
397
|
+
runId: state.upstreamRunId,
|
|
398
|
+
finalText,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (TERMINAL_STATUSES.has(state.status)) {
|
|
402
|
+
return {
|
|
403
|
+
schema: NATIVE_AGENT_RESULT_SCHEMA,
|
|
404
|
+
status: state.status,
|
|
405
|
+
runId: state.upstreamRunId || null,
|
|
406
|
+
output: Object.hasOwn(state, "output")
|
|
407
|
+
? state.output
|
|
408
|
+
: typeof state.result?.finalText === "string"
|
|
409
|
+
? state.result.finalText
|
|
410
|
+
: preservedOutput(state),
|
|
411
|
+
error: Object.hasOwn(state, "error") ? state.error : "native-agent run failed",
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return handleForState(state);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function applyUpstreamPayload(state, payload, now) {
|
|
418
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
419
|
+
throw new Error("native-agent upstream returned an invalid run");
|
|
420
|
+
}
|
|
421
|
+
if (payload.runId !== undefined) {
|
|
422
|
+
boundedString(payload.runId, "native-agent runId", 512);
|
|
423
|
+
if (state.upstreamRunId && payload.runId !== state.upstreamRunId) {
|
|
424
|
+
throw new Error("native-agent upstream returned a different runId");
|
|
425
|
+
}
|
|
426
|
+
state.upstreamRunId = payload.runId;
|
|
427
|
+
}
|
|
428
|
+
if (typeof payload.status === "string" && payload.status) state.status = payload.status;
|
|
429
|
+
else if (state.upstreamRunId && state.status === "starting") state.status = "queued";
|
|
430
|
+
if (Object.hasOwn(payload, "result")) state.result = payload.result;
|
|
431
|
+
if (Object.hasOwn(payload, "output")) state.output = payload.output;
|
|
432
|
+
else if (TERMINAL_STATUSES.has(state.status)
|
|
433
|
+
&& state.status !== "succeeded"
|
|
434
|
+
&& typeof payload.result?.finalText === "string") {
|
|
435
|
+
// Hosted/CIBI failure payloads carry their authoritative partial text in
|
|
436
|
+
// result.finalText rather than output; replace any older running snapshot.
|
|
437
|
+
state.output = payload.result.finalText;
|
|
438
|
+
}
|
|
439
|
+
if (Object.hasOwn(payload, "error")) {
|
|
440
|
+
state.error = redactOpaqueValue(payload.error, state.idempotencyKey);
|
|
441
|
+
}
|
|
442
|
+
state.updatedAt = new Date(now).toISOString();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Use the read tool's advertised integer maximum, never exceeding 50 seconds. */
|
|
446
|
+
export function negotiateNativeAgentWaitSeconds(tools) {
|
|
447
|
+
const readTool = Array.isArray(tools)
|
|
448
|
+
? tools.find((tool) => tool?.name === NATIVE_AGENT_READ_TOOL)
|
|
449
|
+
: null;
|
|
450
|
+
const maximum = readTool?.inputSchema?.properties?.waitSeconds?.maximum;
|
|
451
|
+
if (typeof maximum !== "number" || !Number.isFinite(maximum) || maximum < 1) {
|
|
452
|
+
return DEFAULT_WAIT_SECONDS;
|
|
453
|
+
}
|
|
454
|
+
return Math.min(MAX_WAIT_SECONDS, Math.max(1, Math.floor(maximum)));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export class NativeAgentUpstreamError extends Error {
|
|
458
|
+
constructor(message, {
|
|
459
|
+
ambiguous = false,
|
|
460
|
+
code,
|
|
461
|
+
data,
|
|
462
|
+
incompleteResponse = false,
|
|
463
|
+
} = {}) {
|
|
464
|
+
super(message);
|
|
465
|
+
this.name = "NativeAgentUpstreamError";
|
|
466
|
+
this.ambiguous = ambiguous;
|
|
467
|
+
this.incompleteResponse = incompleteResponse;
|
|
468
|
+
if (code !== undefined) this.code = code;
|
|
469
|
+
if (data !== undefined) this.data = data;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function readBoundedResponseBody(response, maxBytes) {
|
|
474
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
475
|
+
const body = await response.text();
|
|
476
|
+
if (Buffer.byteLength(body, "utf8") > maxBytes) {
|
|
477
|
+
throw new NativeAgentUpstreamError("native-agent MCP response exceeded the size limit", {
|
|
478
|
+
ambiguous: true,
|
|
479
|
+
incompleteResponse: true,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return body;
|
|
483
|
+
}
|
|
484
|
+
const reader = response.body.getReader();
|
|
485
|
+
const decoder = new TextDecoder();
|
|
486
|
+
let body = "";
|
|
487
|
+
let size = 0;
|
|
488
|
+
try {
|
|
489
|
+
while (true) {
|
|
490
|
+
const { done, value } = await reader.read();
|
|
491
|
+
if (done) break;
|
|
492
|
+
size += value.byteLength;
|
|
493
|
+
if (size > maxBytes) {
|
|
494
|
+
try {
|
|
495
|
+
await reader.cancel();
|
|
496
|
+
} catch {
|
|
497
|
+
// Cancelling an already failed response body is best effort.
|
|
498
|
+
}
|
|
499
|
+
throw new NativeAgentUpstreamError("native-agent MCP response exceeded the size limit", {
|
|
500
|
+
ambiguous: true,
|
|
501
|
+
incompleteResponse: true,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
body += decoder.decode(value, { stream: true });
|
|
505
|
+
}
|
|
506
|
+
body += decoder.decode();
|
|
507
|
+
return body;
|
|
508
|
+
} finally {
|
|
509
|
+
reader.releaseLock();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function decodeMcpResponse(contentType, body) {
|
|
514
|
+
if (!body.trim()) return [];
|
|
515
|
+
if (!String(contentType || "").toLowerCase().includes("text/event-stream")) {
|
|
516
|
+
return [JSON.parse(body)];
|
|
517
|
+
}
|
|
518
|
+
return body
|
|
519
|
+
.split(/\r?\n/u)
|
|
520
|
+
.filter((line) => line.startsWith("data:"))
|
|
521
|
+
.map((line) => line.slice(5).trim())
|
|
522
|
+
.filter((line) => line && line !== "[DONE]")
|
|
523
|
+
.map((line) => JSON.parse(line));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function toolPayload(message) {
|
|
527
|
+
if (!message) {
|
|
528
|
+
throw new NativeAgentUpstreamError("native-agent MCP returned no matching response", {
|
|
529
|
+
incompleteResponse: true,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (message.error) {
|
|
533
|
+
throw new NativeAgentUpstreamError(
|
|
534
|
+
redactSecretText(message.error.message || "native-agent MCP tool failed"),
|
|
535
|
+
{ code: message.error.code, data: message.error.data },
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const result = message.result;
|
|
539
|
+
const text = result?.content?.find((item) => item?.type === "text" && typeof item.text === "string")?.text;
|
|
540
|
+
if (result?.isError) {
|
|
541
|
+
throw new NativeAgentUpstreamError(redactSecretText(text || "native-agent MCP tool failed"));
|
|
542
|
+
}
|
|
543
|
+
if (result?.structuredContent && typeof result.structuredContent === "object") {
|
|
544
|
+
return result.structuredContent;
|
|
545
|
+
}
|
|
546
|
+
if (text !== undefined) {
|
|
547
|
+
try {
|
|
548
|
+
return JSON.parse(text);
|
|
549
|
+
} catch {
|
|
550
|
+
throw new NativeAgentUpstreamError("native-agent MCP tool returned invalid JSON", {
|
|
551
|
+
incompleteResponse: true,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export class NativeAgentUpstreamSession {
|
|
559
|
+
constructor({
|
|
560
|
+
gatewayUrl,
|
|
561
|
+
credential,
|
|
562
|
+
fetchImpl = fetch,
|
|
563
|
+
timeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS,
|
|
564
|
+
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
|
565
|
+
signal,
|
|
566
|
+
}) {
|
|
567
|
+
this.endpoint = `${normalizeGatewayUrl(gatewayUrl)}/mcp`;
|
|
568
|
+
this.credential = credential;
|
|
569
|
+
this.fetchImpl = fetchImpl;
|
|
570
|
+
this.timeoutMs = timeoutMs;
|
|
571
|
+
this.maxResponseBytes = maxResponseBytes;
|
|
572
|
+
this.signal = signal;
|
|
573
|
+
this.sessionId = null;
|
|
574
|
+
this.nextId = 1;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async post(message) {
|
|
578
|
+
throwIfAborted(this.signal);
|
|
579
|
+
const controller = new AbortController();
|
|
580
|
+
let timedOut = false;
|
|
581
|
+
const onAbort = () => controller.abort();
|
|
582
|
+
this.signal?.addEventListener("abort", onAbort, { once: true });
|
|
583
|
+
const timeout = setTimeout(() => {
|
|
584
|
+
timedOut = true;
|
|
585
|
+
controller.abort();
|
|
586
|
+
}, this.timeoutMs);
|
|
587
|
+
let response;
|
|
588
|
+
let body;
|
|
589
|
+
try {
|
|
590
|
+
response = await this.fetchImpl(this.endpoint, {
|
|
591
|
+
method: "POST",
|
|
592
|
+
headers: {
|
|
593
|
+
Authorization: `Bearer ${this.credential}`,
|
|
594
|
+
"Content-Type": "application/json",
|
|
595
|
+
Accept: "application/json, text/event-stream",
|
|
596
|
+
...(this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {}),
|
|
597
|
+
},
|
|
598
|
+
body: JSON.stringify(message),
|
|
599
|
+
signal: controller.signal,
|
|
600
|
+
});
|
|
601
|
+
body = await readBoundedResponseBody(response, this.maxResponseBytes);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (this.signal?.aborted) throw abortError();
|
|
604
|
+
const detail = timedOut ? "request timed out" : redactSecretText(error?.message || error);
|
|
605
|
+
throw new NativeAgentUpstreamError(`native-agent MCP request failed: ${detail}`, { ambiguous: true });
|
|
606
|
+
} finally {
|
|
607
|
+
clearTimeout(timeout);
|
|
608
|
+
this.signal?.removeEventListener("abort", onAbort);
|
|
609
|
+
}
|
|
610
|
+
this.sessionId = response.headers.get("mcp-session-id") || this.sessionId;
|
|
611
|
+
if (!response.ok) {
|
|
612
|
+
throw new NativeAgentUpstreamError(`native-agent MCP returned HTTP ${response.status}`, {
|
|
613
|
+
ambiguous: response.status === 408 || response.status === 429 || response.status >= 500,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
return decodeMcpResponse(response.headers.get("content-type"), body);
|
|
618
|
+
} catch {
|
|
619
|
+
throw new NativeAgentUpstreamError("native-agent MCP returned invalid JSON", {
|
|
620
|
+
incompleteResponse: true,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async initialize() {
|
|
626
|
+
const id = this.nextId++;
|
|
627
|
+
const initialized = await this.post({
|
|
628
|
+
jsonrpc: "2.0",
|
|
629
|
+
id,
|
|
630
|
+
method: "initialize",
|
|
631
|
+
params: {
|
|
632
|
+
protocolVersion: "2025-06-18",
|
|
633
|
+
capabilities: {},
|
|
634
|
+
clientInfo: { name: "impel-cli-native-agent-transport", version: "1.0.0" },
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
const response = initialized.find((message) => message?.id === id);
|
|
638
|
+
if (!response?.result || response.error) {
|
|
639
|
+
throw new NativeAgentUpstreamError("native-agent MCP initialization failed");
|
|
640
|
+
}
|
|
641
|
+
await this.post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
|
|
642
|
+
const toolsId = this.nextId++;
|
|
643
|
+
const listed = await this.post({ jsonrpc: "2.0", id: toolsId, method: "tools/list", params: {} });
|
|
644
|
+
const toolsResponse = listed.find((message) => message?.id === toolsId);
|
|
645
|
+
if (toolsResponse?.error || !Array.isArray(toolsResponse?.result?.tools)) {
|
|
646
|
+
throw new NativeAgentUpstreamError("native-agent MCP returned no tool catalog");
|
|
647
|
+
}
|
|
648
|
+
return toolsResponse.result.tools;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async call(name, args) {
|
|
652
|
+
const id = this.nextId++;
|
|
653
|
+
try {
|
|
654
|
+
const messages = await this.post({
|
|
655
|
+
jsonrpc: "2.0",
|
|
656
|
+
id,
|
|
657
|
+
method: "tools/call",
|
|
658
|
+
params: { name, arguments: args },
|
|
659
|
+
});
|
|
660
|
+
return toolPayload(messages.find((message) => message?.id === id));
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (name === NATIVE_AGENT_START_TOOL
|
|
663
|
+
&& error instanceof NativeAgentUpstreamError
|
|
664
|
+
&& (error.incompleteResponse
|
|
665
|
+
|| error.code === CIBI_START_IN_PROGRESS_CODE
|
|
666
|
+
|| error.data?.code === CIBI_START_IN_PROGRESS_CODE
|
|
667
|
+
|| error.message === CIBI_START_IN_PROGRESS_MESSAGE)) {
|
|
668
|
+
error.ambiguous = true;
|
|
669
|
+
}
|
|
670
|
+
throw error;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export class NativeAgentCompositeTransport {
|
|
676
|
+
constructor({
|
|
677
|
+
tenantId,
|
|
678
|
+
agentId,
|
|
679
|
+
scopeParam,
|
|
680
|
+
policyFingerprint,
|
|
681
|
+
gatewayUrl,
|
|
682
|
+
credential,
|
|
683
|
+
runsRoot = path.join(CONFIG_DIR, "native-agent-runs"),
|
|
684
|
+
attachmentWindowMs = DEFAULT_ATTACHMENT_WINDOW_MS,
|
|
685
|
+
maxPolls = DEFAULT_MAX_POLLS,
|
|
686
|
+
now = Date.now,
|
|
687
|
+
randomUUID = crypto.randomUUID,
|
|
688
|
+
randomBytes = crypto.randomBytes,
|
|
689
|
+
sessionFactory,
|
|
690
|
+
fetchImpl = fetch,
|
|
691
|
+
upstreamTimeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS,
|
|
692
|
+
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
|
693
|
+
maxStateBytes = DEFAULT_MAX_STATE_BYTES,
|
|
694
|
+
maxRetainedRuns = DEFAULT_MAX_RETAINED_RUNS,
|
|
695
|
+
retentionMs = DEFAULT_RETENTION_MS,
|
|
696
|
+
maxRecoveryResults = DEFAULT_MAX_RECOVERY_RESULTS,
|
|
697
|
+
lockLeaseMs = DEFAULT_LOCK_LEASE_MS,
|
|
698
|
+
isProcessAlive = processIsAlive,
|
|
699
|
+
beforeFenceMarkerPublish,
|
|
700
|
+
beforeFenceMarkerCompaction,
|
|
701
|
+
beforeStateRename,
|
|
702
|
+
beforeRevisionCompaction,
|
|
703
|
+
beforePruneDelete,
|
|
704
|
+
}) {
|
|
705
|
+
this.tenantId = normalizeTenantId(tenantId);
|
|
706
|
+
for (const [field, value] of Object.entries({ agentId, scopeParam })) {
|
|
707
|
+
if (typeof value !== "string" || !SAFE_BINDING_RE.test(value)) {
|
|
708
|
+
throw new Error(`native-agent ${field} binding is invalid`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (typeof policyFingerprint !== "string" || !SAFE_FINGERPRINT_RE.test(policyFingerprint)) {
|
|
712
|
+
throw new Error("native-agent policy fingerprint is invalid");
|
|
713
|
+
}
|
|
714
|
+
this.agentId = agentId;
|
|
715
|
+
this.scopeParam = scopeParam;
|
|
716
|
+
this.policyFingerprint = policyFingerprint;
|
|
717
|
+
this.gatewayUrl = gatewayUrl;
|
|
718
|
+
this.credential = credential;
|
|
719
|
+
this.runsRoot = runsRoot;
|
|
720
|
+
this.tenantRoot = path.join(runsRoot, this.tenantId);
|
|
721
|
+
this.attachmentWindowMs = Math.max(1, attachmentWindowMs);
|
|
722
|
+
this.maxPolls = Math.max(1, maxPolls);
|
|
723
|
+
this.now = now;
|
|
724
|
+
this.randomUUID = randomUUID;
|
|
725
|
+
this.randomBytes = randomBytes;
|
|
726
|
+
this.fetchImpl = fetchImpl;
|
|
727
|
+
this.upstreamTimeoutMs = upstreamTimeoutMs;
|
|
728
|
+
this.maxResponseBytes = Math.max(1024, maxResponseBytes);
|
|
729
|
+
this.maxStateBytes = Math.max(1024, maxStateBytes);
|
|
730
|
+
this.maxRetainedRuns = Math.max(1, maxRetainedRuns);
|
|
731
|
+
this.retentionMs = Math.max(0, retentionMs);
|
|
732
|
+
this.maxRecoveryResults = Math.max(1, maxRecoveryResults);
|
|
733
|
+
// Keep a healthy owner fenced through its full attachment window plus the
|
|
734
|
+
// platform's bounded atomic-replace retry window.
|
|
735
|
+
this.lockLeaseMs = Math.max(
|
|
736
|
+
this.attachmentWindowMs + STATE_COMMIT_LEASE_MARGIN_MS,
|
|
737
|
+
lockLeaseMs,
|
|
738
|
+
);
|
|
739
|
+
this.isProcessAlive = isProcessAlive;
|
|
740
|
+
this.beforeFenceMarkerPublish = beforeFenceMarkerPublish;
|
|
741
|
+
this.beforeFenceMarkerCompaction = beforeFenceMarkerCompaction;
|
|
742
|
+
this.beforeStateRename = beforeStateRename;
|
|
743
|
+
this.beforeRevisionCompaction = beforeRevisionCompaction;
|
|
744
|
+
this.beforePruneDelete = beforePruneDelete;
|
|
745
|
+
this.sessionFactory = sessionFactory || (({ signal }) => new NativeAgentUpstreamSession({
|
|
746
|
+
gatewayUrl: this.gatewayUrl,
|
|
747
|
+
credential: this.credential,
|
|
748
|
+
fetchImpl: this.fetchImpl,
|
|
749
|
+
timeoutMs: this.upstreamTimeoutMs,
|
|
750
|
+
maxResponseBytes: this.maxResponseBytes,
|
|
751
|
+
signal,
|
|
752
|
+
}));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
statePath(invocationId) {
|
|
756
|
+
return path.join(this.tenantRoot, `${invocationId}.json`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
lockPath(invocationId) {
|
|
760
|
+
return path.join(this.tenantRoot, `${invocationId}.lock`);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
stateVersionsPath(invocationId) {
|
|
764
|
+
return path.join(this.tenantRoot, `${invocationId}.state-versions`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
fencePath(invocationId) {
|
|
768
|
+
return path.join(this.tenantRoot, `${invocationId}.fence`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
fenceVersionsPath(invocationId) {
|
|
772
|
+
return path.join(this.tenantRoot, `${invocationId}.fence-versions`);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
deletionPath(invocationId) {
|
|
776
|
+
return path.join(this.tenantRoot, `${invocationId}.tombstone`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
admissionLockPath() {
|
|
780
|
+
return path.join(this.tenantRoot, ".allocation.lock");
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
ensureRoots() {
|
|
784
|
+
privateDirectory(this.runsRoot);
|
|
785
|
+
privateDirectory(this.tenantRoot);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
readLegacyLockOwner(lockPath) {
|
|
789
|
+
const stat = fs.lstatSync(lockPath);
|
|
790
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024) {
|
|
791
|
+
throw new Error("native-agent run lock is unsafe or invalid");
|
|
792
|
+
}
|
|
793
|
+
let owner;
|
|
794
|
+
try {
|
|
795
|
+
owner = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
796
|
+
} catch {
|
|
797
|
+
throw new Error("native-agent run lock is invalid");
|
|
798
|
+
}
|
|
799
|
+
if (!Number.isSafeInteger(owner?.pid)
|
|
800
|
+
|| owner.pid < 1
|
|
801
|
+
|| !Number.isFinite(Date.parse(owner.createdAt))) {
|
|
802
|
+
throw new Error("native-agent run lock is invalid");
|
|
803
|
+
}
|
|
804
|
+
return { owner, stat };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
readLockClaim(lockPath) {
|
|
808
|
+
const stat = fs.lstatSync(lockPath);
|
|
809
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024) {
|
|
810
|
+
throw new Error("native-agent run lock is unsafe or invalid");
|
|
811
|
+
}
|
|
812
|
+
let claim;
|
|
813
|
+
try {
|
|
814
|
+
claim = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
815
|
+
exactObject(claim, ["schema", "nonce", "generation"], "native-agent run lock claim");
|
|
816
|
+
} catch {
|
|
817
|
+
throw new Error("native-agent run lock is invalid");
|
|
818
|
+
}
|
|
819
|
+
if (claim.schema !== "impel.native-agent-lock-claim.v2"
|
|
820
|
+
|| typeof claim.nonce !== "string"
|
|
821
|
+
|| !/^[a-f0-9]{32}$/u.test(claim.nonce)
|
|
822
|
+
|| path.basename(claim.generation) !== claim.generation
|
|
823
|
+
|| !claim.generation.endsWith(`.generation-${claim.nonce}`)) {
|
|
824
|
+
throw new Error("native-agent run lock is invalid");
|
|
825
|
+
}
|
|
826
|
+
return { claim, stat };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
readClaimedLock(lockPath) {
|
|
830
|
+
const { claim, stat: claimStat } = this.readLockClaim(lockPath);
|
|
831
|
+
const generationPath = path.join(path.dirname(lockPath), claim.generation);
|
|
832
|
+
const generationStat = fs.lstatSync(generationPath);
|
|
833
|
+
if (generationStat.isSymbolicLink() || !generationStat.isDirectory()) {
|
|
834
|
+
throw new Error("native-agent run lock generation is unsafe or invalid");
|
|
835
|
+
}
|
|
836
|
+
const ownerPath = path.join(generationPath, `owner-${claim.nonce}.json`);
|
|
837
|
+
const stat = fs.lstatSync(ownerPath);
|
|
838
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024) {
|
|
839
|
+
throw new Error("native-agent run lock is unsafe or invalid");
|
|
840
|
+
}
|
|
841
|
+
let owner;
|
|
842
|
+
try {
|
|
843
|
+
owner = JSON.parse(fs.readFileSync(ownerPath, "utf8"));
|
|
844
|
+
exactObject(owner, ["pid", "nonce", "createdAt", "fence"], "native-agent run lock");
|
|
845
|
+
} catch {
|
|
846
|
+
throw new Error("native-agent run lock is invalid");
|
|
847
|
+
}
|
|
848
|
+
if (!Number.isSafeInteger(owner.pid)
|
|
849
|
+
|| owner.pid < 1
|
|
850
|
+
|| typeof owner.nonce !== "string"
|
|
851
|
+
|| !/^[a-f0-9]{32}$/u.test(owner.nonce)
|
|
852
|
+
|| !Number.isFinite(Date.parse(owner.createdAt))
|
|
853
|
+
|| (owner.fence !== null && (!Number.isSafeInteger(owner.fence) || owner.fence < 1))) {
|
|
854
|
+
throw new Error("native-agent run lock is invalid");
|
|
855
|
+
}
|
|
856
|
+
if (owner.nonce !== claim.nonce) throw new Error("native-agent run lock generation is invalid");
|
|
857
|
+
return {
|
|
858
|
+
claim,
|
|
859
|
+
claimStat,
|
|
860
|
+
generationPath,
|
|
861
|
+
generationStat,
|
|
862
|
+
owner,
|
|
863
|
+
ownerPath,
|
|
864
|
+
stat,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
lockIsStale(owner, heartbeatMs, leaseMs, now = this.now()) {
|
|
869
|
+
const createdAge = now - Date.parse(owner.createdAt);
|
|
870
|
+
const heartbeatAge = now - heartbeatMs;
|
|
871
|
+
return !this.isProcessAlive(owner.pid)
|
|
872
|
+
|| createdAge < -leaseMs
|
|
873
|
+
|| heartbeatAge > leaseMs
|
|
874
|
+
|| heartbeatAge < -leaseMs;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
readFence(invocationId) {
|
|
878
|
+
const fencePath = this.fencePath(invocationId);
|
|
879
|
+
if (!fs.existsSync(fencePath)) return 0;
|
|
880
|
+
const stat = fs.lstatSync(fencePath);
|
|
881
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 128) {
|
|
882
|
+
throw new Error("native-agent run fence is unsafe or invalid");
|
|
883
|
+
}
|
|
884
|
+
const fence = Number(fs.readFileSync(fencePath, "utf8").trim());
|
|
885
|
+
if (!Number.isSafeInteger(fence) || fence < 0) {
|
|
886
|
+
throw new Error("native-agent run fence is invalid");
|
|
887
|
+
}
|
|
888
|
+
return fence;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
readDeletionTombstone(invocationId) {
|
|
892
|
+
const deletionPath = this.deletionPath(invocationId);
|
|
893
|
+
let stat;
|
|
894
|
+
try {
|
|
895
|
+
stat = fs.lstatSync(deletionPath);
|
|
896
|
+
} catch (error) {
|
|
897
|
+
if (error?.code === "ENOENT") return null;
|
|
898
|
+
throw error;
|
|
899
|
+
}
|
|
900
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024) {
|
|
901
|
+
throw new Error("native-agent deletion tombstone is unsafe or invalid");
|
|
902
|
+
}
|
|
903
|
+
let tombstone;
|
|
904
|
+
try {
|
|
905
|
+
tombstone = JSON.parse(fs.readFileSync(deletionPath, "utf8"));
|
|
906
|
+
exactObject(
|
|
907
|
+
tombstone,
|
|
908
|
+
["schema", "invocationId", "fence", "deletedAt"],
|
|
909
|
+
"native-agent deletion tombstone",
|
|
910
|
+
);
|
|
911
|
+
} catch {
|
|
912
|
+
throw new Error("native-agent deletion tombstone is invalid");
|
|
913
|
+
}
|
|
914
|
+
if (tombstone.schema !== DELETION_SCHEMA
|
|
915
|
+
|| tombstone.invocationId !== invocationId
|
|
916
|
+
|| !Number.isSafeInteger(tombstone.fence)
|
|
917
|
+
|| tombstone.fence < 1
|
|
918
|
+
|| !Number.isFinite(Date.parse(tombstone.deletedAt))) {
|
|
919
|
+
throw new Error("native-agent deletion tombstone is invalid");
|
|
920
|
+
}
|
|
921
|
+
return tombstone;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
writeDeletionTombstone(invocationId, lock) {
|
|
925
|
+
lock.assert();
|
|
926
|
+
const deletionPath = this.deletionPath(invocationId);
|
|
927
|
+
const tombstone = {
|
|
928
|
+
schema: DELETION_SCHEMA,
|
|
929
|
+
invocationId,
|
|
930
|
+
fence: lock.fence,
|
|
931
|
+
deletedAt: new Date(this.now()).toISOString(),
|
|
932
|
+
};
|
|
933
|
+
const temporaryPath = `${deletionPath}.tmp-${process.pid}-${lock.nonce}`;
|
|
934
|
+
try {
|
|
935
|
+
fs.writeFileSync(temporaryPath, safeStateJson(tombstone, 1024), { mode: 0o600, flag: "wx" });
|
|
936
|
+
try {
|
|
937
|
+
// Publish a fully written marker once. The marker and fence are never
|
|
938
|
+
// removed, so no delayed lower-fence state can resurrect this id.
|
|
939
|
+
fs.linkSync(temporaryPath, deletionPath);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
if (error?.code !== "EEXIST") throw error;
|
|
942
|
+
this.readDeletionTombstone(invocationId);
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
fs.chmodSync(deletionPath, 0o600);
|
|
946
|
+
} catch {
|
|
947
|
+
// chmod is best effort on Windows.
|
|
948
|
+
}
|
|
949
|
+
} finally {
|
|
950
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
cleanupTombstonedState(invocationId) {
|
|
955
|
+
for (const [target, options] of [
|
|
956
|
+
[this.statePath(invocationId), { force: true }],
|
|
957
|
+
[this.stateVersionsPath(invocationId), { recursive: true, force: true }],
|
|
958
|
+
]) {
|
|
959
|
+
try {
|
|
960
|
+
fs.rmSync(target, options);
|
|
961
|
+
} catch {
|
|
962
|
+
// The persistent tombstone is already authoritative. A later prune
|
|
963
|
+
// retries physical cleanup without charging this id to capacity.
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
readFenceMarker(filePath, invocationId, expectedFence, expectedNonce) {
|
|
969
|
+
const stat = fs.lstatSync(filePath);
|
|
970
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024) {
|
|
971
|
+
throw new Error("native-agent fence marker is unsafe or invalid");
|
|
972
|
+
}
|
|
973
|
+
let marker;
|
|
974
|
+
try {
|
|
975
|
+
marker = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
976
|
+
exactObject(
|
|
977
|
+
marker,
|
|
978
|
+
["schema", "invocationId", "fence", "nonce", "createdAt"],
|
|
979
|
+
"native-agent fence marker",
|
|
980
|
+
);
|
|
981
|
+
} catch {
|
|
982
|
+
throw new Error("native-agent fence marker is invalid");
|
|
983
|
+
}
|
|
984
|
+
if (marker.schema !== FENCE_MARKER_SCHEMA
|
|
985
|
+
|| marker.invocationId !== invocationId
|
|
986
|
+
|| marker.fence !== expectedFence
|
|
987
|
+
|| marker.nonce !== expectedNonce
|
|
988
|
+
|| !Number.isFinite(Date.parse(marker.createdAt))) {
|
|
989
|
+
throw new Error("native-agent fence marker is invalid");
|
|
990
|
+
}
|
|
991
|
+
return marker;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
fenceMarkers(invocationId) {
|
|
995
|
+
const versionsPath = this.fenceVersionsPath(invocationId);
|
|
996
|
+
if (!fs.existsSync(versionsPath)) return [];
|
|
997
|
+
const stat = fs.lstatSync(versionsPath);
|
|
998
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
999
|
+
throw new Error("native-agent fence markers are unsafe or invalid");
|
|
1000
|
+
}
|
|
1001
|
+
const markers = [];
|
|
1002
|
+
for (const name of fs.readdirSync(versionsPath)) {
|
|
1003
|
+
if (!name.endsWith(".json")) continue;
|
|
1004
|
+
const match = /^(\d{16})-([a-f0-9]{32})\.json$/u.exec(name);
|
|
1005
|
+
const fence = match ? Number(match[1]) : Number.NaN;
|
|
1006
|
+
if (!match || path.basename(name) !== name || !Number.isSafeInteger(fence)) {
|
|
1007
|
+
throw new Error("native-agent fence marker is invalid");
|
|
1008
|
+
}
|
|
1009
|
+
const filePath = path.join(versionsPath, name);
|
|
1010
|
+
markers.push({
|
|
1011
|
+
filePath,
|
|
1012
|
+
marker: this.readFenceMarker(filePath, invocationId, fence, match[2]),
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
return markers;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
highestImmutableFence(invocationId) {
|
|
1019
|
+
return this.fenceMarkers(invocationId)
|
|
1020
|
+
.reduce((highest, { marker }) => Math.max(highest, marker.fence), 0);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
publishFenceMarker(invocationId, fence, nonce) {
|
|
1024
|
+
const versionsPath = this.fenceVersionsPath(invocationId);
|
|
1025
|
+
privateDirectory(versionsPath);
|
|
1026
|
+
const markerPath = path.join(
|
|
1027
|
+
versionsPath,
|
|
1028
|
+
`${String(fence).padStart(16, "0")}-${nonce}.json`,
|
|
1029
|
+
);
|
|
1030
|
+
const temporaryPath = `${markerPath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
1031
|
+
const marker = {
|
|
1032
|
+
schema: FENCE_MARKER_SCHEMA,
|
|
1033
|
+
invocationId,
|
|
1034
|
+
fence,
|
|
1035
|
+
nonce,
|
|
1036
|
+
createdAt: new Date(this.now()).toISOString(),
|
|
1037
|
+
};
|
|
1038
|
+
try {
|
|
1039
|
+
fs.writeFileSync(temporaryPath, safeStateJson(marker, 1024), { mode: 0o600, flag: "wx" });
|
|
1040
|
+
this.beforeFenceMarkerPublish?.({ invocationId, fence, nonce });
|
|
1041
|
+
try {
|
|
1042
|
+
fs.linkSync(temporaryPath, markerPath);
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1045
|
+
}
|
|
1046
|
+
this.readFenceMarker(markerPath, invocationId, fence, nonce);
|
|
1047
|
+
} finally {
|
|
1048
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
1049
|
+
}
|
|
1050
|
+
const lower = this.fenceMarkers(invocationId)
|
|
1051
|
+
.filter(({ marker: candidate }) => candidate.fence < fence)
|
|
1052
|
+
.map(({ filePath }) => filePath);
|
|
1053
|
+
this.beforeFenceMarkerCompaction?.({ invocationId, fence, nonce });
|
|
1054
|
+
for (const obsolete of lower) fs.rmSync(obsolete, { force: true });
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
writeFenceCache(invocationId, fence) {
|
|
1058
|
+
const fencePath = this.fencePath(invocationId);
|
|
1059
|
+
const temporaryPath = `${fencePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
1060
|
+
try {
|
|
1061
|
+
fs.writeFileSync(temporaryPath, `${fence}\n`, { mode: 0o600, flag: "wx" });
|
|
1062
|
+
renameWithWindowsRetry(temporaryPath, fencePath);
|
|
1063
|
+
} finally {
|
|
1064
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
reserveFence(invocationId, nonce) {
|
|
1069
|
+
const tombstone = this.readDeletionTombstone(invocationId);
|
|
1070
|
+
if (tombstone) throw new Error("native-agent run handle is unknown or expired");
|
|
1071
|
+
const authoritativeState = this.readLatestState(invocationId, { allowMissing: true });
|
|
1072
|
+
const fence = Math.max(
|
|
1073
|
+
this.highestImmutableFence(invocationId),
|
|
1074
|
+
authoritativeState?.fence ?? 0,
|
|
1075
|
+
tombstone?.fence ?? 0,
|
|
1076
|
+
this.readFence(invocationId),
|
|
1077
|
+
) + 1;
|
|
1078
|
+
if (!Number.isSafeInteger(fence)) throw new Error("native-agent run fence is exhausted");
|
|
1079
|
+
this.publishFenceMarker(invocationId, fence, nonce);
|
|
1080
|
+
// Compatibility cache only. Immutable markers and state remain the
|
|
1081
|
+
// authority if a delayed lower owner rolls this mutable file backward.
|
|
1082
|
+
this.writeFenceCache(invocationId, fence);
|
|
1083
|
+
return fence;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
readLatestState(invocationId, { allowMissing = false } = {}) {
|
|
1087
|
+
if (this.readDeletionTombstone(invocationId)) {
|
|
1088
|
+
if (allowMissing) return null;
|
|
1089
|
+
throw new Error("native-agent run handle is unknown or expired");
|
|
1090
|
+
}
|
|
1091
|
+
const candidates = [];
|
|
1092
|
+
const basePath = this.statePath(invocationId);
|
|
1093
|
+
if (fs.existsSync(basePath)) candidates.push({ filePath: basePath, expected: null });
|
|
1094
|
+
const versionsPath = this.stateVersionsPath(invocationId);
|
|
1095
|
+
if (fs.existsSync(versionsPath)) {
|
|
1096
|
+
const stat = fs.lstatSync(versionsPath);
|
|
1097
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1098
|
+
throw new Error("native-agent state revisions are unsafe or invalid");
|
|
1099
|
+
}
|
|
1100
|
+
for (const name of fs.readdirSync(versionsPath)) {
|
|
1101
|
+
if (!name.endsWith(".json")) continue;
|
|
1102
|
+
const expected = revisionCoordinatesForName(name);
|
|
1103
|
+
if (!expected) {
|
|
1104
|
+
throw new Error("native-agent state revision is invalid");
|
|
1105
|
+
}
|
|
1106
|
+
candidates.push({
|
|
1107
|
+
filePath: path.join(versionsPath, name),
|
|
1108
|
+
expected,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (!candidates.length) {
|
|
1113
|
+
if (allowMissing) return null;
|
|
1114
|
+
throw new Error("native-agent run handle is unknown or expired");
|
|
1115
|
+
}
|
|
1116
|
+
let latest = null;
|
|
1117
|
+
for (const candidate of candidates) {
|
|
1118
|
+
const state = readPrivateState(candidate.filePath, this.maxStateBytes);
|
|
1119
|
+
if (state.invocationId !== invocationId) {
|
|
1120
|
+
throw new Error("native-agent state revision failed integrity validation");
|
|
1121
|
+
}
|
|
1122
|
+
const coordinates = [state.fence ?? 0, state.sequence ?? 0];
|
|
1123
|
+
if (candidate.expected
|
|
1124
|
+
&& (coordinates[0] !== candidate.expected[0]
|
|
1125
|
+
|| coordinates[1] !== candidate.expected[1])) {
|
|
1126
|
+
throw new Error("native-agent state revision failed integrity validation");
|
|
1127
|
+
}
|
|
1128
|
+
if (!latest
|
|
1129
|
+
|| coordinates[0] > latest.coordinates[0]
|
|
1130
|
+
|| (coordinates[0] === latest.coordinates[0] && coordinates[1] > latest.coordinates[1])) {
|
|
1131
|
+
latest = { state, coordinates };
|
|
1132
|
+
} else if (coordinates[0] === latest.coordinates[0]
|
|
1133
|
+
&& coordinates[1] === latest.coordinates[1]
|
|
1134
|
+
&& stableJson(state) !== stableJson(latest.state)) {
|
|
1135
|
+
throw new Error("native-agent state revisions conflict");
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return latest.state;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
lowerRevisionPaths(invocationId, publishedCoordinates) {
|
|
1142
|
+
const versionsPath = this.stateVersionsPath(invocationId);
|
|
1143
|
+
const lower = [];
|
|
1144
|
+
for (const name of fs.readdirSync(versionsPath)) {
|
|
1145
|
+
if (!name.endsWith(".json")) continue;
|
|
1146
|
+
const coordinates = revisionCoordinatesForName(name);
|
|
1147
|
+
if (!coordinates) throw new Error("native-agent state revision is invalid");
|
|
1148
|
+
const filePath = path.join(versionsPath, name);
|
|
1149
|
+
const state = readPrivateState(filePath, this.maxStateBytes);
|
|
1150
|
+
if (state.invocationId !== invocationId
|
|
1151
|
+
|| (state.fence ?? 0) !== coordinates[0]
|
|
1152
|
+
|| (state.sequence ?? 0) !== coordinates[1]) {
|
|
1153
|
+
throw new Error("native-agent state revision failed integrity validation");
|
|
1154
|
+
}
|
|
1155
|
+
if (coordinates[0] < publishedCoordinates[0]
|
|
1156
|
+
|| (coordinates[0] === publishedCoordinates[0]
|
|
1157
|
+
&& coordinates[1] < publishedCoordinates[1])) {
|
|
1158
|
+
lower.push(filePath);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return lower;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
writeRevision(state, lock, { invokeHook = true } = {}) {
|
|
1165
|
+
const next = structuredClone(state);
|
|
1166
|
+
next.fence = lock.fence;
|
|
1167
|
+
next.sequence = lock.nextSequence + 1;
|
|
1168
|
+
const versionsPath = this.stateVersionsPath(next.invocationId);
|
|
1169
|
+
privateDirectory(versionsPath);
|
|
1170
|
+
const revisionName = `${String(next.fence).padStart(16, "0")}-${String(next.sequence).padStart(16, "0")}-${lock.nonce}.json`;
|
|
1171
|
+
const revisionPath = path.join(versionsPath, revisionName);
|
|
1172
|
+
atomicPrivateWrite(revisionPath, next, this.maxStateBytes, {
|
|
1173
|
+
beforeRename: invokeHook ? this.beforeStateRename : undefined,
|
|
1174
|
+
});
|
|
1175
|
+
const published = readPrivateState(revisionPath, this.maxStateBytes);
|
|
1176
|
+
if (stableJson(published) !== stableJson(next)) {
|
|
1177
|
+
throw new Error("native-agent state revision failed publication validation");
|
|
1178
|
+
}
|
|
1179
|
+
// The immutable revision is authoritative. A takeover that occurs during
|
|
1180
|
+
// the write has already allocated and snapshotted a higher fence, so this
|
|
1181
|
+
// late lower-fence revision can never become visible to readers.
|
|
1182
|
+
lock.assert();
|
|
1183
|
+
const lower = this.lowerRevisionPaths(next.invocationId, [next.fence, next.sequence]);
|
|
1184
|
+
if (invokeHook) {
|
|
1185
|
+
this.beforeRevisionCompaction?.({
|
|
1186
|
+
invocationId: next.invocationId,
|
|
1187
|
+
fence: next.fence,
|
|
1188
|
+
sequence: next.sequence,
|
|
1189
|
+
lock,
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
for (const obsolete of lower) fs.rmSync(obsolete, { force: true });
|
|
1193
|
+
atomicPrivateWrite(this.statePath(next.invocationId), next, this.maxStateBytes);
|
|
1194
|
+
lock.assert();
|
|
1195
|
+
lock.nextSequence = next.sequence;
|
|
1196
|
+
replaceObject(state, next);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
acquireLeaseDirectory(lockPath, {
|
|
1200
|
+
invocationId = null,
|
|
1201
|
+
leaseMs = this.lockLeaseMs,
|
|
1202
|
+
} = {}) {
|
|
1203
|
+
this.ensureRoots();
|
|
1204
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
1205
|
+
const nonce = this.randomBytes(16).subarray(0, 16).toString("hex");
|
|
1206
|
+
const generationName = `${path.basename(lockPath)}.generation-${nonce}`;
|
|
1207
|
+
const generationPath = path.join(path.dirname(lockPath), generationName);
|
|
1208
|
+
const claimPath = `${lockPath}.claim-${process.pid}-${nonce}`;
|
|
1209
|
+
let published = false;
|
|
1210
|
+
try {
|
|
1211
|
+
fs.mkdirSync(generationPath, { mode: 0o700 });
|
|
1212
|
+
let owner = {
|
|
1213
|
+
pid: process.pid,
|
|
1214
|
+
nonce,
|
|
1215
|
+
createdAt: new Date(this.now()).toISOString(),
|
|
1216
|
+
fence: null,
|
|
1217
|
+
};
|
|
1218
|
+
const ownerPath = path.join(generationPath, `owner-${nonce}.json`);
|
|
1219
|
+
fs.writeFileSync(ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: "wx" });
|
|
1220
|
+
const createdHeartbeat = new Date(this.now());
|
|
1221
|
+
fs.utimesSync(ownerPath, createdHeartbeat, createdHeartbeat);
|
|
1222
|
+
fs.writeFileSync(claimPath, `${JSON.stringify({
|
|
1223
|
+
schema: "impel.native-agent-lock-claim.v2",
|
|
1224
|
+
nonce,
|
|
1225
|
+
generation: generationName,
|
|
1226
|
+
})}\n`, { mode: 0o600, flag: "wx" });
|
|
1227
|
+
// Hard-link publication is an atomic create-if-absent operation. A
|
|
1228
|
+
// visible claim always names a fully populated immutable generation.
|
|
1229
|
+
fs.linkSync(claimPath, lockPath);
|
|
1230
|
+
published = true;
|
|
1231
|
+
fs.rmSync(claimPath, { force: true });
|
|
1232
|
+
if (invocationId && this.readDeletionTombstone(invocationId)) {
|
|
1233
|
+
throw new Error("native-agent run handle is unknown or expired");
|
|
1234
|
+
}
|
|
1235
|
+
const fence = invocationId ? this.reserveFence(invocationId, nonce) : null;
|
|
1236
|
+
owner = { ...owner, fence };
|
|
1237
|
+
atomicPrivateWrite(ownerPath, owner, 1024);
|
|
1238
|
+
const finalizedHeartbeat = new Date(this.now());
|
|
1239
|
+
fs.utimesSync(ownerPath, finalizedHeartbeat, finalizedHeartbeat);
|
|
1240
|
+
const lock = {
|
|
1241
|
+
nonce,
|
|
1242
|
+
fence,
|
|
1243
|
+
nextSequence: -1,
|
|
1244
|
+
assert: () => {
|
|
1245
|
+
if (invocationId && this.readDeletionTombstone(invocationId)) {
|
|
1246
|
+
throw new Error("native-agent run lock ownership was lost");
|
|
1247
|
+
}
|
|
1248
|
+
let current;
|
|
1249
|
+
try {
|
|
1250
|
+
current = this.readClaimedLock(lockPath);
|
|
1251
|
+
} catch {
|
|
1252
|
+
throw new Error("native-agent run lock ownership was lost");
|
|
1253
|
+
}
|
|
1254
|
+
if (current.claim.nonce !== nonce
|
|
1255
|
+
|| current.owner.nonce !== nonce
|
|
1256
|
+
|| current.owner.fence !== fence
|
|
1257
|
+
|| fs.existsSync(path.join(generationPath, `released-${nonce}`))
|
|
1258
|
+
|| this.lockIsStale(current.owner, current.stat.mtimeMs, leaseMs)) {
|
|
1259
|
+
throw new Error("native-agent run lock ownership was lost");
|
|
1260
|
+
}
|
|
1261
|
+
const heartbeat = new Date(this.now());
|
|
1262
|
+
fs.utimesSync(current.ownerPath, heartbeat, heartbeat);
|
|
1263
|
+
const verified = this.readClaimedLock(lockPath);
|
|
1264
|
+
if (verified.claim.nonce !== nonce
|
|
1265
|
+
|| verified.owner.nonce !== nonce
|
|
1266
|
+
|| verified.owner.fence !== fence) {
|
|
1267
|
+
throw new Error("native-agent run lock ownership was lost");
|
|
1268
|
+
}
|
|
1269
|
+
},
|
|
1270
|
+
release: () => {
|
|
1271
|
+
const marker = path.join(generationPath, `released-${nonce}`);
|
|
1272
|
+
try {
|
|
1273
|
+
fs.writeFileSync(marker, `${nonce}\n`, { mode: 0o600, flag: "wx" });
|
|
1274
|
+
} catch {
|
|
1275
|
+
// The marker is owner-specific. If takeover already moved this
|
|
1276
|
+
// generation, a delayed release cannot affect its successor.
|
|
1277
|
+
}
|
|
1278
|
+
},
|
|
1279
|
+
};
|
|
1280
|
+
lock.assert();
|
|
1281
|
+
if (invocationId) {
|
|
1282
|
+
const prior = this.readLatestState(invocationId, { allowMissing: true });
|
|
1283
|
+
if (prior) this.writeRevision(prior, lock, { invokeHook: false });
|
|
1284
|
+
}
|
|
1285
|
+
return lock;
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
fs.rmSync(claimPath, { force: true });
|
|
1288
|
+
if (error?.code !== "EEXIST") {
|
|
1289
|
+
if (published) {
|
|
1290
|
+
try {
|
|
1291
|
+
fs.writeFileSync(path.join(generationPath, `released-${nonce}`), `${nonce}\n`, {
|
|
1292
|
+
mode: 0o600,
|
|
1293
|
+
flag: "wx",
|
|
1294
|
+
});
|
|
1295
|
+
} catch {
|
|
1296
|
+
// The published incomplete generation remains safely fenced.
|
|
1297
|
+
}
|
|
1298
|
+
} else {
|
|
1299
|
+
try {
|
|
1300
|
+
fs.rmSync(generationPath, { recursive: true, force: true });
|
|
1301
|
+
} catch {
|
|
1302
|
+
// Preserve the original construction failure.
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
throw error;
|
|
1306
|
+
}
|
|
1307
|
+
fs.rmSync(generationPath, { recursive: true, force: true });
|
|
1308
|
+
const stat = fs.lstatSync(lockPath);
|
|
1309
|
+
if (stat.isSymbolicLink()) throw new Error("refusing to use symlinked native-agent run lock");
|
|
1310
|
+
let current = null;
|
|
1311
|
+
let legacy = null;
|
|
1312
|
+
if (stat.isFile()) {
|
|
1313
|
+
try {
|
|
1314
|
+
current = this.readClaimedLock(lockPath);
|
|
1315
|
+
} catch (claimError) {
|
|
1316
|
+
try {
|
|
1317
|
+
legacy = this.readLegacyLockOwner(lockPath);
|
|
1318
|
+
} catch {
|
|
1319
|
+
if (this.now() - stat.mtimeMs <= leaseMs) throw claimError;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
const released = current
|
|
1323
|
+
&& fs.existsSync(path.join(current.generationPath, `released-${current.owner.nonce}`));
|
|
1324
|
+
if (current && !released && !this.lockIsStale(current.owner, current.stat.mtimeMs, leaseMs)) return null;
|
|
1325
|
+
if (legacy && !this.lockIsStale(legacy.owner, legacy.stat.mtimeMs, leaseMs)) return null;
|
|
1326
|
+
} else if (!stat.isDirectory()) {
|
|
1327
|
+
throw new Error("native-agent run lock is unsafe or invalid");
|
|
1328
|
+
} else if (this.now() - stat.mtimeMs <= leaseMs) {
|
|
1329
|
+
// A pre-v2 interrupted directory is busy for one full lease, but it
|
|
1330
|
+
// is never accepted as a valid owner generation.
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
const retiredPath = `${lockPath}.retired-${process.pid}-${nonce}`;
|
|
1334
|
+
try {
|
|
1335
|
+
fs.renameSync(lockPath, retiredPath);
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
if (error?.code === "ENOENT" || error?.code === "EEXIST") continue;
|
|
1338
|
+
throw error;
|
|
1339
|
+
}
|
|
1340
|
+
let restored = false;
|
|
1341
|
+
try {
|
|
1342
|
+
if (current) {
|
|
1343
|
+
try {
|
|
1344
|
+
const retired = this.readClaimedLock(retiredPath);
|
|
1345
|
+
const released = fs.existsSync(path.join(retired.generationPath, `released-${retired.owner.nonce}`));
|
|
1346
|
+
if (!released && !this.lockIsStale(retired.owner, retired.stat.mtimeMs, leaseMs)) {
|
|
1347
|
+
try {
|
|
1348
|
+
fs.linkSync(retiredPath, lockPath);
|
|
1349
|
+
restored = true;
|
|
1350
|
+
} catch {
|
|
1351
|
+
// Another contender won; fencing makes the retired owner safe.
|
|
1352
|
+
}
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
} catch {
|
|
1356
|
+
// The claim was replaced between inspection and retirement.
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
} finally {
|
|
1360
|
+
fs.rmSync(retiredPath, { recursive: true, force: true });
|
|
1361
|
+
if (current && !restored) {
|
|
1362
|
+
fs.rmSync(current.generationPath, { recursive: true, force: true });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return null;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
prune(now = this.now(), { reserve = 0, guard = null } = {}) {
|
|
1371
|
+
this.ensureRoots();
|
|
1372
|
+
const retained = [];
|
|
1373
|
+
for (const name of fs.readdirSync(this.tenantRoot)) {
|
|
1374
|
+
guard?.();
|
|
1375
|
+
if (!name.endsWith(".json")) continue;
|
|
1376
|
+
const candidate = path.join(this.tenantRoot, name);
|
|
1377
|
+
const stat = fs.lstatSync(candidate);
|
|
1378
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1379
|
+
throw new Error("refusing to retain unsafe native-agent run state");
|
|
1380
|
+
}
|
|
1381
|
+
const invocationId = path.basename(candidate, ".json");
|
|
1382
|
+
const state = this.readLatestState(invocationId, { allowMissing: true });
|
|
1383
|
+
if (!state) {
|
|
1384
|
+
// A tombstone is the durable deletion commit point. Interrupted
|
|
1385
|
+
// physical cleanup and delayed stale files do not consume capacity.
|
|
1386
|
+
this.cleanupTombstonedState(invocationId);
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
if (state.invocationId !== invocationId) {
|
|
1390
|
+
throw new Error("native-agent run state failed integrity validation");
|
|
1391
|
+
}
|
|
1392
|
+
retained.push({
|
|
1393
|
+
candidate,
|
|
1394
|
+
invocationId,
|
|
1395
|
+
terminal: TERMINAL_STATUSES.has(state.status),
|
|
1396
|
+
updatedAt: Date.parse(state.updatedAt),
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
const terminalOldestFirst = retained
|
|
1400
|
+
.filter((item) => item.terminal)
|
|
1401
|
+
.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
1402
|
+
const removed = new Set();
|
|
1403
|
+
const removeTerminal = (item) => {
|
|
1404
|
+
guard?.();
|
|
1405
|
+
const lock = this.acquireLock(item.invocationId);
|
|
1406
|
+
if (!lock) return false;
|
|
1407
|
+
try {
|
|
1408
|
+
const current = this.readState(item.invocationId, lock);
|
|
1409
|
+
if (!TERMINAL_STATUSES.has(current.status)) return false;
|
|
1410
|
+
this.beforePruneDelete?.({ invocationId: item.invocationId, lock });
|
|
1411
|
+
this.writeDeletionTombstone(item.invocationId, lock);
|
|
1412
|
+
this.cleanupTombstonedState(item.invocationId);
|
|
1413
|
+
} finally {
|
|
1414
|
+
lock.release();
|
|
1415
|
+
}
|
|
1416
|
+
removed.add(item.candidate);
|
|
1417
|
+
return true;
|
|
1418
|
+
};
|
|
1419
|
+
for (const item of terminalOldestFirst) {
|
|
1420
|
+
if (now - item.updatedAt > this.retentionMs) removeTerminal(item);
|
|
1421
|
+
}
|
|
1422
|
+
let retainedCount = retained.length - removed.size;
|
|
1423
|
+
for (const item of terminalOldestFirst) {
|
|
1424
|
+
if (retainedCount + reserve <= this.maxRetainedRuns) break;
|
|
1425
|
+
if (removed.has(item.candidate)) continue;
|
|
1426
|
+
if (removeTerminal(item)) retainedCount -= 1;
|
|
1427
|
+
}
|
|
1428
|
+
if (retainedCount + reserve > this.maxRetainedRuns) {
|
|
1429
|
+
throw new Error("native-agent run retention is full of resumable runs");
|
|
1430
|
+
}
|
|
1431
|
+
return retainedCount;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
acquireLock(invocationId) {
|
|
1435
|
+
if (this.readDeletionTombstone(invocationId)) {
|
|
1436
|
+
throw new Error("native-agent run handle is unknown or expired");
|
|
1437
|
+
}
|
|
1438
|
+
return this.acquireLeaseDirectory(this.lockPath(invocationId), { invocationId });
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
acquireAdmissionLock() {
|
|
1442
|
+
return this.acquireLeaseDirectory(this.admissionLockPath(), {
|
|
1443
|
+
leaseMs: Math.max(DEFAULT_ADMISSION_LEASE_MS, this.lockLeaseMs),
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
persist(state, lock = null) {
|
|
1448
|
+
if (lock) {
|
|
1449
|
+
lock.assert();
|
|
1450
|
+
this.writeRevision(state, lock);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
const initial = structuredClone(state);
|
|
1454
|
+
if (this.readDeletionTombstone(initial.invocationId)
|
|
1455
|
+
|| fs.existsSync(this.statePath(initial.invocationId))
|
|
1456
|
+
|| fs.existsSync(this.stateVersionsPath(initial.invocationId))
|
|
1457
|
+
|| fs.existsSync(this.fenceVersionsPath(initial.invocationId))
|
|
1458
|
+
|| fs.existsSync(this.fencePath(initial.invocationId))) {
|
|
1459
|
+
throw new Error("native-agent invocation id already exists");
|
|
1460
|
+
}
|
|
1461
|
+
initial.fence = 0;
|
|
1462
|
+
initial.sequence = 0;
|
|
1463
|
+
atomicPrivateWrite(this.statePath(initial.invocationId), initial, this.maxStateBytes);
|
|
1464
|
+
replaceObject(state, initial);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
readState(invocationId, lock = null) {
|
|
1468
|
+
try {
|
|
1469
|
+
lock?.assert();
|
|
1470
|
+
return this.readLatestState(invocationId);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
if (error?.code === "ENOENT") throw new Error("native-agent run handle is unknown or expired");
|
|
1473
|
+
throw error;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
validateBoundState(state, handle) {
|
|
1478
|
+
const expected = {
|
|
1479
|
+
tenantId: this.tenantId,
|
|
1480
|
+
agentId: this.agentId,
|
|
1481
|
+
scopeParam: this.scopeParam,
|
|
1482
|
+
};
|
|
1483
|
+
for (const [field, value] of Object.entries(expected)) {
|
|
1484
|
+
if (state[field] !== value || handle[field] !== value) {
|
|
1485
|
+
throw new Error("native-agent run handle does not match this fixed binding");
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
if (stableJson(handleForState(state)) !== stableJson(handle)) {
|
|
1489
|
+
throw new Error("native-agent run handle was altered");
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
async prepareExistingSession(signal) {
|
|
1494
|
+
const session = await this.sessionFactory({ signal });
|
|
1495
|
+
const tools = await session.initialize();
|
|
1496
|
+
return { session, waitSeconds: negotiateNativeAgentWaitSeconds(tools) };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
async prepareNewSession(signal) {
|
|
1500
|
+
const prepared = await this.prepareExistingSession(signal);
|
|
1501
|
+
const catalog = normalizeNativeAgentCatalog(
|
|
1502
|
+
await prepared.session.call(NATIVE_AGENT_LIST_TOOL, {}),
|
|
1503
|
+
this.tenantId,
|
|
1504
|
+
);
|
|
1505
|
+
const matches = catalog.agents.filter((agent) =>
|
|
1506
|
+
agent.agentId === this.agentId && agent.scopeParam === this.scopeParam
|
|
1507
|
+
);
|
|
1508
|
+
if (matches.length !== 1) throw new Error("exact native-agent binding is unavailable");
|
|
1509
|
+
if (nativeAgentPolicyFingerprint(matches[0]) !== this.policyFingerprint) {
|
|
1510
|
+
throw new Error("native-agent catalog policy fingerprint changed; synchronize agents again");
|
|
1511
|
+
}
|
|
1512
|
+
return { ...prepared, agent: matches[0] };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
async preparePersistedStartSession(signal, state) {
|
|
1516
|
+
const prepared = await this.prepareExistingSession(signal);
|
|
1517
|
+
const catalog = normalizeNativeAgentCatalog(
|
|
1518
|
+
await prepared.session.call(NATIVE_AGENT_LIST_TOOL, {}),
|
|
1519
|
+
this.tenantId,
|
|
1520
|
+
);
|
|
1521
|
+
const matches = catalog.agents.filter((agent) =>
|
|
1522
|
+
agent.agentId === state.agentId && agent.scopeParam === state.scopeParam
|
|
1523
|
+
);
|
|
1524
|
+
if (matches.length !== 1) {
|
|
1525
|
+
const error = new Error("exact native-agent binding is unavailable; the persisted start was not retried");
|
|
1526
|
+
error.code = "LOCAL_POLICY_CHANGED_BEFORE_START";
|
|
1527
|
+
throw error;
|
|
1528
|
+
}
|
|
1529
|
+
if (nativeAgentPolicyFingerprint(matches[0]) !== state.policyFingerprint) {
|
|
1530
|
+
const error = new Error("native-agent catalog policy fingerprint changed; the persisted start was not retried");
|
|
1531
|
+
error.code = "LOCAL_POLICY_CHANGED_BEFORE_START";
|
|
1532
|
+
throw error;
|
|
1533
|
+
}
|
|
1534
|
+
return prepared;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
normalizeRunArguments(args, agent) {
|
|
1538
|
+
exactObject(args, ["task", "context", "contextKeys"], "run_native_agent arguments");
|
|
1539
|
+
const task = boundedString(args.task, "task", 40_000);
|
|
1540
|
+
const keys = contextKeys(args.contextKeys);
|
|
1541
|
+
const missing = agent.requiredContext.filter((key) => !keys.includes(key));
|
|
1542
|
+
if (missing.length) throw new Error(`missing required context: ${missing.join(", ")}`);
|
|
1543
|
+
if (args.context !== undefined) boundedString(args.context, "context", 40_000, { nonblank: false });
|
|
1544
|
+
if (agent.requiredContext.length && (typeof args.context !== "string" || !args.context.trim())) {
|
|
1545
|
+
throw new Error("nonblank context is required for this native agent");
|
|
1546
|
+
}
|
|
1547
|
+
return {
|
|
1548
|
+
agentId: this.agentId,
|
|
1549
|
+
scopeParam: this.scopeParam,
|
|
1550
|
+
task,
|
|
1551
|
+
...(args.context !== undefined ? { context: args.context } : {}),
|
|
1552
|
+
contextKeys: keys,
|
|
1553
|
+
...(agent.sideEffects === "writes" ? { confirmedSideEffects: true } : {}),
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async run(args, { signal, onProgress } = {}) {
|
|
1558
|
+
throwIfAborted(signal);
|
|
1559
|
+
this.ensureRoots();
|
|
1560
|
+
const attachment = boundedAttachmentSignal(signal, this.attachmentWindowMs);
|
|
1561
|
+
let state = null;
|
|
1562
|
+
let lock = null;
|
|
1563
|
+
let admission = null;
|
|
1564
|
+
try {
|
|
1565
|
+
const prepared = await this.prepareNewSession(attachment.signal);
|
|
1566
|
+
const startArguments = this.normalizeRunArguments(args, prepared.agent);
|
|
1567
|
+
admission = this.acquireAdmissionLock();
|
|
1568
|
+
if (!admission) throw new Error("native-agent run allocation is busy; retry without changing the request");
|
|
1569
|
+
this.prune(this.now(), { reserve: 1, guard: () => admission.assert() });
|
|
1570
|
+
admission.assert();
|
|
1571
|
+
const invocationId = this.randomUUID();
|
|
1572
|
+
const idempotencyKey = `impel-native-${this.randomBytes(24).toString("hex")}`;
|
|
1573
|
+
startArguments.idempotencyKey = idempotencyKey;
|
|
1574
|
+
const requestFingerprint = requestFingerprintFor({
|
|
1575
|
+
tenantId: this.tenantId,
|
|
1576
|
+
agentId: this.agentId,
|
|
1577
|
+
scopeParam: this.scopeParam,
|
|
1578
|
+
policyFingerprint: this.policyFingerprint,
|
|
1579
|
+
startArguments,
|
|
1580
|
+
});
|
|
1581
|
+
const timestamp = new Date(this.now()).toISOString();
|
|
1582
|
+
state = {
|
|
1583
|
+
schema: STATE_SCHEMA,
|
|
1584
|
+
invocationId,
|
|
1585
|
+
tenantId: this.tenantId,
|
|
1586
|
+
agentId: this.agentId,
|
|
1587
|
+
scopeParam: this.scopeParam,
|
|
1588
|
+
policyFingerprint: this.policyFingerprint,
|
|
1589
|
+
requestFingerprint,
|
|
1590
|
+
idempotencyKey,
|
|
1591
|
+
startArguments,
|
|
1592
|
+
upstreamRunId: null,
|
|
1593
|
+
status: "starting",
|
|
1594
|
+
startAttempts: 0,
|
|
1595
|
+
createdAt: timestamp,
|
|
1596
|
+
updatedAt: timestamp,
|
|
1597
|
+
};
|
|
1598
|
+
// The durable identity and exact retry arguments exist before the first
|
|
1599
|
+
// upstream byte is sent.
|
|
1600
|
+
this.persist(state);
|
|
1601
|
+
admission.release();
|
|
1602
|
+
admission = null;
|
|
1603
|
+
lock = this.acquireLock(invocationId);
|
|
1604
|
+
if (!lock) return handleForState(state);
|
|
1605
|
+
return await this.attach(state, prepared, {
|
|
1606
|
+
signal: attachment.signal,
|
|
1607
|
+
onProgress,
|
|
1608
|
+
lock,
|
|
1609
|
+
});
|
|
1610
|
+
} catch (error) {
|
|
1611
|
+
// Once state exists, cancellation or the local attachment deadline must
|
|
1612
|
+
// hand the caller the only handle that can resume it.
|
|
1613
|
+
if (error?.name === "AbortError" && state) return handleForState(state);
|
|
1614
|
+
if (error?.name === "AbortError" && attachment.timedOut()) {
|
|
1615
|
+
throw new Error("native-agent attachment window elapsed before a resumable run was created");
|
|
1616
|
+
}
|
|
1617
|
+
throw error;
|
|
1618
|
+
} finally {
|
|
1619
|
+
admission?.release();
|
|
1620
|
+
lock?.release();
|
|
1621
|
+
attachment.dispose();
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
async resume(args, { signal, onProgress } = {}) {
|
|
1626
|
+
exactObject(args, ["handle"], "resume_native_agent_run arguments");
|
|
1627
|
+
if (!Object.hasOwn(args, "handle")) throw new Error("resume_native_agent_run requires a handle");
|
|
1628
|
+
const handle = normalizeHandle(args.handle);
|
|
1629
|
+
if (handle.tenantId !== this.tenantId) {
|
|
1630
|
+
throw new Error("native-agent run handle does not match this fixed tenant");
|
|
1631
|
+
}
|
|
1632
|
+
this.ensureRoots();
|
|
1633
|
+
throwIfAborted(signal);
|
|
1634
|
+
const attachment = boundedAttachmentSignal(signal, this.attachmentWindowMs);
|
|
1635
|
+
let lock = null;
|
|
1636
|
+
let state = null;
|
|
1637
|
+
let validated = false;
|
|
1638
|
+
try {
|
|
1639
|
+
lock = this.acquireLock(handle.invocationId);
|
|
1640
|
+
if (!lock) return handle;
|
|
1641
|
+
// The lock must precede the authoritative read. Another attachment may
|
|
1642
|
+
// have completed the run after the caller obtained its stale snapshot.
|
|
1643
|
+
state = this.readState(handle.invocationId, lock);
|
|
1644
|
+
this.validateBoundState(state, handle);
|
|
1645
|
+
validated = true;
|
|
1646
|
+
if (state.status === "succeeded"
|
|
1647
|
+
&& (!state.result
|
|
1648
|
+
|| typeof state.result !== "object"
|
|
1649
|
+
|| Array.isArray(state.result)
|
|
1650
|
+
|| typeof state.result.finalText !== "string")) {
|
|
1651
|
+
this.compactTerminalFailure(
|
|
1652
|
+
state,
|
|
1653
|
+
state,
|
|
1654
|
+
"LOCAL_MALFORMED_TERMINAL_RESULT",
|
|
1655
|
+
"The native-agent success payload did not contain a string finalText.",
|
|
1656
|
+
lock,
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
const terminal = resultForState(state);
|
|
1660
|
+
if (terminal.schema === NATIVE_AGENT_RESULT_SCHEMA) return terminal;
|
|
1661
|
+
const prepared = state.upstreamRunId
|
|
1662
|
+
? await this.prepareExistingSession(attachment.signal)
|
|
1663
|
+
: await this.preparePersistedStartSession(attachment.signal, state);
|
|
1664
|
+
return await this.attach(state, prepared, {
|
|
1665
|
+
signal: attachment.signal,
|
|
1666
|
+
onProgress,
|
|
1667
|
+
lock,
|
|
1668
|
+
});
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
if (error?.name === "AbortError") return handle;
|
|
1671
|
+
if (validated && !state?.upstreamRunId && error?.code === "LOCAL_POLICY_CHANGED_BEFORE_START") {
|
|
1672
|
+
this.compactTerminalFailure(
|
|
1673
|
+
state,
|
|
1674
|
+
state,
|
|
1675
|
+
error.code,
|
|
1676
|
+
error.message,
|
|
1677
|
+
lock,
|
|
1678
|
+
);
|
|
1679
|
+
return resultForState(state);
|
|
1680
|
+
}
|
|
1681
|
+
if (validated && state?.upstreamRunId) {
|
|
1682
|
+
try {
|
|
1683
|
+
this.persistDiagnostic(state, error, lock);
|
|
1684
|
+
} catch {
|
|
1685
|
+
// Never write after losing ownership of the bounded lease.
|
|
1686
|
+
}
|
|
1687
|
+
return handle;
|
|
1688
|
+
}
|
|
1689
|
+
throw error;
|
|
1690
|
+
} finally {
|
|
1691
|
+
lock?.release();
|
|
1692
|
+
attachment.dispose();
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
recover(args = {}) {
|
|
1697
|
+
exactObject(args, [], "recover_native_agent_runs arguments");
|
|
1698
|
+
this.ensureRoots();
|
|
1699
|
+
const states = [];
|
|
1700
|
+
for (const name of fs.readdirSync(this.tenantRoot)) {
|
|
1701
|
+
if (!name.endsWith(".json")) continue;
|
|
1702
|
+
let state;
|
|
1703
|
+
try {
|
|
1704
|
+
const invocationId = path.basename(name, ".json");
|
|
1705
|
+
state = this.readLatestState(invocationId);
|
|
1706
|
+
if (state.invocationId !== invocationId) continue;
|
|
1707
|
+
} catch {
|
|
1708
|
+
// Recovery never returns an unvalidated or partially written handle.
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
if (state.tenantId !== this.tenantId
|
|
1712
|
+
|| state.agentId !== this.agentId
|
|
1713
|
+
|| state.scopeParam !== this.scopeParam
|
|
1714
|
+
|| TERMINAL_STATUSES.has(state.status)) {
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
states.push(state);
|
|
1718
|
+
}
|
|
1719
|
+
states.sort((left, right) => {
|
|
1720
|
+
const byTime = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
|
|
1721
|
+
return byTime || left.invocationId.localeCompare(right.invocationId);
|
|
1722
|
+
});
|
|
1723
|
+
return {
|
|
1724
|
+
schema: NATIVE_AGENT_RECOVERY_SCHEMA,
|
|
1725
|
+
handles: states.slice(0, this.maxRecoveryResults).map(handleForState),
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
persistDiagnostic(state, error, lock) {
|
|
1730
|
+
const next = structuredClone(state);
|
|
1731
|
+
next.lastTransportError = diagnosticFor(error, state.idempotencyKey);
|
|
1732
|
+
next.updatedAt = new Date(this.now()).toISOString();
|
|
1733
|
+
try {
|
|
1734
|
+
this.persist(next, lock);
|
|
1735
|
+
} catch (persistError) {
|
|
1736
|
+
if (!(persistError instanceof NativeAgentStateSizeError)) throw persistError;
|
|
1737
|
+
// A near-limit nonterminal snapshot may sacrifice partial output so the
|
|
1738
|
+
// durable identity and the actionable local diagnostic remain readable.
|
|
1739
|
+
delete next.result;
|
|
1740
|
+
delete next.output;
|
|
1741
|
+
if (!TERMINAL_STATUSES.has(next.status)) delete next.error;
|
|
1742
|
+
next.lastTransportError = {
|
|
1743
|
+
message: String(next.lastTransportError?.message || "native-agent attachment failed").slice(0, 1_000),
|
|
1744
|
+
};
|
|
1745
|
+
this.persist(next, lock);
|
|
1746
|
+
}
|
|
1747
|
+
replaceObject(state, next);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
compactTerminalFailure(state, next, code, message, lock) {
|
|
1751
|
+
const compact = structuredClone(state);
|
|
1752
|
+
if (next.upstreamRunId) compact.upstreamRunId = next.upstreamRunId;
|
|
1753
|
+
compact.status = "failed";
|
|
1754
|
+
delete compact.result;
|
|
1755
|
+
delete compact.output;
|
|
1756
|
+
compact.error = { code, message };
|
|
1757
|
+
compact.lastTransportError = { message };
|
|
1758
|
+
compact.updatedAt = new Date(this.now()).toISOString();
|
|
1759
|
+
this.persist(compact, lock);
|
|
1760
|
+
replaceObject(state, compact);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
commitPayload(state, payload, lock) {
|
|
1764
|
+
const next = structuredClone(state);
|
|
1765
|
+
applyUpstreamPayload(next, payload, this.now());
|
|
1766
|
+
delete next.lastTransportError;
|
|
1767
|
+
if (next.status === "succeeded"
|
|
1768
|
+
&& (!next.result
|
|
1769
|
+
|| typeof next.result !== "object"
|
|
1770
|
+
|| Array.isArray(next.result)
|
|
1771
|
+
|| typeof next.result.finalText !== "string")) {
|
|
1772
|
+
this.compactTerminalFailure(
|
|
1773
|
+
state,
|
|
1774
|
+
next,
|
|
1775
|
+
"LOCAL_MALFORMED_TERMINAL_RESULT",
|
|
1776
|
+
"The native-agent success payload did not contain a string finalText.",
|
|
1777
|
+
lock,
|
|
1778
|
+
);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
try {
|
|
1782
|
+
this.persist(next, lock);
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
if (!TERMINAL_STATUSES.has(next.status)) throw error;
|
|
1785
|
+
const oversized = error instanceof NativeAgentStateSizeError;
|
|
1786
|
+
this.compactTerminalFailure(
|
|
1787
|
+
state,
|
|
1788
|
+
next,
|
|
1789
|
+
oversized ? "LOCAL_RESULT_TOO_LARGE" : "LOCAL_TERMINAL_PERSISTENCE_REJECTED",
|
|
1790
|
+
oversized
|
|
1791
|
+
? "The terminal native-agent result exceeded the local durable-state limit."
|
|
1792
|
+
: "The terminal native-agent result was rejected by local persistence safety checks.",
|
|
1793
|
+
lock,
|
|
1794
|
+
);
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
replaceObject(state, next);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
persistStartedIdentity(state, payload, lock) {
|
|
1801
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1802
|
+
throw new Error("native-agent upstream returned an invalid run");
|
|
1803
|
+
}
|
|
1804
|
+
const runId = boundedString(payload.runId, "native-agent runId", 512);
|
|
1805
|
+
const next = structuredClone(state);
|
|
1806
|
+
next.upstreamRunId = runId;
|
|
1807
|
+
next.status = typeof payload.status === "string"
|
|
1808
|
+
&& payload.status.length <= 160
|
|
1809
|
+
&& payload.status
|
|
1810
|
+
&& !TERMINAL_STATUSES.has(payload.status)
|
|
1811
|
+
? payload.status
|
|
1812
|
+
: "queued";
|
|
1813
|
+
next.updatedAt = new Date(this.now()).toISOString();
|
|
1814
|
+
delete next.lastTransportError;
|
|
1815
|
+
this.persist(next, lock);
|
|
1816
|
+
replaceObject(state, next);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
async attach(state, { session, waitSeconds }, { signal, onProgress, lock } = {}) {
|
|
1820
|
+
const deadline = this.now() + this.attachmentWindowMs;
|
|
1821
|
+
try {
|
|
1822
|
+
throwIfAborted(signal);
|
|
1823
|
+
if (!state.upstreamRunId) {
|
|
1824
|
+
let started = null;
|
|
1825
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1826
|
+
state.startAttempts += 1;
|
|
1827
|
+
state.updatedAt = new Date(this.now()).toISOString();
|
|
1828
|
+
this.persist(state, lock);
|
|
1829
|
+
try {
|
|
1830
|
+
started = await session.call(NATIVE_AGENT_START_TOOL, state.startArguments);
|
|
1831
|
+
break;
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
if (error?.name === "AbortError") throw error;
|
|
1834
|
+
if (!error?.ambiguous) {
|
|
1835
|
+
const diagnostic = diagnosticFor(error, state.idempotencyKey);
|
|
1836
|
+
throw new NativeAgentUpstreamError(String(diagnostic.message), {
|
|
1837
|
+
code: diagnostic.code,
|
|
1838
|
+
data: diagnostic.data,
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
this.persistDiagnostic(state, error, lock);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
if (!started) return handleForState(state);
|
|
1845
|
+
|
|
1846
|
+
// Persist the upstream identity before accepting any optional or large
|
|
1847
|
+
// response fields. From this point every attachment failure is safely
|
|
1848
|
+
// recoverable without another start call.
|
|
1849
|
+
this.persistStartedIdentity(state, started, lock);
|
|
1850
|
+
this.commitPayload(state, started, lock);
|
|
1851
|
+
onProgress?.({ status: state.status, runId: state.upstreamRunId });
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
let result = resultForState(state);
|
|
1855
|
+
if (result.schema === NATIVE_AGENT_RESULT_SCHEMA) return result;
|
|
1856
|
+
for (let poll = 0; poll < this.maxPolls; poll += 1) {
|
|
1857
|
+
throwIfAborted(signal);
|
|
1858
|
+
const remainingMs = deadline - this.now();
|
|
1859
|
+
if (remainingMs <= 1_500) return handleForState(state);
|
|
1860
|
+
const boundedWait = Math.max(1, Math.min(waitSeconds, Math.floor((remainingMs - 1_000) / 1000)));
|
|
1861
|
+
const observed = await session.call(NATIVE_AGENT_READ_TOOL, {
|
|
1862
|
+
runId: state.upstreamRunId,
|
|
1863
|
+
waitSeconds: boundedWait,
|
|
1864
|
+
});
|
|
1865
|
+
this.commitPayload(state, observed, lock);
|
|
1866
|
+
onProgress?.({ status: state.status, runId: state.upstreamRunId, poll: poll + 1 });
|
|
1867
|
+
result = resultForState(state);
|
|
1868
|
+
if (result.schema === NATIVE_AGENT_RESULT_SCHEMA) return result;
|
|
1869
|
+
}
|
|
1870
|
+
return handleForState(state);
|
|
1871
|
+
} catch (error) {
|
|
1872
|
+
if (!state.upstreamRunId) throw error;
|
|
1873
|
+
try {
|
|
1874
|
+
this.persistDiagnostic(state, error, lock);
|
|
1875
|
+
} catch {
|
|
1876
|
+
// A lost lease forbids stale writes. The already-persisted handle is
|
|
1877
|
+
// still the only safe return value.
|
|
1878
|
+
}
|
|
1879
|
+
return handleForState(state);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
const HANDLE_SCHEMA = {
|
|
1885
|
+
type: "object",
|
|
1886
|
+
additionalProperties: false,
|
|
1887
|
+
required: [
|
|
1888
|
+
"schema",
|
|
1889
|
+
"tenantId",
|
|
1890
|
+
"agentId",
|
|
1891
|
+
"scopeParam",
|
|
1892
|
+
"policyFingerprint",
|
|
1893
|
+
"invocationId",
|
|
1894
|
+
"requestFingerprint",
|
|
1895
|
+
],
|
|
1896
|
+
properties: {
|
|
1897
|
+
schema: { type: "string", const: NATIVE_AGENT_HANDLE_SCHEMA },
|
|
1898
|
+
tenantId: { type: "string" },
|
|
1899
|
+
agentId: { type: "string" },
|
|
1900
|
+
scopeParam: { type: "string" },
|
|
1901
|
+
policyFingerprint: { type: "string", pattern: "^[a-f0-9]{64}$" },
|
|
1902
|
+
invocationId: { type: "string" },
|
|
1903
|
+
requestFingerprint: { type: "string", pattern: "^[a-f0-9]{64}$" },
|
|
1904
|
+
},
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
export function nativeAgentCompositeTools({ recoveryOnly = false } = {}) {
|
|
1908
|
+
const tools = [
|
|
1909
|
+
{
|
|
1910
|
+
name: NATIVE_AGENT_RUN_TOOL,
|
|
1911
|
+
description: "Start the one native agent fixed by this MCP server and remain attached for a bounded interval.",
|
|
1912
|
+
inputSchema: {
|
|
1913
|
+
type: "object",
|
|
1914
|
+
additionalProperties: false,
|
|
1915
|
+
required: ["task"],
|
|
1916
|
+
properties: {
|
|
1917
|
+
task: { type: "string", minLength: 1, maxLength: 40_000 },
|
|
1918
|
+
context: { type: "string", maxLength: 40_000 },
|
|
1919
|
+
contextKeys: {
|
|
1920
|
+
type: "array",
|
|
1921
|
+
maxItems: 30,
|
|
1922
|
+
uniqueItems: true,
|
|
1923
|
+
items: { type: "string", minLength: 1, maxLength: 160 },
|
|
1924
|
+
},
|
|
1925
|
+
},
|
|
1926
|
+
},
|
|
1927
|
+
},
|
|
1928
|
+
{
|
|
1929
|
+
name: NATIVE_AGENT_RESUME_TOOL,
|
|
1930
|
+
description: "Resume only the durable native-agent invocation identified by the supplied typed handle.",
|
|
1931
|
+
inputSchema: {
|
|
1932
|
+
type: "object",
|
|
1933
|
+
additionalProperties: false,
|
|
1934
|
+
required: ["handle"],
|
|
1935
|
+
properties: { handle: HANDLE_SCHEMA },
|
|
1936
|
+
},
|
|
1937
|
+
},
|
|
1938
|
+
{
|
|
1939
|
+
name: NATIVE_AGENT_RECOVER_TOOL,
|
|
1940
|
+
description: "Recover bounded, integrity-validated pending handles for this MCP server's fixed native-agent binding.",
|
|
1941
|
+
inputSchema: {
|
|
1942
|
+
type: "object",
|
|
1943
|
+
additionalProperties: false,
|
|
1944
|
+
properties: {},
|
|
1945
|
+
},
|
|
1946
|
+
},
|
|
1947
|
+
];
|
|
1948
|
+
return recoveryOnly ? tools.filter(({ name }) => name !== NATIVE_AGENT_RUN_TOOL) : tools;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
export function nativeAgentToolCallResult(value) {
|
|
1952
|
+
if (value?.schema === NATIVE_AGENT_RESULT_SCHEMA && value.status === "succeeded") {
|
|
1953
|
+
return {
|
|
1954
|
+
content: [{ type: "text", text: value.finalText }],
|
|
1955
|
+
structuredContent: value,
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
return {
|
|
1959
|
+
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
1960
|
+
structuredContent: value,
|
|
1961
|
+
};
|
|
1962
|
+
}
|