@relayflows/sdk 2.0.39 → 2.0.41
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-transcript.d.ts +9 -0
- package/dist/agent-transcript.d.ts.map +1 -1
- package/dist/agent-transcript.js +12 -0
- package/dist/agent-transcript.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +22 -4
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.js +5 -6
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +52 -3
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/cloud-deploy.d.ts +1 -1
- package/dist/cli/cloud-deploy.d.ts.map +1 -1
- package/dist/cli/cloud-deploy.js +6 -3
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/step-evidence.d.ts +5 -0
- package/dist/cli/step-evidence.d.ts.map +1 -1
- package/dist/cli/step-evidence.js +45 -7
- package/dist/cli/step-evidence.js.map +1 -1
- package/dist/cli/step-failure.d.ts.map +1 -1
- package/dist/cli/step-failure.js +13 -0
- package/dist/cli/step-failure.js.map +1 -1
- package/dist/cli-commands.d.ts +2 -2
- package/dist/cli-commands.js +1 -1
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli-transport-evidence.d.ts +30 -0
- package/dist/cli-transport-evidence.d.ts.map +1 -0
- package/dist/cli-transport-evidence.js +31 -0
- package/dist/cli-transport-evidence.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +14 -20
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +49 -16
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-versions.d.ts +2 -6
- package/dist/cloud-versions.d.ts.map +1 -1
- package/dist/compile.d.ts +3 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +37 -13
- package/dist/compile.js.map +1 -1
- package/dist/failure-kinds.d.ts +13 -0
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-requirements.d.ts +2 -1
- package/dist/flow-requirements.d.ts.map +1 -1
- package/dist/flow-requirements.js +4 -2
- package/dist/flow-requirements.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/pty-sidechannel.d.ts +17 -0
- package/dist/pty-sidechannel.d.ts.map +1 -1
- package/dist/pty-sidechannel.js +41 -2
- package/dist/pty-sidechannel.js.map +1 -1
- package/dist/spec.d.ts +6 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/step-fields.d.ts +2 -2
- package/dist/step-fields.d.ts.map +1 -1
- package/dist/step-fields.js +2 -1
- package/dist/step-fields.js.map +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +18 -0
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +10 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +116 -44
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-spend.d.ts.map +1 -1
- package/dist/worker-spend.js +4 -1
- package/dist/worker-spend.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +15 -4
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-session.d.ts +2 -0
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +29 -10
- package/dist/wrapper-session.js.map +1 -1
- package/dist/yaml-helpers.js +1 -1
- package/dist/yaml-helpers.js.map +1 -1
- package/package.json +4 -3
- package/src/agent-transcript.ts +12 -0
- package/src/authored-node-runner.ts +23 -5
- package/src/authored-root.ts +5 -6
- package/src/authored-worker-step.ts +58 -3
- package/src/cli/cloud-deploy.ts +8 -4
- package/src/cli/step-evidence.ts +51 -7
- package/src/cli/step-failure.ts +13 -0
- package/src/cli-commands.ts +1 -1
- package/src/cli-transport-evidence.ts +70 -0
- package/src/cli.ts +1 -1
- package/src/cloud-deploy.ts +62 -20
- package/src/cloud-versions.ts +2 -2
- package/src/compile.ts +39 -13
- package/src/failure-kinds.ts +13 -0
- package/src/flow-requirements.ts +6 -3
- package/src/index.ts +1 -1
- package/src/pty-sidechannel.ts +38 -2
- package/src/spec.ts +6 -1
- package/src/step-fields.ts +2 -1
- package/src/validate.ts +18 -0
- package/src/worker-cli.ts +140 -42
- package/src/worker-spend.ts +4 -1
- package/src/worker.ts +21 -4
- package/src/wrapper-session.ts +32 -10
- package/src/yaml-helpers.ts +1 -1
package/src/step-fields.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const STEP_COMMON_FIELDS = [
|
|
|
25
25
|
'input',
|
|
26
26
|
'verification',
|
|
27
27
|
'maxIterations',
|
|
28
|
+
'transportRetries',
|
|
28
29
|
'memory',
|
|
29
30
|
'requirements',
|
|
30
31
|
] as const;
|
|
@@ -37,5 +38,5 @@ export const STEP_COMMON_FIELDS = [
|
|
|
37
38
|
export const STEP_FIELDS_BY_TYPE = {
|
|
38
39
|
deterministic: ['command', 'timeoutMs', 'lease_ms', 'onNonZero'],
|
|
39
40
|
llm: ['prompt', 'model', 'cli', 'output'],
|
|
40
|
-
agent: ['instruction', 'agent', 'cli', 'model', 'cwd', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'],
|
|
41
|
+
agent: ['timeoutMs', 'instruction', 'agent', 'cli', 'model', 'cwd', 'transport', 'surfaces', 'recoveryMode', 'permissions', 'output'],
|
|
41
42
|
} as const satisfies Record<StepType, readonly string[]>;
|
package/src/validate.ts
CHANGED
|
@@ -403,6 +403,9 @@ class Validator {
|
|
|
403
403
|
if (st['maxIterations'] !== undefined && !isPosInt(st['maxIterations'])) {
|
|
404
404
|
this.fail(`${at}.maxIterations: expected a positive integer`);
|
|
405
405
|
}
|
|
406
|
+
if (st['transportRetries'] !== undefined && !isTransportRetries(st['transportRetries'])) {
|
|
407
|
+
this.fail(`${at}.transportRetries: expected a non-negative integer no greater than ${KERNEL_U32_MAX}`);
|
|
408
|
+
}
|
|
406
409
|
|
|
407
410
|
if (type === 'deterministic') {
|
|
408
411
|
this.validateDeterministic(st as unknown as DeterministicStepSpec, at);
|
|
@@ -487,6 +490,13 @@ class Validator {
|
|
|
487
490
|
}
|
|
488
491
|
|
|
489
492
|
private validateAgent(st: AgentStepSpec, at: string): void {
|
|
493
|
+
if (st.timeoutMs !== undefined) {
|
|
494
|
+
if (!isPosInt(st.timeoutMs) || st.timeoutMs > 60 * 60_000) {
|
|
495
|
+
this.fail(`${at}.timeoutMs: expected a positive integer at most 3600000 (60 minutes)`);
|
|
496
|
+
}
|
|
497
|
+
if (st.transport === 'relay') this.fail(`${at}.timeoutMs: unsupported with relay transport`);
|
|
498
|
+
if ((st.maxIterations ?? 1) > 1) this.fail(`${at}.timeoutMs: requires maxIterations 1`);
|
|
499
|
+
}
|
|
490
500
|
if (!isNonEmptyString(st.instruction)) {
|
|
491
501
|
this.fail(`${at}.instruction: expected a non-empty string`);
|
|
492
502
|
}
|
|
@@ -605,6 +615,14 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
605
615
|
|
|
606
616
|
// --- predicates -------------------------------------------------------------
|
|
607
617
|
|
|
618
|
+
/** spec.rs `max_transport_retries: u32`. */
|
|
619
|
+
const KERNEL_U32_MAX = 4_294_967_295;
|
|
620
|
+
|
|
621
|
+
/** A transport retry budget the kernel can deserialize. */
|
|
622
|
+
export function isTransportRetries(v: unknown): v is number {
|
|
623
|
+
return isNonNegInt(v) && v <= KERNEL_U32_MAX;
|
|
624
|
+
}
|
|
625
|
+
|
|
608
626
|
function isObject(v: unknown): v is Record<string, unknown> {
|
|
609
627
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
610
628
|
}
|
package/src/worker-cli.ts
CHANGED
|
@@ -3,8 +3,16 @@ import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
|
3
3
|
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
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
openTranscriptWriter,
|
|
8
|
+
redactText,
|
|
9
|
+
transcriptPath,
|
|
10
|
+
type TranscriptDigest,
|
|
11
|
+
type TranscriptFile,
|
|
12
|
+
type TranscriptWriter,
|
|
13
|
+
} from './agent-transcript.js';
|
|
14
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
15
|
+
import { redactRelayError } from './redact.js';
|
|
8
16
|
import { StringDecoder } from 'node:string_decoder';
|
|
9
17
|
import { childStop } from './child-stop.js';
|
|
10
18
|
import { reapOnExit } from './agent-reaper.js';
|
|
@@ -21,9 +29,14 @@ import {
|
|
|
21
29
|
type WrapperSessionLimits,
|
|
22
30
|
} from './wrapper-session.js';
|
|
23
31
|
import { wrapperEnvironment } from './wrapper-runtime.js';
|
|
24
|
-
import { redactRelayError } from './redact.js';
|
|
25
32
|
import { applyStepEnvironment } from './step-env.js';
|
|
26
33
|
import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js';
|
|
34
|
+
import {
|
|
35
|
+
isRetryableSpawnError,
|
|
36
|
+
transportEvidence,
|
|
37
|
+
type CliTransportCause,
|
|
38
|
+
type CliTransportEvidence,
|
|
39
|
+
} from './cli-transport-evidence.js';
|
|
27
40
|
import {
|
|
28
41
|
runAgentRelayTask,
|
|
29
42
|
AgentRelayTransportError,
|
|
@@ -31,6 +44,9 @@ import {
|
|
|
31
44
|
} from './agent-relay-transport.js';
|
|
32
45
|
import { pinCliAlias } from './cli/pinned-cli-alias.js';
|
|
33
46
|
|
|
47
|
+
export type { CliTransportCause, CliTransportEvidence, CliTransportPhase } from './cli-transport-evidence.js';
|
|
48
|
+
export { agentCompletionReason } from './cli-transport-evidence.js';
|
|
49
|
+
|
|
34
50
|
/** Present only when a dispatched agent step carries a journaled wake context. */
|
|
35
51
|
export const WAKE_CONTEXT_ENV = 'RELAYFLOW_WAKE_CONTEXT';
|
|
36
52
|
|
|
@@ -53,6 +69,12 @@ export interface WorkerCliResult {
|
|
|
53
69
|
exit_code: number | null;
|
|
54
70
|
stdout_tail: string;
|
|
55
71
|
stderr_tail: string;
|
|
72
|
+
/**
|
|
73
|
+
* Bounded, redacted process-lifecycle evidence for a direct CLI spawn.
|
|
74
|
+
* This is journaled in `trajectory_tail`, including when parsed JSON output
|
|
75
|
+
* would otherwise discard the process wrapper.
|
|
76
|
+
*/
|
|
77
|
+
transport?: CliTransportEvidence;
|
|
56
78
|
/**
|
|
57
79
|
* Files the agent created or changed under its working directory,
|
|
58
80
|
* cwd-relative POSIX paths, sorted. Measured by the worker that spawned the
|
|
@@ -99,6 +121,8 @@ export async function runAgentCli(
|
|
|
99
121
|
relayContext?: AgentRelayContext,
|
|
100
122
|
processEnvironment: NodeJS.ProcessEnv = process.env,
|
|
101
123
|
cliIdentity?: string,
|
|
124
|
+
// Trailing parameter preserves existing positional callers.
|
|
125
|
+
stepTimeoutMs?: number,
|
|
102
126
|
): Promise<WorkerCliResult> {
|
|
103
127
|
signal?.throwIfAborted();
|
|
104
128
|
if (signal !== undefined && process.platform === 'win32') {
|
|
@@ -145,17 +169,21 @@ export async function runAgentCli(
|
|
|
145
169
|
// discovery names are set from this dispatch, exactly as for a direct spawn.
|
|
146
170
|
const wrapperEnv = wrapperEnvironment(processEnvironment);
|
|
147
171
|
applyStepEnvironment(wrapperEnv, sidechannel);
|
|
148
|
-
|
|
172
|
+
const session = await runWrapperSession(
|
|
149
173
|
cli,
|
|
150
174
|
instruction,
|
|
151
175
|
wakeContext,
|
|
152
176
|
effectiveModel,
|
|
153
177
|
wrapperEnv,
|
|
154
|
-
wrapperLimits,
|
|
178
|
+
stepTimeoutMs === undefined ? wrapperLimits : { ...wrapperLimits, executionTimeoutMs: stepTimeoutMs },
|
|
155
179
|
signal,
|
|
156
180
|
cwd,
|
|
157
181
|
argv0,
|
|
158
|
-
)
|
|
182
|
+
);
|
|
183
|
+
const decoded = decodeWrapperResult(session);
|
|
184
|
+
// A wrapper that hit its deadline after reporting usage is still charged
|
|
185
|
+
// for it; what it wrote is not the step's output.
|
|
186
|
+
return requirePricedUsage(session.transport?.cause === 'timeout' ? { ...decoded, stdout_tail: '' } : decoded, effectiveModel);
|
|
159
187
|
}
|
|
160
188
|
|
|
161
189
|
const env: NodeJS.ProcessEnv = { ...processEnvironment };
|
|
@@ -166,6 +194,8 @@ export async function runAgentCli(
|
|
|
166
194
|
applyStepEnvironment(env, sidechannel);
|
|
167
195
|
const invocation = mode === 'llm' ? llmExecution(kind, instruction, effectiveModel) : agentExecution(kind, instruction, effectiveModel);
|
|
168
196
|
|
|
197
|
+
if (stepTimeoutMs !== undefined) invocation.timeoutMs = stepTimeoutMs;
|
|
198
|
+
|
|
169
199
|
if (wakeContext !== undefined) {
|
|
170
200
|
try {
|
|
171
201
|
env[WAKE_CONTEXT_ENV] = JSON.stringify(wakeContext);
|
|
@@ -185,7 +215,9 @@ export async function runAgentCli(
|
|
|
185
215
|
const args = [...invocation.args];
|
|
186
216
|
args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json']));
|
|
187
217
|
const completion = kind === 'claude' ? claudeResultOutcome : undefined;
|
|
188
|
-
return requirePricedUsage(decodeProviderResult(await spawnInvocation(
|
|
218
|
+
return requirePricedUsage(decodeProviderResult(await spawnInvocation(
|
|
219
|
+
cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion, argv0, kind,
|
|
220
|
+
), kind, env), effectiveModel);
|
|
189
221
|
}
|
|
190
222
|
}
|
|
191
223
|
|
|
@@ -325,9 +357,15 @@ async function spawnInvocation(
|
|
|
325
357
|
cwd?: string,
|
|
326
358
|
completion?: (line: string) => { failed: boolean } | undefined,
|
|
327
359
|
argv0?: string,
|
|
360
|
+
kind?: CliAdapterKind,
|
|
328
361
|
): Promise<WorkerCliResult> {
|
|
329
|
-
|
|
330
|
-
let
|
|
362
|
+
const pendingInput: Buffer[] = [];
|
|
363
|
+
let acceptingDrive = true;
|
|
364
|
+
let writeInput: (bytes: Buffer) => Promise<boolean> = async bytes => {
|
|
365
|
+
pendingInput.push(Buffer.from(bytes));
|
|
366
|
+
return true;
|
|
367
|
+
};
|
|
368
|
+
let canDrive = () => acceptingDrive;
|
|
331
369
|
let driven = false;
|
|
332
370
|
// Prove and pin the authored identity before opening any per-attempt
|
|
333
371
|
// resources. If pinning fails there is then nothing else to unwind.
|
|
@@ -362,6 +400,12 @@ async function spawnInvocation(
|
|
|
362
400
|
...sidechannel,
|
|
363
401
|
onDrive() { driven = true; sidechannel.onDrive(); },
|
|
364
402
|
}, bytes => writeInput(bytes), () => canDrive());
|
|
403
|
+
// Decide the child's stdin shape before spawn. An unattended Codex process
|
|
404
|
+
// gets `/dev/null` from `ignore`, so it never enters the "additional input
|
|
405
|
+
// from stdin" path. Only an already-enrolled drive peer gets a writable
|
|
406
|
+
// pipe. View/passthrough retain live output without changing stdin.
|
|
407
|
+
driven = channel === undefined ? false : await channel.waitForDrive(100);
|
|
408
|
+
acceptingDrive = false;
|
|
365
409
|
// Tee the transcript into bounded tail files beside the socket. Evidence
|
|
366
410
|
// for `flows status --tail`, never the record; a failure here is a warning.
|
|
367
411
|
tails = sidechannel === undefined ? undefined : openTails(sidechannel);
|
|
@@ -376,10 +420,10 @@ async function spawnInvocation(
|
|
|
376
420
|
// Always a group of its own off Windows, so every stop — and
|
|
377
421
|
// `reapOnExit` — reaches the whole agent tree, lease-bound or not.
|
|
378
422
|
const ownsGroup = process.platform !== 'win32';
|
|
379
|
-
let child:
|
|
423
|
+
let child: ChildProcess;
|
|
380
424
|
try {
|
|
381
425
|
child = spawn(pinned.executable, invocation.args, {
|
|
382
|
-
stdio: ['pipe', 'pipe', 'pipe'], env,
|
|
426
|
+
stdio: [driven ? 'pipe' : 'ignore', 'pipe', 'pipe'], env,
|
|
383
427
|
detached: ownsGroup,
|
|
384
428
|
...(argv0 === undefined ? {} : { argv0 }),
|
|
385
429
|
...(cwd === undefined ? {} : { cwd }),
|
|
@@ -389,20 +433,32 @@ async function spawnInvocation(
|
|
|
389
433
|
throw error;
|
|
390
434
|
}
|
|
391
435
|
return new Promise((resolve) => {
|
|
392
|
-
child.stdin
|
|
393
|
-
|
|
394
|
-
canDrive = () => !
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
436
|
+
const stdin = child.stdin;
|
|
437
|
+
stdin?.on('error', () => {});
|
|
438
|
+
canDrive = () => stdin !== null && !stdin.destroyed && !stdin.writableEnded;
|
|
439
|
+
let inputQueue = Promise.resolve(true);
|
|
440
|
+
writeInput = bytes => {
|
|
441
|
+
inputQueue = inputQueue.then(previousAccepted => {
|
|
442
|
+
if (!previousAccepted || !canDrive()) return false;
|
|
443
|
+
return new Promise<boolean>(resolve => {
|
|
444
|
+
// write(false) still accepts the bytes. The completion callback
|
|
445
|
+
// waits until they flush; the sidechannel pauses its reader.
|
|
446
|
+
stdin!.write(bytes, error => resolve(!error));
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
return inputQueue;
|
|
450
|
+
};
|
|
451
|
+
for (const bytes of pendingInput.splice(0)) void writeInput(bytes);
|
|
452
|
+
// The last driver leaving is the end of input: flush what it sent, then
|
|
453
|
+
// EOF. Without it a driven Codex waits on its stdin lifecycle forever.
|
|
454
|
+
if (driven) {
|
|
455
|
+
channel?.whenDriveIdle(() => {
|
|
456
|
+
inputQueue = inputQueue.then(() => {
|
|
457
|
+
if (canDrive()) stdin!.end();
|
|
458
|
+
return false;
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
}
|
|
406
462
|
let release = () => {};
|
|
407
463
|
const stdout: Buffer[] = [];
|
|
408
464
|
const stderr: Buffer[] = [];
|
|
@@ -414,7 +470,6 @@ async function spawnInvocation(
|
|
|
414
470
|
const finish = (result: WorkerCliResult, discardTranscript = false): void => {
|
|
415
471
|
if (settled) return;
|
|
416
472
|
settled = true;
|
|
417
|
-
if (inputTimer !== undefined) clearTimeout(inputTimer);
|
|
418
473
|
channel?.close();
|
|
419
474
|
if (timer !== undefined) clearTimeout(timer);
|
|
420
475
|
if (graceTimer !== undefined) clearTimeout(graceTimer);
|
|
@@ -498,8 +553,12 @@ async function spawnInvocation(
|
|
|
498
553
|
timer = undefined;
|
|
499
554
|
if (graceTimer !== undefined) clearTimeout(graceTimer);
|
|
500
555
|
graceTimer = undefined;
|
|
556
|
+
const transport = transportEvidence({
|
|
557
|
+
phase: 'abort', cause: 'lease_lost', exitCode: null, signal: null,
|
|
558
|
+
stderr: 'Agent execution aborted: lease ownership lost.', retryable: false,
|
|
559
|
+
}, env);
|
|
501
560
|
finishAfterStop(
|
|
502
|
-
{ exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' },
|
|
561
|
+
{ exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.', transport },
|
|
503
562
|
'kill',
|
|
504
563
|
true,
|
|
505
564
|
'abort',
|
|
@@ -529,14 +588,22 @@ async function spawnInvocation(
|
|
|
529
588
|
const onResult = (outcome: { failed: boolean }): void => {
|
|
530
589
|
if (graceTimer !== undefined || settled) return;
|
|
531
590
|
graceTimer = setTimeout(() => {
|
|
591
|
+
const exitCode = outcome.failed ? 1 : 0;
|
|
592
|
+
const stderrText = `${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();
|
|
593
|
+
const transport = transportEvidence({
|
|
594
|
+
phase: 'result_exit_grace', cause: 'result_exit_timeout', exitCode, signal: null,
|
|
595
|
+
stderr: stderrText,
|
|
596
|
+
retryable: false,
|
|
597
|
+
}, env);
|
|
532
598
|
finishAfterStop({
|
|
533
|
-
exit_code:
|
|
599
|
+
exit_code: exitCode,
|
|
534
600
|
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
535
|
-
stderr_tail:
|
|
601
|
+
stderr_tail: redactText(stderrText, env),
|
|
602
|
+
transport,
|
|
536
603
|
}, 'terminate');
|
|
537
604
|
}, RESULT_EXIT_GRACE_MS);
|
|
538
605
|
};
|
|
539
|
-
child.stdout
|
|
606
|
+
child.stdout!.on('data', (chunk: Buffer) => {
|
|
540
607
|
stdout.push(chunk);
|
|
541
608
|
channel?.publish(chunk);
|
|
542
609
|
// The tails take raw bytes, so they are fed before any line splitting
|
|
@@ -555,28 +622,59 @@ async function spawnInvocation(
|
|
|
555
622
|
if (outcome !== undefined) onResult(outcome);
|
|
556
623
|
}
|
|
557
624
|
});
|
|
558
|
-
child.stderr
|
|
559
|
-
child.once('error', (error) =>
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
625
|
+
child.stderr!.on('data', (chunk: Buffer) => { stderr.push(chunk); channel?.publish(chunk); tails?.stderr.append(chunk); });
|
|
626
|
+
child.once('error', (error) => {
|
|
627
|
+
const code = typeof (error as NodeJS.ErrnoException).code === 'string'
|
|
628
|
+
? (error as NodeJS.ErrnoException).code : undefined;
|
|
629
|
+
const retryable = isRetryableSpawnError(code);
|
|
630
|
+
const transport = transportEvidence({
|
|
631
|
+
phase: 'spawn', cause: child.pid === undefined ? 'spawn_error' : 'process_error',
|
|
632
|
+
exitCode: null, signal: null, stderr: error.message, errorCode: code, retryable,
|
|
633
|
+
}, env);
|
|
634
|
+
finishOnChildExit({
|
|
635
|
+
exit_code: null,
|
|
636
|
+
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
637
|
+
stderr_tail: redactText(error.message, env),
|
|
638
|
+
transport,
|
|
639
|
+
});
|
|
640
|
+
});
|
|
564
641
|
child.once('error', () => { if (child.pid === undefined) release(); });
|
|
565
642
|
child.once('close', release);
|
|
566
|
-
child.once('close', (code) =>
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
643
|
+
child.once('close', (code, signalName) => {
|
|
644
|
+
const stderrText = Buffer.concat(stderr).toString('utf8');
|
|
645
|
+
const codexStdinLifecycle = kind === 'codex' && code === 1
|
|
646
|
+
&& stderrText.trim() === 'Reading additional input from stdin...';
|
|
647
|
+
const cause: CliTransportCause = codexStdinLifecycle ? 'codex_stdin_lifecycle'
|
|
648
|
+
: signalName !== null ? 'signal'
|
|
649
|
+
: code === null ? 'close_without_status'
|
|
650
|
+
: code === 0 ? 'exited' : 'nonzero_exit';
|
|
651
|
+
const retryable = codexStdinLifecycle || signalName !== null || code === null;
|
|
652
|
+
const transport = transportEvidence({
|
|
653
|
+
phase: 'close', cause, exitCode: code, signal: signalName, stderr: stderrText, retryable,
|
|
654
|
+
}, env);
|
|
655
|
+
finishOnChildExit({
|
|
656
|
+
exit_code: code,
|
|
657
|
+
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
658
|
+
stderr_tail: redactText(stderrText, env),
|
|
659
|
+
transport,
|
|
660
|
+
});
|
|
661
|
+
});
|
|
571
662
|
if (invocation.timeoutMs > 0) {
|
|
572
663
|
timer = setTimeout(() => {
|
|
573
664
|
// The stop owns this settle: a direct-child event is not proof that its
|
|
574
665
|
// group is gone, and an unprovable forced stop must fail the step rather
|
|
575
666
|
// than arriving after a successful result has already won the race.
|
|
667
|
+
const timeoutMessage = `CLI invocation timed out after ${invocation.timeoutMs}ms.`;
|
|
668
|
+
const transport = transportEvidence({
|
|
669
|
+
phase: 'timeout', cause: 'timeout', exitCode: null, signal: null,
|
|
670
|
+
stderr: `${Buffer.concat(stderr).toString('utf8')}\n${timeoutMessage}`,
|
|
671
|
+
retryable: false,
|
|
672
|
+
}, env);
|
|
576
673
|
finishAfterStop({
|
|
577
674
|
exit_code: null,
|
|
578
675
|
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
|
579
|
-
stderr_tail:
|
|
676
|
+
stderr_tail: timeoutMessage,
|
|
677
|
+
transport,
|
|
580
678
|
}, 'terminate');
|
|
581
679
|
}, invocation.timeoutMs);
|
|
582
680
|
}
|
package/src/worker-spend.ts
CHANGED
|
@@ -35,7 +35,10 @@ function unmeteredUsage(input: number | undefined, output: number | undefined):
|
|
|
35
35
|
*/
|
|
36
36
|
export function workerSpend(result: WorkerCliResult, model?: string): { result: WorkerCliResult; usage: StepUsage | undefined } {
|
|
37
37
|
try {
|
|
38
|
-
|
|
38
|
+
// Usage that was never reported is unknown, not a measured $0, even for a
|
|
39
|
+
// priced model (a step stopped before its CLI reported, for one).
|
|
40
|
+
const reported = result.tokens_input !== undefined && result.tokens_output !== undefined;
|
|
41
|
+
const usage = (reported ? pricedUsage(model, result.tokens_input, result.tokens_output) : undefined)
|
|
39
42
|
?? unmeteredUsage(result.tokens_input, result.tokens_output);
|
|
40
43
|
return { result, usage };
|
|
41
44
|
}
|
package/src/worker.ts
CHANGED
|
@@ -7,13 +7,20 @@ import type { JournalClient } from './journal-client.js';
|
|
|
7
7
|
import type { Pins, StepDispatchEvent } from './protocol.js';
|
|
8
8
|
import type { ResolvedKernelAgentStep } from './resolved-cli-identity.js';
|
|
9
9
|
import { runAgentCli } from './worker-cli.js';
|
|
10
|
+
import { agentCompletionReason } from './cli-transport-evidence.js';
|
|
10
11
|
import { agentStepCwd } from './agent-cwd.js';
|
|
11
12
|
import { resolveCliModel } from './cli-adapter.js';
|
|
12
13
|
import { withWorkerLease } from './worker-lease.js';
|
|
13
14
|
import { workerInstruction } from './worker-input.js';
|
|
14
15
|
import { helperCall } from './yaml-helpers.js';
|
|
15
16
|
import { completeHelperDispatch } from './yaml-helper-effect.js';
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
ARTIFACT_PATHS_MAX,
|
|
19
|
+
boundTranscriptDigest,
|
|
20
|
+
boundedTail,
|
|
21
|
+
redactText,
|
|
22
|
+
type TranscriptDigest,
|
|
23
|
+
} from './agent-transcript.js';
|
|
17
24
|
|
|
18
25
|
export { MODEL_ENV, WAKE_CONTEXT_ENV } from './worker-cli.js';
|
|
19
26
|
|
|
@@ -149,11 +156,11 @@ export class AgentWorker extends EventEmitter {
|
|
|
149
156
|
spec.transport === 'relay' ? 'relay' : 'direct',
|
|
150
157
|
{ runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key,
|
|
151
158
|
dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema },
|
|
152
|
-
this.options.environment, spec.cli_identity)
|
|
159
|
+
this.options.environment, spec.cli_identity, spec.timeout_ms)
|
|
153
160
|
: Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
|
|
154
161
|
const { result, usage } = workerSpend(completed, effectiveModel);
|
|
155
162
|
const cost = reportedCost(completed, effectiveModel);
|
|
156
|
-
const completionReason = result
|
|
163
|
+
const completionReason = agentCompletionReason(result);
|
|
157
164
|
|
|
158
165
|
// Output shape: if the CLI's stdout parses as JSON, promote THAT
|
|
159
166
|
// as the step's `output` value so `json_schema` verification
|
|
@@ -181,7 +188,16 @@ export class AgentWorker extends EventEmitter {
|
|
|
181
188
|
// `transcript` is not part of the wrapper either: it is evidence about the
|
|
182
189
|
// attempt, journaled in `trajectory_tail` below on success and failure
|
|
183
190
|
// alike, where the kernel already accepts and bounds it (16 KiB).
|
|
184
|
-
const { transcript, ...
|
|
191
|
+
const { transcript, transport, ...rawWrapper } = result;
|
|
192
|
+
// `stderr_tail` is evidence, not authored output. Redact and bound it
|
|
193
|
+
// before it reaches either the worker-failure render or the journal --
|
|
194
|
+
// against the environment the CLI ran with as well as this host's, since a
|
|
195
|
+
// secret supplied only to the CLI is the one most likely to be echoed.
|
|
196
|
+
const redacted = redactText(redactText(rawWrapper.stderr_tail), this.options.environment ?? process.env);
|
|
197
|
+
const wrapper = {
|
|
198
|
+
...rawWrapper,
|
|
199
|
+
stderr_tail: boundedTail(redacted, 2 * 1024, 'worker stderr: ').text,
|
|
200
|
+
};
|
|
185
201
|
const output = result.relay_task?.status === 'completed' && result.exit_code === 0
|
|
186
202
|
? result.relay_task.output : parseJsonOutput(result.stdout_tail) ?? wrapper;
|
|
187
203
|
const trajectoryTail = {
|
|
@@ -190,6 +206,7 @@ export class AgentWorker extends EventEmitter {
|
|
|
190
206
|
task_execution: result.relay_task.task_execution, error: result.relay_task.error,
|
|
191
207
|
} }),
|
|
192
208
|
...(transcript === undefined ? {} : { transcript: transcriptDigest(transcript, dispatch.attempt, result) }),
|
|
209
|
+
...(transport === undefined ? {} : { transport }),
|
|
193
210
|
};
|
|
194
211
|
|
|
195
212
|
await this.client.stepComplete(
|
package/src/wrapper-session.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { transportEvidence, type CliTransportEvidence } from './cli-transport-evidence.js';
|
|
1
2
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
3
|
import { childStop, ownsProcessGroup } from './child-stop.js';
|
|
3
4
|
import { reapOnExit } from './agent-reaper.js';
|
|
@@ -29,6 +30,7 @@ export interface WrapperSessionLimits {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export interface WrapperSessionResult {
|
|
33
|
+
transport?: CliTransportEvidence;
|
|
32
34
|
exit_code: number | null;
|
|
33
35
|
stdout_tail: string;
|
|
34
36
|
stderr_tail: string;
|
|
@@ -157,7 +159,7 @@ async function executePinnedWrapper(
|
|
|
157
159
|
let exitCode: number | null = null;
|
|
158
160
|
/** The handshake deadline, then the execution deadline if there is one. */
|
|
159
161
|
let lifecycleTimer: NodeJS.Timeout | undefined;
|
|
160
|
-
/** The post-exit drain grace, armed
|
|
162
|
+
/** The post-exit drain grace, armed once the wrapper has exited and acknowledged. */
|
|
161
163
|
let drainTimer: NodeJS.Timeout | undefined;
|
|
162
164
|
|
|
163
165
|
const finish = (result: WrapperSessionResult): void => {
|
|
@@ -231,7 +233,7 @@ async function executePinnedWrapper(
|
|
|
231
233
|
};
|
|
232
234
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
233
235
|
if (signal?.aborted) { onAbort(); return; }
|
|
234
|
-
const terminate = (message: string): void => {
|
|
236
|
+
const terminate = (message: string, transport?: WrapperSessionResult['transport'], captured = ''): void => {
|
|
235
237
|
if (protocolError !== undefined) return;
|
|
236
238
|
protocolError = message;
|
|
237
239
|
if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
|
|
@@ -247,7 +249,7 @@ async function executePinnedWrapper(
|
|
|
247
249
|
// The shared stop owns both the liveness bound and settlement. Its
|
|
248
250
|
// callback preserves this refusal once group death is proved, or
|
|
249
251
|
// replaces it with a fail-closed confirmation error when it is not.
|
|
250
|
-
finishAfterStop(failure(message), 'terminate');
|
|
252
|
+
finishAfterStop({ ...failure(message), stdout_tail: captured, ...(transport === undefined ? {} : { transport }) }, 'terminate');
|
|
251
253
|
};
|
|
252
254
|
const startExecutionTimer = (): void => {
|
|
253
255
|
if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
|
|
@@ -258,9 +260,29 @@ async function executePinnedWrapper(
|
|
|
258
260
|
// and what bounds the work from here is the lease abort — the same thing
|
|
259
261
|
// that bounds a `claude` or `codex` step.
|
|
260
262
|
if (limits.executionTimeoutMs <= 0) return;
|
|
261
|
-
lifecycleTimer = setTimeout(() =>
|
|
263
|
+
lifecycleTimer = setTimeout(() => {
|
|
264
|
+
lifecycleTimer = undefined;
|
|
265
|
+
// The deadline bounds a wrapper that is still running. One that has
|
|
266
|
+
// already exited did not time out, whatever it left holding stdio:
|
|
267
|
+
// its settle belongs to the post-exit drain, which reports the
|
|
268
|
+
// wrapper's own exit. Re-arming here is idempotent.
|
|
269
|
+
if (childExited) {
|
|
270
|
+
startDrainGrace();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
onExecutionDeadline();
|
|
274
|
+
}, limits.executionTimeoutMs);
|
|
275
|
+
};
|
|
276
|
+
const onExecutionDeadline = (): void => {
|
|
277
|
+
terminate(
|
|
262
278
|
`CLI ${JSON.stringify(cli)} execution timed out after ${limits.executionTimeoutMs}ms.`,
|
|
263
|
-
|
|
279
|
+
transportEvidence({ phase: 'timeout', cause: 'timeout', retryable: false, exitCode: null, signal: null,
|
|
280
|
+
stderr: `CLI execution timed out after ${limits.executionTimeoutMs}ms.` }, env),
|
|
281
|
+
// What the wrapper wrote before its deadline, so a result envelope it
|
|
282
|
+
// already emitted still reports its usage. Only the caller decides
|
|
283
|
+
// what of it, if anything, is output.
|
|
284
|
+
stdout.join('') + executionPending,
|
|
285
|
+
);
|
|
264
286
|
};
|
|
265
287
|
const exceedsOutputLimit = (additionalBytes: number): boolean => {
|
|
266
288
|
const pendingBytes = Buffer.byteLength(executionPending);
|
|
@@ -382,13 +404,13 @@ async function executePinnedWrapper(
|
|
|
382
404
|
finishOnChildExit(result);
|
|
383
405
|
};
|
|
384
406
|
/**
|
|
385
|
-
* Arm the post-exit drain, for
|
|
386
|
-
*
|
|
387
|
-
* execution deadline
|
|
388
|
-
*
|
|
407
|
+
* Arm the post-exit drain, for a session that has both seen the wrapper
|
|
408
|
+
* exit and consumed its acknowledgement. This holds with or without an
|
|
409
|
+
* execution deadline: the deadline bounds a wrapper still running, and a
|
|
410
|
+
* wrapper that exited — even with a descendant holding stdio — settles as
|
|
411
|
+
* its own exit here rather than waiting out the deadline as a timeout.
|
|
389
412
|
*/
|
|
390
413
|
const startDrainGrace = (): void => {
|
|
391
|
-
if (limits.executionTimeoutMs > 0) return;
|
|
392
414
|
if (!childExited || phase !== 'execute') return;
|
|
393
415
|
if (settled || drainTimer !== undefined || protocolError !== undefined) return;
|
|
394
416
|
drainTimer = setTimeout(drainExpired, DRAIN_AFTER_EXIT_MS);
|
package/src/yaml-helpers.ts
CHANGED
|
@@ -63,7 +63,7 @@ export type HelperCall = { [P in keyof YamlHelperParams]: { [V in keyof YamlHelp
|
|
|
63
63
|
}[keyof YamlHelperParams[P]] }[keyof YamlHelperParams];
|
|
64
64
|
|
|
65
65
|
const prefix = HELPER_INSTRUCTION_PREFIX;
|
|
66
|
-
const commonFields = ['id', 'dependsOn', 'maxIterations', 'verification', 'output'] as const;
|
|
66
|
+
const commonFields = ['id', 'dependsOn', 'maxIterations', 'transportRetries', 'verification', 'output'] as const;
|
|
67
67
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
68
68
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
69
69
|
|