@relayflows/sdk 2.0.37 → 2.0.39
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/dist/agent-reaper.d.ts +1 -1
- package/dist/agent-reaper.d.ts.map +1 -1
- package/dist/agent-reaper.js +6 -4
- package/dist/agent-reaper.js.map +1 -1
- package/dist/authored-budget.d.ts +26 -1
- package/dist/authored-budget.d.ts.map +1 -1
- package/dist/authored-budget.js +72 -2
- package/dist/authored-budget.js.map +1 -1
- package/dist/authored-flow-error.d.ts +7 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +3 -2
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +141 -6
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-worker-step.js +1 -0
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/child-stop.d.ts +3 -1
- package/dist/child-stop.d.ts.map +1 -1
- package/dist/child-stop.js +42 -2
- package/dist/child-stop.js.map +1 -1
- package/dist/cli/add-extension.js +1 -1
- package/dist/cli/add-extension.js.map +1 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +25 -7
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cli-probe.d.ts +1 -0
- package/dist/cli/cli-probe.d.ts.map +1 -1
- package/dist/cli/cli-probe.js +72 -36
- package/dist/cli/cli-probe.js.map +1 -1
- package/dist/cli/cloud-deploy.d.ts +9 -1
- package/dist/cli/cloud-deploy.d.ts.map +1 -1
- package/dist/cli/cloud-deploy.js +24 -5
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/cloud-versions.d.ts +22 -0
- package/dist/cli/cloud-versions.d.ts.map +1 -0
- package/dist/cli/cloud-versions.js +96 -0
- package/dist/cli/cloud-versions.js.map +1 -0
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +4 -1
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/pinned-cli-alias.d.ts +13 -0
- package/dist/cli/pinned-cli-alias.d.ts.map +1 -0
- package/dist/cli/pinned-cli-alias.js +58 -0
- package/dist/cli/pinned-cli-alias.js.map +1 -0
- package/dist/cli/run.d.ts +8 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +91 -8
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/step-failure.d.ts.map +1 -1
- package/dist/cli/step-failure.js +18 -0
- package/dist/cli/step-failure.js.map +1 -1
- package/dist/cli-commands.d.ts +27 -0
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +18 -0
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +17 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +17 -0
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +8 -1
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +9 -2
- package/dist/cloud-run.js.map +1 -1
- package/dist/cloud-versions-wire.d.ts +25 -0
- package/dist/cloud-versions-wire.d.ts.map +1 -0
- package/dist/cloud-versions-wire.js +39 -0
- package/dist/cloud-versions-wire.js.map +1 -0
- package/dist/cloud-versions.d.ts +47 -0
- package/dist/cloud-versions.d.ts.map +1 -0
- package/dist/cloud-versions.js +131 -0
- package/dist/cloud-versions.js.map +1 -0
- package/dist/communication/worker.d.ts.map +1 -1
- package/dist/communication/worker.js +24 -6
- package/dist/communication/worker.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +16 -7
- package/dist/compile.js.map +1 -1
- package/dist/failure-kinds.d.ts +9 -1
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +6 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-extension-loader.d.ts +1 -1
- package/dist/flow-extension-loader.d.ts.map +1 -1
- package/dist/flow-extension-loader.js +143 -14
- package/dist/flow-extension-loader.js.map +1 -1
- package/dist/flow-extension-manifest.d.ts +1 -0
- package/dist/flow-extension-manifest.d.ts.map +1 -1
- package/dist/flow-extension-manifest.js +12 -3
- package/dist/flow-extension-manifest.js.map +1 -1
- package/dist/hosted-extension-manifest.d.ts.map +1 -1
- package/dist/hosted-extension-manifest.js +25 -4
- package/dist/hosted-extension-manifest.js.map +1 -1
- package/dist/hosted-extension-runtime.js +1 -1
- package/dist/hosted-extension-sandbox.js +2 -2
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +4 -2
- package/dist/llm-worker.js.map +1 -1
- package/dist/mcp-stdio.d.ts +2 -1
- package/dist/mcp-stdio.d.ts.map +1 -1
- package/dist/mcp-stdio.js +38 -21
- package/dist/mcp-stdio.js.map +1 -1
- package/dist/preflight.d.ts +2 -0
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js.map +1 -1
- package/dist/protocol.d.ts +9 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/resolved-cli-identity.d.ts +14 -0
- package/dist/resolved-cli-identity.d.ts.map +1 -0
- package/dist/resolved-cli-identity.js +19 -0
- package/dist/resolved-cli-identity.js.map +1 -0
- package/dist/worker-cli.d.ts +3 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +116 -40
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-lease.d.ts +3 -1
- package/dist/worker-lease.d.ts.map +1 -1
- package/dist/worker-lease.js +47 -9
- package/dist/worker-lease.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +4 -2
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-session.d.ts +3 -1
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +93 -53
- package/dist/wrapper-session.js.map +1 -1
- package/package.json +2 -2
- package/src/agent-reaper.ts +7 -4
- package/src/authored-budget.ts +73 -1
- package/src/authored-flow-error.ts +6 -0
- package/src/authored-node-runner.ts +3 -2
- package/src/authored-root.ts +150 -7
- package/src/authored-worker-step.ts +1 -0
- package/src/child-stop.ts +40 -1
- package/src/cli/add-extension.ts +1 -1
- package/src/cli/check.ts +25 -4
- package/src/cli/cli-probe.ts +73 -37
- package/src/cli/cloud-deploy.ts +21 -7
- package/src/cli/cloud-versions.ts +103 -0
- package/src/cli/direct-run.ts +4 -1
- package/src/cli/pinned-cli-alias.ts +74 -0
- package/src/cli/run.ts +103 -7
- package/src/cli/step-failure.ts +18 -0
- package/src/cli-commands.ts +18 -0
- package/src/cli.ts +15 -0
- package/src/cloud-deploy.ts +24 -3
- package/src/cloud-run.ts +9 -2
- package/src/cloud-versions-wire.ts +58 -0
- package/src/cloud-versions.ts +179 -0
- package/src/communication/worker.ts +24 -9
- package/src/compile.ts +28 -13
- package/src/failure-kinds.ts +14 -0
- package/src/flow-extension-loader.ts +154 -14
- package/src/flow-extension-manifest.ts +6 -3
- package/src/hosted-extension-manifest.ts +28 -5
- package/src/hosted-extension-runtime.ts +1 -1
- package/src/hosted-extension-sandbox.ts +2 -2
- package/src/llm-worker.ts +6 -4
- package/src/mcp-stdio.ts +34 -20
- package/src/preflight.ts +2 -0
- package/src/protocol.ts +9 -0
- package/src/resolved-cli-identity.ts +42 -0
- package/src/worker-cli.ts +123 -37
- package/src/worker-lease.ts +50 -9
- package/src/worker.ts +6 -4
- package/src/wrapper-session.ts +89 -56
package/src/worker-cli.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js
|
|
|
4
4
|
import { claudeResultOutcome, decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js';
|
|
5
5
|
import { openSidechannel, type SidechannelContext } from './pty-sidechannel.js';
|
|
6
6
|
import { openTranscriptWriter, transcriptPath, type TranscriptDigest, type TranscriptFile, type TranscriptWriter } from './agent-transcript.js';
|
|
7
|
-
import { spawn } from 'node:child_process';
|
|
7
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
8
8
|
import { StringDecoder } from 'node:string_decoder';
|
|
9
9
|
import { childStop } from './child-stop.js';
|
|
10
10
|
import { reapOnExit } from './agent-reaper.js';
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
AgentRelayTransportError,
|
|
30
30
|
type AgentTransport,
|
|
31
31
|
} from './agent-relay-transport.js';
|
|
32
|
+
import { pinCliAlias } from './cli/pinned-cli-alias.js';
|
|
32
33
|
|
|
33
34
|
/** Present only when a dispatched agent step carries a journaled wake context. */
|
|
34
35
|
export const WAKE_CONTEXT_ENV = 'RELAYFLOW_WAKE_CONTEXT';
|
|
@@ -40,6 +41,11 @@ export const WAKE_CONTEXT_ENV = 'RELAYFLOW_WAKE_CONTEXT';
|
|
|
40
41
|
*/
|
|
41
42
|
export const MODEL_ENV = 'RELAYFLOW_MODEL';
|
|
42
43
|
|
|
44
|
+
/** Preserve a multicall adapter's authored basename while executing pinned bytes. */
|
|
45
|
+
export function cliInvocationArgv0(cli: string, cliIdentity?: string): string {
|
|
46
|
+
return basename(cliIdentity ?? cli);
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
export interface WorkerCliResult {
|
|
44
50
|
relay_task?: import('./agent-relay-receipt.js').RelayTaskReceipt;
|
|
45
51
|
tokens_input?: number;
|
|
@@ -92,13 +98,15 @@ export async function runAgentCli(
|
|
|
92
98
|
transport: AgentTransport = 'direct',
|
|
93
99
|
relayContext?: AgentRelayContext,
|
|
94
100
|
processEnvironment: NodeJS.ProcessEnv = process.env,
|
|
101
|
+
cliIdentity?: string,
|
|
95
102
|
): Promise<WorkerCliResult> {
|
|
96
103
|
signal?.throwIfAborted();
|
|
97
104
|
if (signal !== undefined && process.platform === 'win32') {
|
|
98
105
|
throw new Error('Lease-bound agent execution requires macOS or Linux process-group cancellation; Windows is unsupported.');
|
|
99
106
|
}
|
|
100
|
-
const kind = cliAdapterKind(cli);
|
|
101
|
-
const effectiveModel = resolveCliModel(cli, model);
|
|
107
|
+
const kind = cliAdapterKind(cliIdentity ?? cli);
|
|
108
|
+
const effectiveModel = resolveCliModel(cliIdentity ?? cli, model);
|
|
109
|
+
const argv0 = cliInvocationArgv0(cli, cliIdentity);
|
|
102
110
|
|
|
103
111
|
if (mode === 'agent' && transport === 'relay') {
|
|
104
112
|
return runViaAgentRelay(kind, instruction, wakeContext, effectiveModel, relayContext, cwd, signal);
|
|
@@ -146,6 +154,7 @@ export async function runAgentCli(
|
|
|
146
154
|
wrapperLimits,
|
|
147
155
|
signal,
|
|
148
156
|
cwd,
|
|
157
|
+
argv0,
|
|
149
158
|
)), effectiveModel);
|
|
150
159
|
}
|
|
151
160
|
|
|
@@ -176,7 +185,7 @@ export async function runAgentCli(
|
|
|
176
185
|
const args = [...invocation.args];
|
|
177
186
|
args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json']));
|
|
178
187
|
const completion = kind === 'claude' ? claudeResultOutcome : undefined;
|
|
179
|
-
return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion), kind, env), effectiveModel);
|
|
188
|
+
return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0), kind, env), effectiveModel);
|
|
180
189
|
}
|
|
181
190
|
}
|
|
182
191
|
|
|
@@ -315,40 +324,71 @@ async function spawnInvocation(
|
|
|
315
324
|
sidechannel?: SidechannelContext,
|
|
316
325
|
cwd?: string,
|
|
317
326
|
completion?: (line: string) => { failed: boolean } | undefined,
|
|
327
|
+
argv0?: string,
|
|
318
328
|
): Promise<WorkerCliResult> {
|
|
319
329
|
let writeInput: (bytes: Buffer) => Promise<boolean> = async () => false;
|
|
320
330
|
let canDrive = () => false;
|
|
321
331
|
let driven = false;
|
|
332
|
+
// Prove and pin the authored identity before opening any per-attempt
|
|
333
|
+
// resources. If pinning fails there is then nothing else to unwind.
|
|
334
|
+
const pinned = await pinCliAlias(cli, argv0 ?? basename(cli));
|
|
335
|
+
if (signal?.aborted) {
|
|
336
|
+
pinned.release();
|
|
337
|
+
signal.throwIfAborted();
|
|
338
|
+
}
|
|
322
339
|
// The transcript file lives beside the PTY socket and is named by attempt.
|
|
323
340
|
// Its absence (no data dir, no attempt, unwritable dir) costs the step
|
|
324
341
|
// nothing: the digest is built from the buffered frames regardless. Opened
|
|
325
342
|
// BEFORE the sidechannel: once `onReady` has fired, a drive peer's HELLO may
|
|
326
343
|
// arrive at any time, and nothing may sit between that and the spawn that
|
|
327
344
|
// arms `canDrive`.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
onDrive() { driven = true; sidechannel.onDrive(); },
|
|
333
|
-
}, bytes => writeInput(bytes), () => canDrive());
|
|
334
|
-
// Tee the transcript into bounded tail files beside the socket. Evidence
|
|
335
|
-
// for `flows status --tail`, never the record; a failure here is a warning.
|
|
336
|
-
const tails = sidechannel === undefined ? undefined : openTails(sidechannel);
|
|
337
|
-
if (signal?.aborted) {
|
|
345
|
+
let writer: TranscriptWriter | undefined;
|
|
346
|
+
let channel: Awaited<ReturnType<typeof openSidechannel>> | undefined;
|
|
347
|
+
let tails: ReturnType<typeof openTails>;
|
|
348
|
+
const closeBeforeSpawn = async (): Promise<void> => {
|
|
338
349
|
channel?.close();
|
|
339
|
-
|
|
340
|
-
|
|
350
|
+
const closing = Promise.allSettled([
|
|
351
|
+
...(writer === undefined ? [] : [writer.close()]),
|
|
352
|
+
...(tails === undefined ? [] : [tails.stdout.close(), tails.stderr.close()]),
|
|
353
|
+
]);
|
|
354
|
+
pinned.release();
|
|
355
|
+
const deadline = new Promise<void>((done) => setTimeout(done, TAIL_CLOSE_TIMEOUT_MS).unref?.());
|
|
356
|
+
await Promise.race([closing, deadline]);
|
|
357
|
+
};
|
|
358
|
+
try {
|
|
359
|
+
writer = sidechannel?.attempt === undefined ? undefined
|
|
360
|
+
: await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), env);
|
|
361
|
+
channel = sidechannel === undefined ? undefined : await openSidechannel({
|
|
362
|
+
...sidechannel,
|
|
363
|
+
onDrive() { driven = true; sidechannel.onDrive(); },
|
|
364
|
+
}, bytes => writeInput(bytes), () => canDrive());
|
|
365
|
+
// Tee the transcript into bounded tail files beside the socket. Evidence
|
|
366
|
+
// for `flows status --tail`, never the record; a failure here is a warning.
|
|
367
|
+
tails = sidechannel === undefined ? undefined : openTails(sidechannel);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
await closeBeforeSpawn();
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
if (signal?.aborted) {
|
|
373
|
+
await closeBeforeSpawn();
|
|
341
374
|
signal.throwIfAborted();
|
|
342
375
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
376
|
+
// Always a group of its own off Windows, so every stop — and
|
|
377
|
+
// `reapOnExit` — reaches the whole agent tree, lease-bound or not.
|
|
378
|
+
const ownsGroup = process.platform !== 'win32';
|
|
379
|
+
let child: ChildProcessWithoutNullStreams;
|
|
380
|
+
try {
|
|
381
|
+
child = spawn(pinned.executable, invocation.args, {
|
|
348
382
|
stdio: ['pipe', 'pipe', 'pipe'], env,
|
|
349
383
|
detached: ownsGroup,
|
|
384
|
+
...(argv0 === undefined ? {} : { argv0 }),
|
|
350
385
|
...(cwd === undefined ? {} : { cwd }),
|
|
351
386
|
});
|
|
387
|
+
} catch (error) {
|
|
388
|
+
await closeBeforeSpawn();
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
return new Promise((resolve) => {
|
|
352
392
|
child.stdin.on('error', () => {});
|
|
353
393
|
if (channel === undefined) child.stdin.end();
|
|
354
394
|
canDrive = () => !child.stdin.destroyed && !child.stdin.writableEnded;
|
|
@@ -363,8 +403,7 @@ async function spawnInvocation(
|
|
|
363
403
|
// until they flush; the sidechannel pauses its reader in the meantime.
|
|
364
404
|
child.stdin.write(bytes, error => resolve(!error));
|
|
365
405
|
});
|
|
366
|
-
|
|
367
|
-
const release = ownsGroup ? reapOnExit(stop) : () => {};
|
|
406
|
+
let release = () => {};
|
|
368
407
|
const stdout: Buffer[] = [];
|
|
369
408
|
const stderr: Buffer[] = [];
|
|
370
409
|
let settled = false;
|
|
@@ -413,17 +452,66 @@ async function spawnInvocation(
|
|
|
413
452
|
? result : { ...result, transcript: { file: transcriptFile } });
|
|
414
453
|
}, () => resolve(result));
|
|
415
454
|
};
|
|
455
|
+
let pendingStopResult: {
|
|
456
|
+
result: WorkerCliResult;
|
|
457
|
+
discardTranscript: boolean;
|
|
458
|
+
priority: 'normal' | 'abort';
|
|
459
|
+
} | undefined;
|
|
460
|
+
const stop = childStop(child, ownsGroup, undefined, (stopError) => {
|
|
461
|
+
if (stopError !== undefined) {
|
|
462
|
+
finish({ exit_code: null, stdout_tail: '', stderr_tail: stopError.message }, true);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
pinned.release();
|
|
467
|
+
} catch (error) {
|
|
468
|
+
finish({
|
|
469
|
+
exit_code: null,
|
|
470
|
+
stdout_tail: '',
|
|
471
|
+
stderr_tail: `CLI invocation alias cleanup failed: ${String(error)}`,
|
|
472
|
+
}, true);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (pendingStopResult !== undefined) {
|
|
476
|
+
finish(pendingStopResult.result, pendingStopResult.discardTranscript);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
release = ownsGroup ? reapOnExit(stop, () => {
|
|
480
|
+
try { pinned.release(); } catch { /* host exit cannot report another result */ }
|
|
481
|
+
}) : () => {};
|
|
482
|
+
const finishAfterStop = (
|
|
483
|
+
result: WorkerCliResult,
|
|
484
|
+
action: 'kill' | 'terminate',
|
|
485
|
+
discardTranscript = false,
|
|
486
|
+
priority: 'normal' | 'abort' = 'normal',
|
|
487
|
+
): void => {
|
|
488
|
+
// Lease loss may replace an earlier result while its stop is pending;
|
|
489
|
+
// once it does, no later result-grace or execution timer may replace it.
|
|
490
|
+
if (pendingStopResult !== undefined) {
|
|
491
|
+
if (pendingStopResult.priority === 'abort' || priority === 'normal') return;
|
|
492
|
+
}
|
|
493
|
+
pendingStopResult = { result, discardTranscript, priority };
|
|
494
|
+
stop[action]();
|
|
495
|
+
};
|
|
416
496
|
const onAbort = (): void => {
|
|
417
|
-
|
|
418
|
-
|
|
497
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
498
|
+
timer = undefined;
|
|
499
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer);
|
|
500
|
+
graceTimer = undefined;
|
|
501
|
+
finishAfterStop(
|
|
502
|
+
{ exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' },
|
|
503
|
+
'kill',
|
|
504
|
+
true,
|
|
505
|
+
'abort',
|
|
506
|
+
);
|
|
419
507
|
};
|
|
420
508
|
/**
|
|
421
509
|
* Same invariant as `wrapper-session.ts`: `'close'` and `'error'` are
|
|
422
510
|
* evidence about the DIRECT CHILD, so they may not settle over a pending
|
|
423
511
|
* escalation, and only `maySettleOnChildExit` may drop one. This settle
|
|
424
|
-
* carries no deadline of its own because it needs none —
|
|
425
|
-
* settles
|
|
426
|
-
*
|
|
512
|
+
* carries no deadline of its own because it needs none — a stop-owned
|
|
513
|
+
* result now settles from the shared confirmation callback, while a normal
|
|
514
|
+
* child exit can only defer to a `'close'` we are still going to get.
|
|
427
515
|
*/
|
|
428
516
|
const finishOnChildExit = (result: WorkerCliResult): void => {
|
|
429
517
|
if (!stop.maySettleOnChildExit()) return;
|
|
@@ -441,12 +529,11 @@ async function spawnInvocation(
|
|
|
441
529
|
const onResult = (outcome: { failed: boolean }): void => {
|
|
442
530
|
if (graceTimer !== undefined || settled) return;
|
|
443
531
|
graceTimer = setTimeout(() => {
|
|
444
|
-
|
|
445
|
-
finish({
|
|
532
|
+
finishAfterStop({
|
|
446
533
|
exit_code: outcome.failed ? 1 : 0,
|
|
447
534
|
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
448
535
|
stderr_tail: `${Buffer.concat(stderr).toString('utf8')}\nCLI reported its final result but had not exited ${RESULT_EXIT_GRACE_MS}ms later; its process tree was stopped.`.trim(),
|
|
449
|
-
});
|
|
536
|
+
}, 'terminate');
|
|
450
537
|
}, RESULT_EXIT_GRACE_MS);
|
|
451
538
|
};
|
|
452
539
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
@@ -483,15 +570,14 @@ async function spawnInvocation(
|
|
|
483
570
|
}));
|
|
484
571
|
if (invocation.timeoutMs > 0) {
|
|
485
572
|
timer = setTimeout(() => {
|
|
486
|
-
// The stop
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
finish({
|
|
573
|
+
// The stop owns this settle: a direct-child event is not proof that its
|
|
574
|
+
// group is gone, and an unprovable forced stop must fail the step rather
|
|
575
|
+
// than arriving after a successful result has already won the race.
|
|
576
|
+
finishAfterStop({
|
|
491
577
|
exit_code: null,
|
|
492
578
|
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
493
579
|
stderr_tail: `CLI invocation timed out after ${invocation.timeoutMs}ms.`,
|
|
494
|
-
});
|
|
580
|
+
}, 'terminate');
|
|
495
581
|
}, invocation.timeoutMs);
|
|
496
582
|
}
|
|
497
583
|
});
|
package/src/worker-lease.ts
CHANGED
|
@@ -2,8 +2,12 @@ import { JournalProtocolError, type JournalClient } from './journal-client.js';
|
|
|
2
2
|
import type { StepDispatchEvent } from './protocol.js';
|
|
3
3
|
|
|
4
4
|
export class WorkerLeaseLostError extends Error {
|
|
5
|
-
constructor(
|
|
6
|
-
|
|
5
|
+
constructor(
|
|
6
|
+
readonly reason: 'already_expired' | 'renewal_expired' | 'completion_expired',
|
|
7
|
+
message: string,
|
|
8
|
+
options?: { cause?: unknown },
|
|
9
|
+
) {
|
|
10
|
+
super(message, options);
|
|
7
11
|
this.name = 'WorkerLeaseLostError';
|
|
8
12
|
}
|
|
9
13
|
}
|
|
@@ -36,13 +40,19 @@ export async function withWorkerLease<T>(
|
|
|
36
40
|
): Promise<T> {
|
|
37
41
|
const controller = new AbortController();
|
|
38
42
|
let stopped = false;
|
|
39
|
-
|
|
43
|
+
// Every deadline below is on THIS process's monotonic clock. The daemon
|
|
44
|
+
// issues `lease_deadline_ms` on its own wall clock, and comparing it with
|
|
45
|
+
// ours made the lease exactly as good as the two clocks' agreement: a
|
|
46
|
+
// worker 45s ahead refused every dispatch as already expired, one 75s
|
|
47
|
+
// behind scheduled its first renewal after the daemon had swept the lease
|
|
48
|
+
// (customer rw_3a0fcb71; tests/worker-lease-drift-live.test.ts).
|
|
49
|
+
let latestDeadline = Number.NaN;
|
|
40
50
|
let renewalTimer: NodeJS.Timeout | undefined;
|
|
41
51
|
let expiryTimer: NodeJS.Timeout | undefined;
|
|
42
52
|
let pending: Promise<void> = Promise.resolve();
|
|
43
53
|
const fail = (error: unknown): void => { controller.abort(error); };
|
|
44
54
|
const armExpiry = (deadline: number): number => {
|
|
45
|
-
const remaining = deadline -
|
|
55
|
+
const remaining = deadline - performance.now();
|
|
46
56
|
if (!Number.isFinite(remaining)) {
|
|
47
57
|
throw new Error(`Agent lease is already expired for ${dispatch.run_id}/${dispatch.step_id}.`);
|
|
48
58
|
}
|
|
@@ -57,16 +67,19 @@ export async function withWorkerLease<T>(
|
|
|
57
67
|
return remaining;
|
|
58
68
|
};
|
|
59
69
|
const renew = async (): Promise<void> => {
|
|
70
|
+
// Anchored BEFORE the request: the daemon starts the renewed lease no
|
|
71
|
+
// earlier than it receives this, so the local deadline errs early.
|
|
72
|
+
const sentAt = performance.now();
|
|
60
73
|
const result = await untilAborted(client.stepHeartbeat(
|
|
61
74
|
dispatch.run_id, dispatch.step_id, dispatch.attempt, dispatch.lease_id,
|
|
62
75
|
), controller.signal);
|
|
63
76
|
controller.signal.throwIfAborted();
|
|
64
77
|
// A response handled after local expiry cannot revive ownership, even
|
|
65
78
|
// if its future deadline was issued before this event loop stalled.
|
|
66
|
-
if (
|
|
79
|
+
if (performance.now() >= latestDeadline) {
|
|
67
80
|
throw new WorkerLeaseLostError('renewal_expired', `Agent lease expired before renewal for ${dispatch.run_id}/${dispatch.step_id}.`);
|
|
68
81
|
}
|
|
69
|
-
const remaining = armExpiry(result.lease_deadline_ms);
|
|
82
|
+
const remaining = armExpiry(localDeadline(result.lease_deadline_ms, result.lease_ttl_ms, sentAt));
|
|
70
83
|
if (!stopped) {
|
|
71
84
|
renewalTimer = setTimeout(() => {
|
|
72
85
|
pending = renew().catch(fail);
|
|
@@ -74,10 +87,26 @@ export async function withWorkerLease<T>(
|
|
|
74
87
|
}
|
|
75
88
|
};
|
|
76
89
|
try {
|
|
77
|
-
armExpiry(dispatch.lease_deadline_ms);
|
|
90
|
+
armExpiry(localDeadline(dispatch.lease_deadline_ms, dispatch.lease_ttl_ms, performance.now()));
|
|
78
91
|
// Establish ownership before starting an effectful process.
|
|
79
92
|
await renew();
|
|
80
|
-
|
|
93
|
+
let result: T;
|
|
94
|
+
try {
|
|
95
|
+
result = await execute(controller.signal);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// A body that rejected because the lease was lost under it reports
|
|
98
|
+
// whatever its wait happened to say ("worker wait canceled"). The lease
|
|
99
|
+
// is the cause, and callers branch on it (isLeaseLost): an attempt the
|
|
100
|
+
// kernel will retry must not be recorded as the body's own failure.
|
|
101
|
+
const lost = controller.signal.reason;
|
|
102
|
+
if (isLeaseLost(lost) && error !== lost) {
|
|
103
|
+
if (lost instanceof WorkerLeaseLostError) {
|
|
104
|
+
throw new WorkerLeaseLostError(lost.reason, lost.message, { cause: error });
|
|
105
|
+
}
|
|
106
|
+
throw lost;
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
81
110
|
stopped = true;
|
|
82
111
|
if (renewalTimer !== undefined) clearTimeout(renewalTimer);
|
|
83
112
|
// Drain any renewal before the caller sends step.complete. A renewal
|
|
@@ -86,7 +115,7 @@ export async function withWorkerLease<T>(
|
|
|
86
115
|
controller.signal.throwIfAborted();
|
|
87
116
|
// Timer callbacks can be delayed behind a resolved subprocess promise.
|
|
88
117
|
// Check the clock itself before permitting step.complete.
|
|
89
|
-
if (
|
|
118
|
+
if (performance.now() >= latestDeadline) {
|
|
90
119
|
throw new WorkerLeaseLostError('completion_expired', `Agent lease expired before completion for ${dispatch.run_id}/${dispatch.step_id}.`);
|
|
91
120
|
}
|
|
92
121
|
return result;
|
|
@@ -99,6 +128,18 @@ export async function withWorkerLease<T>(
|
|
|
99
128
|
}
|
|
100
129
|
}
|
|
101
130
|
|
|
131
|
+
/**
|
|
132
|
+
* A daemon lease deadline on this process's monotonic clock. With the
|
|
133
|
+
* daemon's `lease_ttl_ms` the wall clocks never meet: the duration is added to
|
|
134
|
+
* `anchor`, a moment no later than the daemon started it. An older daemon
|
|
135
|
+
* sends only the absolute deadline, which keeps the old wall-clock comparison
|
|
136
|
+
* -- read NOW, not at `anchor`, or the round trip would be subtracted twice.
|
|
137
|
+
*/
|
|
138
|
+
function localDeadline(daemonDeadlineMs: number, ttlMs: number | undefined, anchor: number): number {
|
|
139
|
+
if (typeof ttlMs === 'number' && Number.isFinite(ttlMs)) return anchor + ttlMs;
|
|
140
|
+
return performance.now() + (daemonDeadlineMs - Date.now());
|
|
141
|
+
}
|
|
142
|
+
|
|
102
143
|
function untilAborted<T>(request: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
103
144
|
return new Promise((resolve, reject) => {
|
|
104
145
|
const abort = (): void => { reject(signal.reason); };
|
package/src/worker.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { WorkerCliResult } from './worker-cli.js';
|
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
6
|
import type { JournalClient } from './journal-client.js';
|
|
7
7
|
import type { Pins, StepDispatchEvent } from './protocol.js';
|
|
8
|
-
import type {
|
|
8
|
+
import type { ResolvedKernelAgentStep } from './resolved-cli-identity.js';
|
|
9
9
|
import { runAgentCli } from './worker-cli.js';
|
|
10
10
|
import { agentStepCwd } from './agent-cwd.js';
|
|
11
11
|
import { resolveCliModel } from './cli-adapter.js';
|
|
@@ -114,7 +114,7 @@ export class AgentWorker extends EventEmitter {
|
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
private async execute(dispatch: StepDispatchEvent): Promise<void> {
|
|
117
|
-
const spec = dispatch.spec as Partial<
|
|
117
|
+
const spec = dispatch.spec as Partial<ResolvedKernelAgentStep>;
|
|
118
118
|
const helper = helperCall(spec);
|
|
119
119
|
if (helper !== undefined) {
|
|
120
120
|
if (this.options.dataDir === undefined) throw new Error('Helper worker requires a data directory for durable receipts');
|
|
@@ -130,7 +130,9 @@ export class AgentWorker extends EventEmitter {
|
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
let humanIntervention = false;
|
|
133
|
-
const effectiveModel = typeof spec.cli === 'string'
|
|
133
|
+
const effectiveModel = typeof spec.cli === 'string'
|
|
134
|
+
? resolveCliModel(spec.cli_identity ?? spec.cli, spec.model)
|
|
135
|
+
: spec.model;
|
|
134
136
|
// Resolved here, before the lease, because this is the process that shares
|
|
135
137
|
// the agent's filesystem. A refusal completes the step the way a missing
|
|
136
138
|
// CLI does — journaled as `worker_error` with the reason — rather than
|
|
@@ -147,7 +149,7 @@ export class AgentWorker extends EventEmitter {
|
|
|
147
149
|
spec.transport === 'relay' ? 'relay' : 'direct',
|
|
148
150
|
{ runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key,
|
|
149
151
|
dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema },
|
|
150
|
-
this.options.environment)
|
|
152
|
+
this.options.environment, spec.cli_identity)
|
|
151
153
|
: Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
|
|
152
154
|
const { result, usage } = workerSpend(completed, effectiveModel);
|
|
153
155
|
const cost = reportedCost(completed, effectiveModel);
|