@relayflows/sdk 2.0.22 → 2.0.23
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-artifacts.d.ts +18 -9
- package/dist/agent-artifacts.d.ts.map +1 -1
- package/dist/agent-artifacts.js +27 -11
- package/dist/agent-artifacts.js.map +1 -1
- package/dist/authored-flow-error.d.ts +33 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js +20 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +4 -1
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-node-entry.js +5 -1
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts +11 -0
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +30 -1
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.js +3 -1
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-source-authority.js +13 -0
- package/dist/authored-source-authority.js.map +1 -1
- package/dist/authored-step-index.d.ts +82 -0
- package/dist/authored-step-index.d.ts.map +1 -0
- package/dist/authored-step-index.js +120 -0
- package/dist/authored-step-index.js.map +1 -0
- package/dist/authored-step-output.d.ts +23 -2
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +47 -5
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts +10 -2
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +81 -7
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +67 -12
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-read.d.ts +31 -0
- package/dist/cli/cloud-read.d.ts.map +1 -0
- package/dist/cli/cloud-read.js +482 -0
- package/dist/cli/cloud-read.js.map +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +53 -25
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/status.d.ts +9 -0
- package/dist/cli/status.d.ts.map +1 -1
- package/dist/cli/status.js +14 -0
- package/dist/cli/status.js.map +1 -1
- package/dist/cli/step-failure.d.ts +26 -0
- package/dist/cli/step-failure.d.ts.map +1 -1
- package/dist/cli/step-failure.js +68 -3
- package/dist/cli/step-failure.js.map +1 -1
- package/dist/cli-commands.d.ts +29 -1
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +23 -1
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -2
- package/dist/cli.js.map +1 -1
- package/dist/cloud-http.d.ts +12 -0
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +14 -6
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-read.d.ts +154 -0
- package/dist/cloud-read.d.ts.map +1 -0
- package/dist/cloud-read.js +373 -0
- package/dist/cloud-read.js.map +1 -0
- package/dist/cloud-transcript.d.ts +94 -0
- package/dist/cloud-transcript.d.ts.map +1 -0
- package/dist/cloud-transcript.js +342 -0
- package/dist/cloud-transcript.js.map +1 -0
- package/dist/communication/environment.d.ts +3 -0
- package/dist/communication/environment.d.ts.map +1 -0
- package/dist/communication/environment.js +34 -0
- package/dist/communication/environment.js.map +1 -0
- package/dist/communication/history.d.ts +6 -0
- package/dist/communication/history.d.ts.map +1 -0
- package/dist/communication/history.js +38 -0
- package/dist/communication/history.js.map +1 -0
- package/dist/communication/local.d.ts +6 -0
- package/dist/communication/local.d.ts.map +1 -0
- package/dist/communication/local.js +56 -0
- package/dist/communication/local.js.map +1 -0
- package/dist/communication/preflight.d.ts +6 -0
- package/dist/communication/preflight.d.ts.map +1 -0
- package/dist/communication/preflight.js +37 -0
- package/dist/communication/preflight.js.map +1 -0
- package/dist/communication/projection.d.ts +8 -0
- package/dist/communication/projection.d.ts.map +1 -0
- package/dist/communication/projection.js +23 -0
- package/dist/communication/projection.js.map +1 -0
- package/dist/communication/relay.d.ts +78 -0
- package/dist/communication/relay.d.ts.map +1 -0
- package/dist/communication/relay.js +89 -0
- package/dist/communication/relay.js.map +1 -0
- package/dist/communication/session.d.ts +24 -0
- package/dist/communication/session.d.ts.map +1 -0
- package/dist/communication/session.js +100 -0
- package/dist/communication/session.js.map +1 -0
- package/dist/communication/spec.d.ts +20 -0
- package/dist/communication/spec.d.ts.map +1 -0
- package/dist/communication/spec.js +113 -0
- package/dist/communication/spec.js.map +1 -0
- package/dist/communication/tool.d.ts +3 -0
- package/dist/communication/tool.d.ts.map +1 -0
- package/dist/communication/tool.js +23 -0
- package/dist/communication/tool.js.map +1 -0
- package/dist/communication/tools.d.ts +11 -0
- package/dist/communication/tools.d.ts.map +1 -0
- package/dist/communication/tools.js +65 -0
- package/dist/communication/tools.js.map +1 -0
- package/dist/communication/worker.d.ts +6 -0
- package/dist/communication/worker.d.ts.map +1 -0
- package/dist/communication/worker.js +120 -0
- package/dist/communication/worker.js.map +1 -0
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +8 -1
- package/dist/compile.js.map +1 -1
- package/dist/failure-kinds.d.ts +16 -1
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +1 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +4 -1
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +11 -2
- package/dist/journal-client.js.map +1 -1
- package/dist/preflight.d.ts +13 -2
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +23 -6
- package/dist/preflight.js.map +1 -1
- package/dist/protocol.d.ts +39 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/spec.d.ts +8 -0
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +29 -50
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker.d.ts +1 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +10 -1
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-session.d.ts +8 -0
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +156 -23
- package/dist/wrapper-session.js.map +1 -1
- package/package.json +14 -2
- package/src/agent-artifacts.ts +27 -11
- package/src/authored-flow-error.ts +17 -0
- package/src/authored-flow-executor.ts +4 -1
- package/src/authored-node-entry.ts +5 -1
- package/src/authored-node-runner.ts +33 -1
- package/src/authored-root.ts +2 -1
- package/src/authored-source-authority.ts +13 -0
- package/src/authored-step-index.ts +157 -0
- package/src/authored-step-output.ts +66 -14
- package/src/authored-worker-step.ts +88 -15
- package/src/cli/check.ts +69 -11
- package/src/cli/cloud-read.ts +518 -0
- package/src/cli/run.ts +43 -25
- package/src/cli/status.ts +20 -0
- package/src/cli/step-failure.ts +68 -3
- package/src/cli-commands.ts +23 -1
- package/src/cli.ts +20 -1
- package/src/cloud-http.ts +32 -6
- package/src/cloud-read.ts +515 -0
- package/src/cloud-transcript.ts +422 -0
- package/src/communication/environment.ts +33 -0
- package/src/communication/history.ts +38 -0
- package/src/communication/local.ts +41 -0
- package/src/communication/preflight.ts +34 -0
- package/src/communication/projection.ts +23 -0
- package/src/communication/relay.ts +102 -0
- package/src/communication/session.ts +84 -0
- package/src/communication/spec.ts +106 -0
- package/src/communication/tool.ts +22 -0
- package/src/communication/tools.ts +50 -0
- package/src/communication/worker.ts +95 -0
- package/src/compile.ts +4 -1
- package/src/failure-kinds.ts +16 -0
- package/src/index.ts +10 -1
- package/src/journal-client.ts +12 -2
- package/src/preflight.ts +36 -7
- package/src/protocol.ts +15 -0
- package/src/spec.ts +2 -0
- package/src/worker-cli.ts +28 -47
- package/src/worker.ts +10 -0
- package/src/wrapper-session.ts +159 -24
package/src/preflight.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { communicationInstruction } from './communication/spec.js';
|
|
1
2
|
import { loadPlugins, type LoadedPlugin } from './plugin-loader.js';
|
|
2
3
|
import { PluginError } from './plugin-manifest.js';
|
|
3
4
|
import type { FlowSpec, StepSpec, TriggerSpec, McpServerConfig } from './spec.js';
|
|
@@ -32,13 +33,24 @@ export interface CliResolution {
|
|
|
32
33
|
|
|
33
34
|
export interface CliProbeResult {
|
|
34
35
|
exists: boolean;
|
|
35
|
-
authenticated: boolean;
|
|
36
|
+
authenticated: boolean | 'unverified';
|
|
36
37
|
/** False when a custom executable did not identify as a wrapper adapter. */
|
|
37
38
|
supported?: boolean;
|
|
38
39
|
/** Exact declared model passed the CLI's model-scoped readiness probe. */
|
|
39
40
|
modelAvailable?: boolean;
|
|
40
41
|
authCommand?: string;
|
|
41
42
|
modelCommand?: string;
|
|
43
|
+
/** Exit code of the authentication probe, when it failed. */
|
|
44
|
+
authExitCode?: number | null;
|
|
45
|
+
/**
|
|
46
|
+
* Redacted output of a FAILED authentication probe.
|
|
47
|
+
*
|
|
48
|
+
* Present only on failure. Without it a `cli_unauthenticated` refusal can
|
|
49
|
+
* state that a probe exited non-zero and never what it said, which makes an
|
|
50
|
+
* intermittent probe failure impossible to tell apart from a genuinely
|
|
51
|
+
* unauthenticated CLI.
|
|
52
|
+
*/
|
|
53
|
+
authFailureDetail?: string;
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export type CliProbeFailureDetail =
|
|
@@ -70,7 +82,7 @@ export interface PreflightProbes {
|
|
|
70
82
|
* model in scope, so readiness answers "can this CLI use THIS model" rather
|
|
71
83
|
* than the weaker "is this CLI authenticated at all".
|
|
72
84
|
*/
|
|
73
|
-
cli(cli: string, source: CliResolutionSource, model?: string): CliProbeResult;
|
|
85
|
+
cli(cli: string, source: CliResolutionSource, model?: string, execution?: 'managed'): CliProbeResult;
|
|
74
86
|
executor(trigger: TriggerSpec): boolean;
|
|
75
87
|
command(binary: string): boolean;
|
|
76
88
|
}
|
|
@@ -292,7 +304,8 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
|
|
|
292
304
|
continue;
|
|
293
305
|
}
|
|
294
306
|
const resolution = resolutionByStep.get(step.id)!;
|
|
295
|
-
probeResolvedCli(resolution, options.probes, cliProbeResults, diagnostics
|
|
307
|
+
probeResolvedCli(resolution, options.probes, cliProbeResults, diagnostics,
|
|
308
|
+
step.type === 'agent' && communicationInstruction(step.instruction) !== undefined);
|
|
296
309
|
}
|
|
297
310
|
|
|
298
311
|
for (const trigger of compiled.triggers ?? []) {
|
|
@@ -453,6 +466,7 @@ function probeResolvedCli(
|
|
|
453
466
|
probes: PreflightProbes,
|
|
454
467
|
cache: Map<string, CliProbeOutcome>,
|
|
455
468
|
diagnostics: PreflightDiagnostic[],
|
|
469
|
+
managed = false,
|
|
456
470
|
): void {
|
|
457
471
|
// Source is load-bearing: the same relative CLI string resolves from the
|
|
458
472
|
// flow directory for step/named/flow declarations and the config directory for
|
|
@@ -460,11 +474,13 @@ function probeResolvedCli(
|
|
|
460
474
|
// Model is part of the key: the same CLI probed with two different models
|
|
461
475
|
// is two different questions, and caching on the CLI alone would let a
|
|
462
476
|
// model that the CLI cannot resolve inherit an earlier model's pass.
|
|
463
|
-
const cacheKey = JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null]);
|
|
477
|
+
const cacheKey = JSON.stringify([resolution.cli, resolution.source, resolution.model ?? null, managed]);
|
|
464
478
|
let outcome = cache.get(cacheKey);
|
|
465
479
|
if (outcome === undefined) {
|
|
466
480
|
try {
|
|
467
|
-
outcome = { result:
|
|
481
|
+
outcome = { result: managed
|
|
482
|
+
? probes.cli(resolution.cli, resolution.source, resolution.model, 'managed')
|
|
483
|
+
: probes.cli(resolution.cli, resolution.source, resolution.model) };
|
|
468
484
|
} catch (error) {
|
|
469
485
|
const detail = error instanceof CliProbeError ? error.detail : undefined;
|
|
470
486
|
outcome = { failure: detail ?? null };
|
|
@@ -500,14 +516,27 @@ function probeResolvedCli(
|
|
|
500
516
|
cli: resolution.cli,
|
|
501
517
|
message: `Step "${resolution.stepId}" declares CLI "${resolution.cli}", but it is neither a supported raw Claude/Codex executable nor a conforming Relayflows wrapper; custom wrappers must identify with the relayflows-agent-cli-v1 contract.`,
|
|
502
518
|
});
|
|
503
|
-
} else if (
|
|
519
|
+
} else if (managed && result.authenticated === 'unverified') {
|
|
520
|
+
diagnostics.push({ severity: 'warning', kind: 'managed_cli_unverified', stepId: resolution.stepId,
|
|
521
|
+
message: `Step "${resolution.stepId}" uses Relay's interactive CLI transport. The executable exists, but authentication${resolution.model ? ' and access to model "' + resolution.model + '"' : ''} could not be verified before startup. The managed session must execute its task and report completion; startup or task failure fails the step.` });
|
|
522
|
+
} else if (result.authenticated !== true) {
|
|
504
523
|
const command = result.authCommand ?? `${resolution.cli} auth status`;
|
|
524
|
+
// Say what the probe reported. A refusal that names only the command turns
|
|
525
|
+
// a transient provider rejection and a genuinely unauthenticated CLI into
|
|
526
|
+
// the same message, and the difference decides whether retrying is
|
|
527
|
+
// correct.
|
|
528
|
+
const exitCode = result.authExitCode === undefined || result.authExitCode === null
|
|
529
|
+
? ''
|
|
530
|
+
: ` (exit ${result.authExitCode})`;
|
|
531
|
+
const detail = result.authFailureDetail !== undefined && result.authFailureDetail.length > 0
|
|
532
|
+
? ` It reported: ${result.authFailureDetail}`
|
|
533
|
+
: ' It produced no output, so the reason is unavailable.';
|
|
505
534
|
diagnostics.push({
|
|
506
535
|
severity: 'refusal',
|
|
507
536
|
kind: 'cli_unauthenticated',
|
|
508
537
|
stepId: resolution.stepId,
|
|
509
538
|
cli: resolution.cli,
|
|
510
|
-
message: `Step "${resolution.stepId}" declares CLI "${resolution.cli}", but "${command}" exited non-zero; authenticate it or repair that adapter's authentication probe
|
|
539
|
+
message: `Step "${resolution.stepId}" declares CLI "${resolution.cli}", but "${command}" exited non-zero${exitCode}; authenticate it or repair that adapter's authentication probe.${detail}`,
|
|
511
540
|
});
|
|
512
541
|
} else if (resolution.model !== undefined && result.modelAvailable !== true) {
|
|
513
542
|
diagnostics.push({
|
package/src/protocol.ts
CHANGED
|
@@ -55,6 +55,9 @@ export type Verb =
|
|
|
55
55
|
| 'step.wait'
|
|
56
56
|
| 'event.emit'
|
|
57
57
|
| 'event.submit'
|
|
58
|
+
| 'channel.append'
|
|
59
|
+
| 'channel.receive'
|
|
60
|
+
| 'channel.ack'
|
|
58
61
|
| 'stream.append'
|
|
59
62
|
| 'stream.read'
|
|
60
63
|
| 'journal.read';
|
|
@@ -160,6 +163,8 @@ export interface WorkerAttachParams {
|
|
|
160
163
|
step_types: StepType[];
|
|
161
164
|
/** Maximum concurrent assignments. Omitted means the conservative default 1. */
|
|
162
165
|
capacity?: number;
|
|
166
|
+
/** Accept only agent steps declaring every named stream (must also be pinned). */
|
|
167
|
+
required_streams?: string[];
|
|
163
168
|
/**
|
|
164
169
|
* The surfaces this worker holds, as opaque revisions/offsets. Required when
|
|
165
170
|
* `step_types` includes `agent` — an agent attempt's start pins come from
|
|
@@ -411,7 +416,17 @@ export interface JournalReadResult {
|
|
|
411
416
|
}
|
|
412
417
|
|
|
413
418
|
/** Typed map of verb -> { params, result }. Used by the client for type-safety. */
|
|
419
|
+
export interface ChannelIdentity {
|
|
420
|
+
run_id: string; step_id: string; attempt: number; idempotency_key: string; channel: string;
|
|
421
|
+
}
|
|
422
|
+
export interface ChannelEntry {
|
|
423
|
+
seq: number;
|
|
424
|
+
payload: { channel: string; offset: number; message?: unknown; producer?: string; consumer?: string; message_id?: string; delivery_seq?: number };
|
|
425
|
+
}
|
|
414
426
|
export interface VerbContract {
|
|
427
|
+
'channel.append': { params: ChannelIdentity & { message_id: string; message: unknown }; result: ChannelEntry };
|
|
428
|
+
'channel.receive': { params: ChannelIdentity; result: ChannelEntry | null };
|
|
429
|
+
'channel.ack': { params: ChannelIdentity & { delivery_seq: number }; result: ChannelEntry };
|
|
415
430
|
hello: { params: HelloParams; result: HelloResult };
|
|
416
431
|
'run.start': { params: RunStartParams; result: RunStartResult };
|
|
417
432
|
'run.resume': { params: RunResumeParams; result: RunResumeResult };
|
package/src/spec.ts
CHANGED
|
@@ -374,6 +374,8 @@ export interface TriggerSpec {
|
|
|
374
374
|
* is this spec mapped to the kernel dialect (`toKernelSpec`).
|
|
375
375
|
*/
|
|
376
376
|
export interface FlowSpec {
|
|
377
|
+
/** Opt-in live messaging between independent agent steps; compiled into journal channels. */
|
|
378
|
+
communication?: { links: Array<{ from: string; to: string }>; timeoutMs?: number };
|
|
377
379
|
/** Spec schema semver (RFC §7). Compilers always emit latest. */
|
|
378
380
|
version: string;
|
|
379
381
|
name?: string;
|
package/src/worker-cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
2
|
+
import { 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';
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import { wrapperEnvironment } from './wrapper-runtime.js';
|
|
24
24
|
import { redactRelayError } from './redact.js';
|
|
25
25
|
import { applyStepEnvironment } from './step-env.js';
|
|
26
|
-
import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail,
|
|
26
|
+
import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js';
|
|
27
27
|
import {
|
|
28
28
|
runAgentRelayTask,
|
|
29
29
|
AgentRelayTransportError,
|
|
@@ -110,28 +110,23 @@ export async function runAgentCli(
|
|
|
110
110
|
// serialized around their snapshot-spawn-snapshot interval, so one agent's
|
|
111
111
|
// writes are never attributed to a concurrent one in the same directory.
|
|
112
112
|
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
113
|
+
// Bookkeeping written during that interval is not agent-authored content,
|
|
114
|
+
// and when the data dir sits under the cwd (a local `--data-dir` inside the
|
|
115
|
+
// project) all of it lands inside the scanned tree: this process's own
|
|
116
|
+
// transcript file and two `flows status --tail` tails, and the run journals
|
|
117
|
+
// the daemon appends to while the agent runs. So the whole data dir is
|
|
118
|
+
// dropped from the diff. Anything left in reports the kernel's own state
|
|
119
|
+
// back to the kernel as the step's artifacts, where an `artifact_exists`
|
|
120
|
+
// gate then reads it.
|
|
119
121
|
const artifactRoot = mode === 'agent' ? resolve(cwd ?? process.cwd()) : undefined;
|
|
120
122
|
return artifactRoot === undefined
|
|
121
123
|
? execute()
|
|
122
124
|
: serializedByDirectory(artifactRoot, async () => {
|
|
123
125
|
const before = await snapshotWorkspaceFiles(artifactRoot);
|
|
124
126
|
const result = await execute();
|
|
125
|
-
const
|
|
126
|
-
for (const path of ownEvidencePaths(sidechannel)) {
|
|
127
|
-
const real = realPath(path);
|
|
128
|
-
if (real !== undefined) ours.add(real);
|
|
129
|
-
}
|
|
127
|
+
const kernelData = sidechannel === undefined ? undefined : realPath(resolve(sidechannel.dataDir));
|
|
130
128
|
const artifacts = diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot))
|
|
131
|
-
.filter(path =>
|
|
132
|
-
const real = realPath(resolve(artifactRoot, path));
|
|
133
|
-
return real === undefined || !ours.has(real);
|
|
134
|
-
});
|
|
129
|
+
.filter(path => !under(realPath(resolve(artifactRoot, path)), kernelData));
|
|
135
130
|
return { ...result, artifacts };
|
|
136
131
|
});
|
|
137
132
|
|
|
@@ -198,39 +193,25 @@ function openTails(context: SidechannelContext): { stdout: TranscriptTailWriter;
|
|
|
198
193
|
}
|
|
199
194
|
|
|
200
195
|
/**
|
|
201
|
-
*
|
|
202
|
-
* the artifact diff can drop all of them.
|
|
196
|
+
* Whether `path` is `directory` or anything beneath it, both symlink-free.
|
|
203
197
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
198
|
+
* The artifact exclusion is a subtree, not a list of names, because the files
|
|
199
|
+
* to exclude are not all knowable in advance. The attempt's own transcript and
|
|
200
|
+
* tails are (they derive from the attempt identity), but the daemon decides on
|
|
201
|
+
* its own when to write which run journal, and a journal appended to inside
|
|
202
|
+
* the snapshot interval is indistinguishable in a diff from a file the agent
|
|
203
|
+
* wrote. Excluding the directory covers both, and covers them without reading
|
|
204
|
+
* the result: the transcript pointer there is optional — `finish` drops it
|
|
205
|
+
* when the close outruns `TAIL_CLOSE_TIMEOUT_MS` and `discardTranscript` drops
|
|
206
|
+
* it on abort, while the file stays on disk — so an exclusion built from it
|
|
207
|
+
* lost the transcript exactly when the close was slow (flows#495).
|
|
211
208
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
209
|
+
* `false` when there is no data dir to exclude, which is exactly when this
|
|
210
|
+
* process has nowhere to write.
|
|
214
211
|
*/
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const identity = { dataDir: context.dataDir, runId: context.runId, stepId: context.stepId, attempt };
|
|
219
|
-
try {
|
|
220
|
-
// For each tail, both the finished file and the `.tmp` the writer stages
|
|
221
|
-
// and renames over (`transcript-tail.ts`): a close that timed out or a
|
|
222
|
-
// flush that failed can leave the staging file behind, and it is no more
|
|
223
|
-
// agent-authored than the file it was going to become.
|
|
224
|
-
const paths = [transcriptPath(identity, attempt)];
|
|
225
|
-
for (const stream of ['stdout', 'stderr'] as const) {
|
|
226
|
-
const tail = transcriptTailPath(identity, stream);
|
|
227
|
-
paths.push(tail, `${tail}.tmp`);
|
|
228
|
-
}
|
|
229
|
-
return paths;
|
|
230
|
-
} catch {
|
|
231
|
-
// An id the path cannot carry; nothing was written under it either.
|
|
232
|
-
return [];
|
|
233
|
-
}
|
|
212
|
+
function under(path: string | undefined, directory: string | undefined): boolean {
|
|
213
|
+
if (path === undefined || directory === undefined) return false;
|
|
214
|
+
return path === directory || path.startsWith(directory.endsWith(sep) ? directory : directory + sep);
|
|
234
215
|
}
|
|
235
216
|
|
|
236
217
|
/** Symlink-free form of a path, or the path itself when it cannot be resolved. */
|
package/src/worker.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { communicationInstruction } from './communication/spec.js';
|
|
1
2
|
import { workerSpend } from './worker-spend.js';
|
|
2
3
|
import type { WorkerCliResult } from './worker-cli.js';
|
|
3
4
|
import { EventEmitter } from 'node:events';
|
|
@@ -18,6 +19,7 @@ export interface AgentWorkerOptions {
|
|
|
18
19
|
workerId: string;
|
|
19
20
|
pins: Pins;
|
|
20
21
|
capacity?: number;
|
|
22
|
+
requiredStreams?: string[];
|
|
21
23
|
dataDir?: string;
|
|
22
24
|
onPtyReady?: (path: string) => void;
|
|
23
25
|
}
|
|
@@ -60,6 +62,7 @@ export class AgentWorker extends EventEmitter {
|
|
|
60
62
|
['agent'],
|
|
61
63
|
this.options.pins,
|
|
62
64
|
this.options.capacity,
|
|
65
|
+
this.options.requiredStreams,
|
|
63
66
|
);
|
|
64
67
|
this.attached = true;
|
|
65
68
|
} catch (error) {
|
|
@@ -106,6 +109,13 @@ export class AgentWorker extends EventEmitter {
|
|
|
106
109
|
await completeHelperDispatch(this.client, dispatch, helper, this.options.dataDir);
|
|
107
110
|
return;
|
|
108
111
|
}
|
|
112
|
+
const communication = communicationInstruction(spec.instruction);
|
|
113
|
+
if (communication) {
|
|
114
|
+
if (!this.options.dataDir) throw new Error('Agent communication requires a worker data directory');
|
|
115
|
+
const { completeCommunicationDispatch } = await import('./communication/worker.js');
|
|
116
|
+
await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
109
119
|
let humanIntervention = false;
|
|
110
120
|
const effectiveModel = typeof spec.cli === 'string' ? resolveCliModel(spec.cli, spec.model) : spec.model;
|
|
111
121
|
const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal =>
|
package/src/wrapper-session.ts
CHANGED
|
@@ -13,6 +13,14 @@ import {
|
|
|
13
13
|
|
|
14
14
|
export interface WrapperSessionLimits {
|
|
15
15
|
handshakeTimeoutMs: number;
|
|
16
|
+
/**
|
|
17
|
+
* How long the wrapper may work after acknowledging the request, or `0` for
|
|
18
|
+
* no deadline at all — the same convention `CliInvocation.timeoutMs` already
|
|
19
|
+
* uses for a native CLI spawn, where `worker-cli.ts` arms its timer only
|
|
20
|
+
* `if (invocation.timeoutMs > 0)`. `0` is the default, so a wrapper-backed
|
|
21
|
+
* step is bounded by the lease abort exactly as a `claude` or `codex` step
|
|
22
|
+
* is, and not by a constant neither of those pays.
|
|
23
|
+
*/
|
|
16
24
|
executionTimeoutMs: number;
|
|
17
25
|
maxOutputBytes: number;
|
|
18
26
|
}
|
|
@@ -25,7 +33,7 @@ export interface WrapperSessionResult {
|
|
|
25
33
|
|
|
26
34
|
const DEFAULT_LIMITS: WrapperSessionLimits = {
|
|
27
35
|
handshakeTimeoutMs: 10_000,
|
|
28
|
-
executionTimeoutMs:
|
|
36
|
+
executionTimeoutMs: 0,
|
|
29
37
|
maxOutputBytes: 1_048_576,
|
|
30
38
|
};
|
|
31
39
|
const HANDSHAKE_OUTPUT_LIMIT = 8_192;
|
|
@@ -36,6 +44,19 @@ const HANDSHAKE_OUTPUT_LIMIT = 8_192;
|
|
|
36
44
|
* not depend on `'close'`: past this point the reader settles regardless.
|
|
37
45
|
*/
|
|
38
46
|
const SETTLE_AFTER_KILL_MS = 250;
|
|
47
|
+
/**
|
|
48
|
+
* How long a session with NO execution deadline waits, after the wrapper
|
|
49
|
+
* process itself is gone, for its stdio to finish draining.
|
|
50
|
+
*
|
|
51
|
+
* The execution deadline used to be what settled a wrapper that exited while a
|
|
52
|
+
* descendant still held an inherited pipe, because `'close'` waits on every
|
|
53
|
+
* one of them. That job is real; keying it on elapsed time was not. It is
|
|
54
|
+
* keyed on the wrapper's own exit instead, which is the fact that actually
|
|
55
|
+
* matters. Reaching the end of this grace is not proof that a pipe was leaked
|
|
56
|
+
* — buffered output may simply still be in flight — so the grace is a policy
|
|
57
|
+
* and the stop that follows it is bounded, not a diagnosis.
|
|
58
|
+
*/
|
|
59
|
+
const DRAIN_AFTER_EXIT_MS = 250;
|
|
39
60
|
|
|
40
61
|
/**
|
|
41
62
|
* Identify and execute a custom wrapper in one pinned process. No private
|
|
@@ -108,14 +129,29 @@ function executePinnedWrapper(
|
|
|
108
129
|
let phase: 'identity' | 'ack' | 'execute' = 'identity';
|
|
109
130
|
let protocolError: string | undefined;
|
|
110
131
|
let settled = false;
|
|
132
|
+
let outputFinalized = false;
|
|
133
|
+
let childExited = false;
|
|
134
|
+
let exitCode: number | null = null;
|
|
135
|
+
/** The handshake deadline, then the execution deadline if there is one. */
|
|
111
136
|
let lifecycleTimer: NodeJS.Timeout | undefined;
|
|
137
|
+
/** `terminate`'s own settle deadline. */
|
|
112
138
|
let settleTimer: NodeJS.Timeout | undefined;
|
|
139
|
+
/** The post-exit drain grace, armed only when execution is unlimited. */
|
|
140
|
+
let drainTimer: NodeJS.Timeout | undefined;
|
|
141
|
+
/** The settle deadline the drain's own stop owes, after its escalation. */
|
|
142
|
+
let drainSettleTimer: NodeJS.Timeout | undefined;
|
|
143
|
+
// Each deadline owns its own handle. They can be armed at the same time —
|
|
144
|
+
// a drain grace can be running when an over-limit final line terminates
|
|
145
|
+
// the session — and a shared variable would drop the only reference to
|
|
146
|
+
// one of them and leave it pending past the settle.
|
|
113
147
|
|
|
114
148
|
const finish = (result: WrapperSessionResult): void => {
|
|
115
149
|
if (settled) return;
|
|
116
150
|
settled = true;
|
|
117
151
|
if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
|
|
118
152
|
if (settleTimer !== undefined) clearTimeout(settleTimer);
|
|
153
|
+
if (drainTimer !== undefined) clearTimeout(drainTimer);
|
|
154
|
+
if (drainSettleTimer !== undefined) clearTimeout(drainSettleTimer);
|
|
119
155
|
signal?.removeEventListener('abort', onAbort);
|
|
120
156
|
resolve(result);
|
|
121
157
|
};
|
|
@@ -152,6 +188,12 @@ function executePinnedWrapper(
|
|
|
152
188
|
if (protocolError !== undefined) return;
|
|
153
189
|
protocolError = message;
|
|
154
190
|
if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
|
|
191
|
+
lifecycleTimer = undefined;
|
|
192
|
+
// A refusal wins over a drain that was going to report the wrapper's own
|
|
193
|
+
// exit as a success: from here this settle belongs to the deadline armed
|
|
194
|
+
// below, and a drain that still fired would stop the tree a second time.
|
|
195
|
+
if (drainTimer !== undefined) clearTimeout(drainTimer);
|
|
196
|
+
drainTimer = undefined;
|
|
155
197
|
// Same reach as an abort, only gentler first: this stop must find the
|
|
156
198
|
// whole group, or a descendant outlives the session still holding the
|
|
157
199
|
// stdio it inherited.
|
|
@@ -171,6 +213,13 @@ function executePinnedWrapper(
|
|
|
171
213
|
};
|
|
172
214
|
const startExecutionTimer = (): void => {
|
|
173
215
|
if (lifecycleTimer !== undefined) clearTimeout(lifecycleTimer);
|
|
216
|
+
lifecycleTimer = undefined;
|
|
217
|
+
// `0` is no deadline, exactly as `worker-cli.ts` reads
|
|
218
|
+
// `invocation.timeoutMs` for a native CLI. The handshake deadline it
|
|
219
|
+
// just replaced was a liveness check and is over; nothing else is armed,
|
|
220
|
+
// and what bounds the work from here is the lease abort — the same thing
|
|
221
|
+
// that bounds a `claude` or `codex` step.
|
|
222
|
+
if (limits.executionTimeoutMs <= 0) return;
|
|
174
223
|
lifecycleTimer = setTimeout(() => terminate(
|
|
175
224
|
`CLI ${JSON.stringify(cli)} execution timed out after ${limits.executionTimeoutMs}ms.`,
|
|
176
225
|
), limits.executionTimeoutMs);
|
|
@@ -210,6 +259,12 @@ function executePinnedWrapper(
|
|
|
210
259
|
}
|
|
211
260
|
phase = 'execute';
|
|
212
261
|
startExecutionTimer();
|
|
262
|
+
// The wrapper may already be gone: `'exit'` is delivered as soon as the
|
|
263
|
+
// process dies, while the bytes it wrote are still being read out of
|
|
264
|
+
// the pipe. The exit is remembered rather than acted on, and the drain
|
|
265
|
+
// grace starts here, once the acknowledgement it waits behind has
|
|
266
|
+
// actually been consumed.
|
|
267
|
+
startDrainGrace();
|
|
213
268
|
}
|
|
214
269
|
};
|
|
215
270
|
const acceptExecutionData = (chunk: string): void => {
|
|
@@ -228,6 +283,83 @@ function executePinnedWrapper(
|
|
|
228
283
|
}
|
|
229
284
|
};
|
|
230
285
|
const handshakeComplete = (): boolean => phase === 'execute';
|
|
286
|
+
/**
|
|
287
|
+
* Fold the last un-terminated line into the captured output, exactly once.
|
|
288
|
+
*
|
|
289
|
+
* Two settles can now reach this — `'close'` and the drain grace — and the
|
|
290
|
+
* pending buffer may only be spent by one of them. A second finalization
|
|
291
|
+
* that appended it again would duplicate the wrapper's own result, and a
|
|
292
|
+
* buffer left in place would go on being counted against `maxOutputBytes`
|
|
293
|
+
* by any later chunk.
|
|
294
|
+
*/
|
|
295
|
+
const finalizeOutput = (): void => {
|
|
296
|
+
if (outputFinalized) return;
|
|
297
|
+
outputFinalized = true;
|
|
298
|
+
if (protocolError === undefined && phase === 'execute' && executionPending.length > 0) {
|
|
299
|
+
if (normalizeLine(executionPending) === WRAPPER_EXECUTE_TOKEN) {
|
|
300
|
+
protocolError = `CLI ${JSON.stringify(cli)} emitted a duplicate execute protocol frame.`;
|
|
301
|
+
} else if (!exceedsOutputLimit(0)) {
|
|
302
|
+
appendStdout(executionPending);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
executionPending = '';
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* The one result builder. A recorded protocol or output failure outranks
|
|
309
|
+
* the exit status: a wrapper that violated the contract is refused even
|
|
310
|
+
* when the process it ran in went on to exit 0.
|
|
311
|
+
*/
|
|
312
|
+
const executionResult = (code: number | null): WrapperSessionResult => {
|
|
313
|
+
finalizeOutput();
|
|
314
|
+
if (protocolError !== undefined || phase !== 'execute') {
|
|
315
|
+
return failure(
|
|
316
|
+
protocolError
|
|
317
|
+
?? `CLI ${JSON.stringify(cli)} exited before completing the ${WRAPPER_IDENTIFY_TOKEN} same-process handshake.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
exit_code: code,
|
|
322
|
+
stdout_tail: stdout.join(''),
|
|
323
|
+
stderr_tail: Buffer.concat(stderr).toString('utf8'),
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
const drainExpired = (): void => {
|
|
327
|
+
drainTimer = undefined;
|
|
328
|
+
if (settled) return;
|
|
329
|
+
const result = executionResult(exitCode);
|
|
330
|
+
// Finalizing can itself refuse the session — an over-limit final line —
|
|
331
|
+
// and `terminate` has then already stopped the tree and armed its own
|
|
332
|
+
// settle deadline. Stopping or arming a second time here would leave two
|
|
333
|
+
// deadlines racing for one settle.
|
|
334
|
+
if (settleTimer !== undefined) return;
|
|
335
|
+
// The wrapper is gone and `'close'` has still not arrived, so something
|
|
336
|
+
// it left behind is holding stdio it inherited. Stop what is reachable —
|
|
337
|
+
// a settle that releases no pipes lets the step complete while
|
|
338
|
+
// `flows run` never exits — and then own the settle regardless: the
|
|
339
|
+
// force kill has no callback, and a descendant that escaped into its own
|
|
340
|
+
// group before the wrapper died is not traceable to this spawn at all.
|
|
341
|
+
stop.terminate();
|
|
342
|
+
drainSettleTimer = setTimeout(
|
|
343
|
+
() => finish(executionResult(exitCode)),
|
|
344
|
+
FORCE_KILL_DELAY_MS + SETTLE_AFTER_KILL_MS,
|
|
345
|
+
);
|
|
346
|
+
// Settle now if the stop turned out to have nothing to reach; otherwise
|
|
347
|
+
// the deadline above owns it, and a `'close'` that arrives once the
|
|
348
|
+
// group is empty may still settle earlier.
|
|
349
|
+
finishOnChildExit(result);
|
|
350
|
+
};
|
|
351
|
+
/**
|
|
352
|
+
* Arm the post-exit drain, for an unlimited session that has both seen the
|
|
353
|
+
* wrapper exit and consumed its acknowledgement. A session with an explicit
|
|
354
|
+
* execution deadline keeps its existing behaviour: that deadline is what
|
|
355
|
+
* bounds a withheld `'close'` there, and nothing about it changes.
|
|
356
|
+
*/
|
|
357
|
+
const startDrainGrace = (): void => {
|
|
358
|
+
if (limits.executionTimeoutMs > 0) return;
|
|
359
|
+
if (!childExited || phase !== 'execute') return;
|
|
360
|
+
if (settled || drainTimer !== undefined || protocolError !== undefined) return;
|
|
361
|
+
drainTimer = setTimeout(drainExpired, DRAIN_AFTER_EXIT_MS);
|
|
362
|
+
};
|
|
231
363
|
|
|
232
364
|
lifecycleTimer = setTimeout(() => terminate(
|
|
233
365
|
`CLI ${JSON.stringify(cli)} did not identify as ${WRAPPER_IDENTIFY_TOKEN} within ${limits.handshakeTimeoutMs}ms.`,
|
|
@@ -235,7 +367,11 @@ function executePinnedWrapper(
|
|
|
235
367
|
|
|
236
368
|
child.stdout.setEncoding('utf8');
|
|
237
369
|
child.stdout.on('data', (chunk: string) => {
|
|
238
|
-
|
|
370
|
+
// Once the output has been finalized the result is built and the
|
|
371
|
+
// captured bytes are spent. A late chunk — the drain grace has expired
|
|
372
|
+
// and a descendant still owns the pipe — may neither be appended to a
|
|
373
|
+
// result already reported nor re-judged against a limit already applied.
|
|
374
|
+
if (protocolError !== undefined || outputFinalized) return;
|
|
239
375
|
if (phase === 'execute') {
|
|
240
376
|
acceptExecutionData(chunk);
|
|
241
377
|
return;
|
|
@@ -273,7 +409,7 @@ function executePinnedWrapper(
|
|
|
273
409
|
}
|
|
274
410
|
});
|
|
275
411
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
276
|
-
if (protocolError !== undefined || exceedsOutputLimit(chunk.byteLength)) return;
|
|
412
|
+
if (protocolError !== undefined || outputFinalized || exceedsOutputLimit(chunk.byteLength)) return;
|
|
277
413
|
capturedBytes += chunk.byteLength;
|
|
278
414
|
stderr.push(chunk);
|
|
279
415
|
});
|
|
@@ -281,42 +417,41 @@ function executePinnedWrapper(
|
|
|
281
417
|
// A child that closes stdin before acknowledgement is classified on close.
|
|
282
418
|
});
|
|
283
419
|
child.once('error', (error) => finishOnChildExit(failure(error.message)));
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (protocolError !== undefined || phase !== 'execute') {
|
|
293
|
-
finishOnChildExit(failure(
|
|
294
|
-
protocolError
|
|
295
|
-
?? `CLI ${JSON.stringify(cli)} exited before completing the ${WRAPPER_IDENTIFY_TOKEN} same-process handshake.`,
|
|
296
|
-
));
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
finishOnChildExit({
|
|
300
|
-
exit_code: code,
|
|
301
|
-
stdout_tail: stdout.join(''),
|
|
302
|
-
stderr_tail: Buffer.concat(stderr).toString('utf8'),
|
|
303
|
-
});
|
|
420
|
+
// `'exit'` is evidence about the DIRECT CHILD only: the wrapper process is
|
|
421
|
+
// gone, whether or not anything it spawned still holds the stdio it
|
|
422
|
+
// inherited. That is exactly the fact the drain grace waits on, and it is
|
|
423
|
+
// not evidence of a dead group — nothing here settles or stops.
|
|
424
|
+
child.once('exit', (code) => {
|
|
425
|
+
childExited = true;
|
|
426
|
+
exitCode = code;
|
|
427
|
+
startDrainGrace();
|
|
304
428
|
});
|
|
429
|
+
child.once('close', (code) => finishOnChildExit(executionResult(code)));
|
|
305
430
|
});
|
|
306
431
|
}
|
|
307
432
|
|
|
308
433
|
function sessionLimits(overrides: Partial<WrapperSessionLimits>): WrapperSessionLimits {
|
|
309
434
|
return {
|
|
310
435
|
handshakeTimeoutMs: positiveLimit(overrides.handshakeTimeoutMs, DEFAULT_LIMITS.handshakeTimeoutMs),
|
|
311
|
-
executionTimeoutMs:
|
|
436
|
+
executionTimeoutMs: durationLimit(overrides.executionTimeoutMs, DEFAULT_LIMITS.executionTimeoutMs),
|
|
312
437
|
maxOutputBytes: positiveLimit(overrides.maxOutputBytes, DEFAULT_LIMITS.maxOutputBytes),
|
|
313
438
|
};
|
|
314
439
|
}
|
|
315
440
|
|
|
441
|
+
/** A bound a wrapper may not switch off: `0` and below fall back. */
|
|
316
442
|
function positiveLimit(value: number | undefined, fallback: number): number {
|
|
317
443
|
return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
318
444
|
}
|
|
319
445
|
|
|
446
|
+
/**
|
|
447
|
+
* A duration that may be switched off, so `0` is a value and not a missing
|
|
448
|
+
* one. Reusing {@link positiveLimit} here would coerce an explicit "no
|
|
449
|
+
* deadline" back to the fallback, which is how the default became unreachable.
|
|
450
|
+
*/
|
|
451
|
+
function durationLimit(value: number | undefined, fallback: number): number {
|
|
452
|
+
return value !== undefined && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
|
|
453
|
+
}
|
|
454
|
+
|
|
320
455
|
function normalizeLine(line: string): string {
|
|
321
456
|
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
322
457
|
}
|