@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/authored-root.ts
CHANGED
|
@@ -12,13 +12,13 @@ import {
|
|
|
12
12
|
type LoadedAuthoredFlow,
|
|
13
13
|
type SurfaceModuleAuthority,
|
|
14
14
|
} from './authored-flow-loader.js';
|
|
15
|
-
import { JournalClient } from './journal-client.js';
|
|
15
|
+
import { JournalClient, JournalProtocolError } from './journal-client.js';
|
|
16
16
|
import type { RunOutcome, StepDispatchEvent } from './protocol.js';
|
|
17
17
|
import { SPEC_SCHEMA_VERSION } from './spec.js';
|
|
18
18
|
import type { RunLifecycleOptions } from './cli/run.js';
|
|
19
|
-
import { withWorkerLease } from './worker-lease.js';
|
|
19
|
+
import { isLeaseLost, withWorkerLease } from './worker-lease.js';
|
|
20
20
|
import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
|
|
21
|
-
import { readOpenHumanWaits } from './authored-human.js';
|
|
21
|
+
import { readOpenHumanWaits, resumeCommand } from './authored-human.js';
|
|
22
22
|
import { isSurfaceCompletionReason } from './authored-step-output.js';
|
|
23
23
|
import { readSubscriptionPark } from './authored-subscription-park.js';
|
|
24
24
|
import { localAgentCredentialEnvironment } from './local-agent-environment.js';
|
|
@@ -134,7 +134,7 @@ export async function executeDurableAuthoredFlow(
|
|
|
134
134
|
if (parked !== undefined) return { state: 'suspended', name: metadata.flowName,
|
|
135
135
|
suspension: parked, journalSteps: [], rootRunId: outcome.run_id };
|
|
136
136
|
const dispatch = await dispatchWait.promise;
|
|
137
|
-
return await
|
|
137
|
+
return await driveRootFollowingRetries(loaded, metadata, journal, peer, dispatch, options);
|
|
138
138
|
} finally {
|
|
139
139
|
cancelDispatch();
|
|
140
140
|
peer.close();
|
|
@@ -177,7 +177,7 @@ export async function resumeDurableAuthoredFlow(
|
|
|
177
177
|
if (parked !== undefined) return { state: 'suspended', name: metadata.flowName,
|
|
178
178
|
suspension: parked, journalSteps: [], rootRunId };
|
|
179
179
|
const dispatch = await dispatchWait.promise;
|
|
180
|
-
return await
|
|
180
|
+
return await driveRootFollowingRetries(loaded, metadata, journal, peer, dispatch, options);
|
|
181
181
|
} finally {
|
|
182
182
|
cancelDispatch();
|
|
183
183
|
peer.close();
|
|
@@ -214,6 +214,102 @@ export async function readAuthoredRootMetadata(
|
|
|
214
214
|
return Object.freeze({ ...value, extensions: value.extensions ?? [] });
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* How many times one CLI re-drives a root whose lease it lost. Each re-drive
|
|
219
|
+
* replays completed steps from their journaled receipts, so the bound guards a
|
|
220
|
+
* lease that keeps being lost, not the cost of the work.
|
|
221
|
+
*/
|
|
222
|
+
const MAX_ROOT_REDRIVES = 3;
|
|
223
|
+
/**
|
|
224
|
+
* How long to wait for the kernel to re-dispatch a root whose lease was lost:
|
|
225
|
+
* a full lease (30s, relayflowd LEASE_RENEWAL_MS) in case the daemon still
|
|
226
|
+
* held it, plus its sweep and grace.
|
|
227
|
+
*/
|
|
228
|
+
const ROOT_REDISPATCH_TIMEOUT_MS = 45_000;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Drive the root, and FOLLOW the kernel when it retries the root attempt.
|
|
232
|
+
*
|
|
233
|
+
* A lost root lease is not a failed flow. The kernel journals the attempt
|
|
234
|
+
* `lease_expired` with `disposition: retry` without charging an iteration
|
|
235
|
+
* (relayflowd-core recovery.rs) and re-dispatches the root to the worker
|
|
236
|
+
* still attached on `peer` -- this process. Exiting there with
|
|
237
|
+
* "worker wait canceled" (customer rw_3a0fcb71) threw away a run the kernel
|
|
238
|
+
* was about to continue, including a child agent already on its second
|
|
239
|
+
* attempt: the re-driven body replays completed steps from their receipts and
|
|
240
|
+
* adopts that in-flight child (authored-step-output.ts). Only when no retry
|
|
241
|
+
* arrives does the error leave, as `root_lease_lost` naming the resume.
|
|
242
|
+
*/
|
|
243
|
+
async function driveRootFollowingRetries(
|
|
244
|
+
loaded: LoadedAuthoredFlow,
|
|
245
|
+
metadata: AuthoredRootMetadata,
|
|
246
|
+
journal: JournalClient,
|
|
247
|
+
peer: JournalClient,
|
|
248
|
+
firstDispatch: StepDispatchEvent,
|
|
249
|
+
options: Omit<DurableAuthoredOptions, 'admissionKey'>,
|
|
250
|
+
): Promise<DurableAuthoredFlowResult> {
|
|
251
|
+
let dispatch = firstDispatch;
|
|
252
|
+
for (let redrives = 0; ; redrives += 1) {
|
|
253
|
+
// Listen before driving: the retry can be dispatched the moment the
|
|
254
|
+
// daemon sweeps the lease, before the aborted body has unwound. The
|
|
255
|
+
// timeout starts only once there is a lost lease to wait on.
|
|
256
|
+
const next = rootRedispatch(peer);
|
|
257
|
+
try {
|
|
258
|
+
const result = await driveRoot(loaded, metadata, journal, peer, dispatch, options);
|
|
259
|
+
next.cancel();
|
|
260
|
+
return result;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// `run_terminal` from a step verb means the run has already reached a
|
|
263
|
+
// terminal state. It is lease-shaped for worker cleanup, but the kernel
|
|
264
|
+
// cannot re-dispatch this root, so do not turn it into a 45s retry wait.
|
|
265
|
+
const terminal = error instanceof JournalProtocolError && error.code === 'run_terminal';
|
|
266
|
+
if (!isLeaseLost(error) || terminal || options.lifecycle?.signal?.aborted === true) {
|
|
267
|
+
next.cancel();
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
const lost = sentence(error instanceof Error ? error.message : String(error));
|
|
271
|
+
const resume = resumeCommand(dispatch.run_id, options.dataDir, options.localAgentStream !== undefined);
|
|
272
|
+
if (redrives >= MAX_ROOT_REDRIVES) {
|
|
273
|
+
next.cancel();
|
|
274
|
+
throw rootLeaseLost(dispatch, `${lost} It was lost ${redrives + 1} times in this process; continue the run with: ${resume}`, error);
|
|
275
|
+
}
|
|
276
|
+
process.emitWarning(
|
|
277
|
+
`authored root run_id=${dispatch.run_id} attempt=${dispatch.attempt}: ${lost} Waiting for the kernel to retry it.`,
|
|
278
|
+
{ code: 'FLOWS_ROOT_LEASE_LOST' },
|
|
279
|
+
);
|
|
280
|
+
try {
|
|
281
|
+
dispatch = await next.wait(ROOT_REDISPATCH_TIMEOUT_MS, options.lifecycle?.signal);
|
|
282
|
+
} catch (waitError) {
|
|
283
|
+
// A caller cancel while waiting is the caller's, not a lost lease.
|
|
284
|
+
if (aborted(options.lifecycle?.signal)) throw waitError;
|
|
285
|
+
throw rootLeaseLost(dispatch, `${lost} The kernel did not re-dispatch the root within ${ROOT_REDISPATCH_TIMEOUT_MS / 1000}s; continue the run with: ${resume}`, error);
|
|
286
|
+
}
|
|
287
|
+
if (dispatch.run_id !== firstDispatch.run_id) {
|
|
288
|
+
throw new Error('authored root retry was dispatched for a different run');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Read at call time: `signal.aborted` changes while this function awaits. */
|
|
295
|
+
function aborted(signal: AbortSignal | undefined): boolean {
|
|
296
|
+
return signal?.aborted === true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sentence(text: string): string {
|
|
300
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function rootLeaseLost(dispatch: StepDispatchEvent, message: string, cause: unknown): AuthoredFlowExecutionError {
|
|
304
|
+
const error = new AuthoredFlowExecutionError('root_lease_lost',
|
|
305
|
+
`authored root run ${dispatch.run_id} lost its worker lease on attempt ${dispatch.attempt}. ${message}`
|
|
306
|
+
+ ' Completed steps are journaled and are not re-run.',
|
|
307
|
+
'lease_expired');
|
|
308
|
+
error.rootRunId = dispatch.run_id;
|
|
309
|
+
(error as { cause?: unknown }).cause = cause;
|
|
310
|
+
return error;
|
|
311
|
+
}
|
|
312
|
+
|
|
217
313
|
async function driveRoot(
|
|
218
314
|
loaded: LoadedAuthoredFlow,
|
|
219
315
|
metadata: AuthoredRootMetadata,
|
|
@@ -269,6 +365,9 @@ async function driveRoot(
|
|
|
269
365
|
);
|
|
270
366
|
return Object.freeze({ ...result, rootRunId: dispatch.run_id });
|
|
271
367
|
} catch (error) {
|
|
368
|
+
// A lost lease is owned by the kernel's retry path, not by this attempt's
|
|
369
|
+
// terminalization or suspension handling.
|
|
370
|
+
if (isLeaseLost(error)) throw error;
|
|
272
371
|
if (error instanceof AuthoredFlowExecutionError
|
|
273
372
|
&& error.code === 'subscription_suspended'
|
|
274
373
|
&& error.suspension !== undefined) {
|
|
@@ -376,7 +475,51 @@ function hasRootStream(step: Record<string, unknown>): boolean {
|
|
|
376
475
|
try { rootStreamFromMetadata(step); return true; } catch { return false; }
|
|
377
476
|
}
|
|
378
477
|
|
|
379
|
-
|
|
478
|
+
/**
|
|
479
|
+
* Buffers the next root dispatch from the moment it is created; `wait`
|
|
480
|
+
* bounds only the time spent waiting for it. Unlike `nextRootDispatch`, no
|
|
481
|
+
* timer runs while the body is being driven -- a body longer than the
|
|
482
|
+
* timeout must not leave a rejected promise nobody awaits.
|
|
483
|
+
*/
|
|
484
|
+
function rootRedispatch(peer: JournalClient): {
|
|
485
|
+
wait(timeoutMs: number, signal?: AbortSignal): Promise<StepDispatchEvent>;
|
|
486
|
+
cancel(): void;
|
|
487
|
+
} {
|
|
488
|
+
let received: StepDispatchEvent | undefined;
|
|
489
|
+
let deliver: ((dispatch: StepDispatchEvent) => void) | undefined;
|
|
490
|
+
const onDispatch = (dispatch: StepDispatchEvent): void => {
|
|
491
|
+
if (dispatch.step_id !== 'authored-root') return;
|
|
492
|
+
peer.off('step.dispatch', onDispatch);
|
|
493
|
+
if (deliver === undefined) received = dispatch;
|
|
494
|
+
else deliver(dispatch);
|
|
495
|
+
};
|
|
496
|
+
peer.on('step.dispatch', onDispatch);
|
|
497
|
+
const cancel = (): void => { peer.off('step.dispatch', onDispatch); };
|
|
498
|
+
return {
|
|
499
|
+
cancel,
|
|
500
|
+
wait: (timeoutMs, signal) => new Promise((resolve, reject) => {
|
|
501
|
+
if (received !== undefined) { resolve(received); return; }
|
|
502
|
+
const settle = (): void => {
|
|
503
|
+
clearTimeout(timer);
|
|
504
|
+
signal?.removeEventListener('abort', onAbort);
|
|
505
|
+
cancel();
|
|
506
|
+
};
|
|
507
|
+
const onAbort = (): void => {
|
|
508
|
+
settle();
|
|
509
|
+
reject(new Error('waiting for the authored root to be re-dispatched was canceled', { cause: signal?.reason }));
|
|
510
|
+
};
|
|
511
|
+
const timer = setTimeout(() => {
|
|
512
|
+
settle();
|
|
513
|
+
reject(new Error('authored root was not re-dispatched'));
|
|
514
|
+
}, timeoutMs);
|
|
515
|
+
deliver = dispatch => { settle(); resolve(dispatch); };
|
|
516
|
+
if (signal?.aborted === true) onAbort();
|
|
517
|
+
else signal?.addEventListener('abort', onAbort, { once: true });
|
|
518
|
+
}),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function nextRootDispatch(peer: JournalClient, timeoutMs = 30_000): {
|
|
380
523
|
promise: Promise<StepDispatchEvent>;
|
|
381
524
|
cancel(): void;
|
|
382
525
|
} {
|
|
@@ -385,7 +528,7 @@ function nextRootDispatch(peer: JournalClient): {
|
|
|
385
528
|
const timer = setTimeout(() => {
|
|
386
529
|
peer.off('step.dispatch', onDispatch);
|
|
387
530
|
reject(new Error('authored root was not dispatched'));
|
|
388
|
-
},
|
|
531
|
+
}, timeoutMs);
|
|
389
532
|
const onDispatch = (dispatch: StepDispatchEvent) => {
|
|
390
533
|
if (dispatch.step_id !== 'authored-root') return;
|
|
391
534
|
clearTimeout(timer);
|
|
@@ -343,6 +343,7 @@ function stepDetails(
|
|
|
343
343
|
...(found.completionReason === undefined ? {} : { completionReason: found.completionReason }),
|
|
344
344
|
...(found.attempt === undefined ? {} : { attempt: found.attempt }),
|
|
345
345
|
...(found.maxIterations === undefined ? {} : { maxIterations: found.maxIterations }),
|
|
346
|
+
...(found.unchargedAttempts === undefined ? {} : { unchargedAttempts: found.unchargedAttempts }),
|
|
346
347
|
...(found.exitCode === undefined ? {} : { exitCode: found.exitCode }),
|
|
347
348
|
...(found.stdoutTail === undefined ? {} : { stdoutTail: found.stdoutTail }),
|
|
348
349
|
...(found.stderrTail === undefined ? {} : { stderrTail: found.stderrTail }),
|
package/src/child-stop.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { execFileSync, type ChildProcess } from 'node:child_process';
|
|
|
6
6
|
* the `flows run` event loop alive long after the step itself has settled.
|
|
7
7
|
*/
|
|
8
8
|
export const FORCE_KILL_DELAY_MS = 1_000;
|
|
9
|
+
/** Bound for proving that a forced process group has actually disappeared. */
|
|
10
|
+
export const GROUP_EXIT_CONFIRM_TIMEOUT_MS = 1_000;
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* The one stop path for a spawned agent process.
|
|
@@ -65,6 +67,7 @@ export function childStop(
|
|
|
65
67
|
child: ChildProcess,
|
|
66
68
|
ownsGroup: boolean,
|
|
67
69
|
forceKillDelayMs: number = FORCE_KILL_DELAY_MS,
|
|
70
|
+
onStopped: (error?: Error) => void = () => {},
|
|
68
71
|
): ChildStop {
|
|
69
72
|
// Pinned at the spawn rather than read per signal. Every interesting use of
|
|
70
73
|
// this id happens AFTER the direct child has been reaped — the escalation
|
|
@@ -74,6 +77,17 @@ export function childStop(
|
|
|
74
77
|
// which is exactly the window both of those need to address.
|
|
75
78
|
const pid = child.pid;
|
|
76
79
|
let forceTimer: NodeJS.Timeout | undefined;
|
|
80
|
+
let groupPoll: NodeJS.Timeout | undefined;
|
|
81
|
+
let forced = false;
|
|
82
|
+
let forcedAt: number | undefined;
|
|
83
|
+
let stopped = false;
|
|
84
|
+
const notifyStopped = (error?: Error): void => {
|
|
85
|
+
if (stopped) return;
|
|
86
|
+
stopped = true;
|
|
87
|
+
if (groupPoll !== undefined) clearTimeout(groupPoll);
|
|
88
|
+
groupPoll = undefined;
|
|
89
|
+
onStopped(error);
|
|
90
|
+
};
|
|
77
91
|
const cancel = (): void => {
|
|
78
92
|
if (forceTimer !== undefined) clearTimeout(forceTimer);
|
|
79
93
|
forceTimer = undefined;
|
|
@@ -106,6 +120,21 @@ export function childStop(
|
|
|
106
120
|
}
|
|
107
121
|
});
|
|
108
122
|
};
|
|
123
|
+
const awaitGroupExit = (): void => {
|
|
124
|
+
if (!groupAnswers()) {
|
|
125
|
+
notifyStopped();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (forcedAt !== undefined && Date.now() - forcedAt >= GROUP_EXIT_CONFIRM_TIMEOUT_MS) {
|
|
129
|
+
notifyStopped(new Error(`Process groups did not stop answering within ${GROUP_EXIT_CONFIRM_TIMEOUT_MS}ms after SIGKILL.`));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (groupPoll !== undefined) return;
|
|
133
|
+
groupPoll = setTimeout(() => {
|
|
134
|
+
groupPoll = undefined;
|
|
135
|
+
awaitGroupExit();
|
|
136
|
+
}, 10);
|
|
137
|
+
};
|
|
109
138
|
const signalTree = (name: NodeJS.Signals): void => {
|
|
110
139
|
if (ownsGroup && pid !== undefined) {
|
|
111
140
|
escapedGroups ??= descendantGroups(pid);
|
|
@@ -127,6 +156,9 @@ export function childStop(
|
|
|
127
156
|
kill: (): void => {
|
|
128
157
|
cancel();
|
|
129
158
|
signalTree('SIGKILL');
|
|
159
|
+
forced = true;
|
|
160
|
+
forcedAt ??= Date.now();
|
|
161
|
+
awaitGroupExit();
|
|
130
162
|
},
|
|
131
163
|
terminate: (): void => {
|
|
132
164
|
cancel();
|
|
@@ -134,6 +166,9 @@ export function childStop(
|
|
|
134
166
|
forceTimer = setTimeout(() => {
|
|
135
167
|
forceTimer = undefined;
|
|
136
168
|
signalTree('SIGKILL');
|
|
169
|
+
forced = true;
|
|
170
|
+
forcedAt ??= Date.now();
|
|
171
|
+
awaitGroupExit();
|
|
137
172
|
}, forceKillDelayMs);
|
|
138
173
|
// Deliberately REFERENCED, unlike every other timer we arm. The survivor
|
|
139
174
|
// this escalation exists for is the one that ignored `SIGTERM` and holds
|
|
@@ -144,9 +179,13 @@ export function childStop(
|
|
|
144
179
|
// `maySettleOnChildExit` confirms the group is empty.
|
|
145
180
|
},
|
|
146
181
|
maySettleOnChildExit: (): boolean => {
|
|
147
|
-
if (forceTimer === undefined)
|
|
182
|
+
if (forceTimer === undefined && !forced) {
|
|
183
|
+
notifyStopped();
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
148
186
|
if (groupAnswers()) return false;
|
|
149
187
|
cancel();
|
|
188
|
+
notifyStopped();
|
|
150
189
|
return true;
|
|
151
190
|
},
|
|
152
191
|
};
|
package/src/cli/add-extension.ts
CHANGED
|
@@ -36,7 +36,7 @@ function eventKeys(manifest: FlowExtensionManifest): readonly string[] {
|
|
|
36
36
|
|
|
37
37
|
function formatBudget(budget: FlowExtensionManifest['permissions']['budget']): string {
|
|
38
38
|
if (budget === undefined) return 'inherits base';
|
|
39
|
-
return [budget.dollars === undefined ? '' : `$${budget.dollars}`, budget.wallclock ?? ''].filter(Boolean).join(' / ') || 'inherits base';
|
|
39
|
+
return [budget.tokens === undefined ? '' : `${budget.tokens} tokens`, budget.dollars === undefined ? '' : `$${budget.dollars}`, budget.wallclock ?? ''].filter(Boolean).join(' / ') || 'inherits base';
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function describeExtension(manifest: FlowExtensionManifest): string[] {
|
package/src/cli/check.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { communicationInstruction } from '../communication/spec.js';
|
|
2
|
+
import { rememberResolvedCliIdentities } from '../resolved-cli-identity.js';
|
|
2
3
|
import { checkCommunicationEnvironment } from '../communication/preflight.js';
|
|
3
4
|
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
4
5
|
import { dirname, isAbsolute, join, parse as parsePath, resolve } from 'node:path';
|
|
@@ -15,6 +16,7 @@ import { parseMcpConfig } from '../mcp-config.js';
|
|
|
15
16
|
import type { StepGateInspection } from '../gate-contract.js';
|
|
16
17
|
import type { CheckFailureKind, CheckWarningKind } from '../failure-kinds.js';
|
|
17
18
|
import {
|
|
19
|
+
cliProbeKey,
|
|
18
20
|
preflight,
|
|
19
21
|
type CliResolution,
|
|
20
22
|
type CliProbeResult,
|
|
@@ -202,11 +204,15 @@ export function checkAuthoredFlow(
|
|
|
202
204
|
const projectConfig = options !== undefined
|
|
203
205
|
? options.projectConfig
|
|
204
206
|
: projectConfigOrOptions as ProjectConfig | undefined;
|
|
205
|
-
|
|
207
|
+
// Keep the exact probe result so the executable identity selected during
|
|
208
|
+
// readiness is the one written onto the admitted step. Without an
|
|
209
|
+
// explicit map, preflight would allocate a private cache and a bare CLI
|
|
210
|
+
// name could be resolved again under the later worker cwd.
|
|
211
|
+
const effectiveProbeCache = cliProbeCache ?? options?.probeCache ?? new Map<string, CliProbeOutcome>();
|
|
206
212
|
const config = projectConfig ?? readProjectConfig(dirname(absolutePath));
|
|
207
213
|
const probes = systemProbes(dirname(absolutePath), config, invocation.environment);
|
|
208
214
|
const result = preflight(authoring, {
|
|
209
|
-
|
|
215
|
+
cliProbeCache: effectiveProbeCache,
|
|
210
216
|
projectCli: config.cli,
|
|
211
217
|
projectConfigPath: config.path,
|
|
212
218
|
projectSearchStart: dirname(absolutePath),
|
|
@@ -220,6 +226,7 @@ export function checkAuthoredFlow(
|
|
|
220
226
|
result.resolutions,
|
|
221
227
|
dirname(absolutePath),
|
|
222
228
|
config.directory,
|
|
229
|
+
effectiveProbeCache,
|
|
223
230
|
)
|
|
224
231
|
: undefined;
|
|
225
232
|
if (flow?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
|
|
@@ -472,20 +479,34 @@ function bindResolvedCliPaths(
|
|
|
472
479
|
resolutions: readonly CliResolution[],
|
|
473
480
|
flowDirectory: string,
|
|
474
481
|
configDirectory: string,
|
|
482
|
+
probeCache: ReadonlyMap<string, CliProbeOutcome>,
|
|
475
483
|
): FlowSpec {
|
|
476
484
|
const byStep = new Map(resolutions.map((resolution) => [resolution.stepId, resolution]));
|
|
477
|
-
|
|
485
|
+
const identities = new Map<string, string>();
|
|
486
|
+
const bound: FlowSpec = {
|
|
478
487
|
...flow,
|
|
479
488
|
steps: flow.steps.map((step) => {
|
|
480
489
|
if (step.type === 'deterministic') return step;
|
|
481
490
|
const resolution = byStep.get(step.id);
|
|
482
491
|
if (resolution === undefined) return step;
|
|
483
492
|
const directory = resolution.source === 'project' ? configDirectory : flowDirectory;
|
|
484
|
-
|
|
493
|
+
const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined;
|
|
494
|
+
const outcome = probeCache.get(cliProbeKey(resolution, managed));
|
|
495
|
+
const executable = outcome !== undefined && 'result' in outcome
|
|
496
|
+
? outcome.result.executable
|
|
497
|
+
: undefined;
|
|
498
|
+
const boundCli = executable !== undefined && isAbsolute(executable)
|
|
499
|
+
? executable
|
|
500
|
+
: canonicalCli(resolution.cli, directory);
|
|
501
|
+
if (executable !== undefined && isAbsolute(executable)) {
|
|
502
|
+
identities.set(step.id, resolution.cli);
|
|
503
|
+
}
|
|
504
|
+
return { ...step, cli: boundCli,
|
|
485
505
|
...(resolution.modelSource === 'adapter' && resolution.model !== undefined
|
|
486
506
|
? { model: resolution.model } : {}) };
|
|
487
507
|
}),
|
|
488
508
|
};
|
|
509
|
+
return rememberResolvedCliIdentities(bound, identities);
|
|
489
510
|
}
|
|
490
511
|
|
|
491
512
|
function canonicalCli(cli: string, directory: string): string {
|
package/src/cli/cli-probe.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { accessSync, constants } from 'node:fs';
|
|
2
|
-
import { isAbsolute, resolve } from 'node:path';
|
|
1
|
+
import { accessSync, constants, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, isAbsolute, resolve } from 'node:path';
|
|
3
3
|
import { execFile, spawnSync } from 'node:child_process';
|
|
4
4
|
import { agentEnvironment, brokerEnvironment } from '../communication/environment.js';
|
|
5
5
|
import { adapterIdentification, authenticationProbe, classifyModelProbeFailure, cliAdapterKind,
|
|
6
6
|
displayInvocation, modelReadinessProbe, type CliInvocation } from '../cli-adapter.js';
|
|
7
7
|
import { MODEL_ENV } from '../worker-cli.js';
|
|
8
8
|
import { CliProbeError, type CliProbeResult } from '../preflight.js';
|
|
9
|
+
import { pinCliAlias, pinCliAliasSync } from './pinned-cli-alias.js';
|
|
9
10
|
|
|
10
11
|
interface ProbeRequest {
|
|
12
|
+
argv0?: string;
|
|
11
13
|
executable: string;
|
|
12
14
|
directory: string;
|
|
13
15
|
invocation: CliInvocation;
|
|
@@ -35,12 +37,19 @@ function driveSync<T>(sequence: Generator<ProbeRequest, T, ProbeOutput>): T {
|
|
|
35
37
|
let next = sequence.next();
|
|
36
38
|
while (!next.done) {
|
|
37
39
|
const request = next.value;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
const pinned = pinCliAliasSync(request.executable, request.argv0 ?? basename(request.executable));
|
|
41
|
+
const result = (() => {
|
|
42
|
+
try {
|
|
43
|
+
return spawnSync(pinned.executable, request.invocation.args, {
|
|
44
|
+
...probeOptions(request),
|
|
45
|
+
// Preserve the old synchronous probe contract: provider CLIs must not
|
|
46
|
+
// inherit a readable stdin that can block auth/identify probes.
|
|
47
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
+
});
|
|
49
|
+
} finally {
|
|
50
|
+
pinned.release();
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
44
53
|
const failure = classifySpawnFailure(result.error, result.signal, request.invocation.timeoutMs);
|
|
45
54
|
if (failure !== undefined) throw failure;
|
|
46
55
|
next = sequence.next({ status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' });
|
|
@@ -48,30 +57,35 @@ function driveSync<T>(sequence: Generator<ProbeRequest, T, ProbeOutput>): T {
|
|
|
48
57
|
return next.value;
|
|
49
58
|
}
|
|
50
59
|
|
|
51
|
-
function probeOptions({ directory, invocation, environment }: ProbeRequest) {
|
|
60
|
+
function probeOptions({ argv0, directory, invocation, environment }: ProbeRequest) {
|
|
52
61
|
const env = { ...environment };
|
|
53
62
|
delete env[MODEL_ENV];
|
|
54
63
|
if (invocation.modelEnv !== undefined) env[MODEL_ENV] = invocation.modelEnv;
|
|
55
64
|
return { cwd: directory, encoding: 'utf8' as const, timeout: invocation.timeoutMs,
|
|
56
|
-
maxBuffer: 1024 * 1024, env };
|
|
65
|
+
maxBuffer: 1024 * 1024, env, ...(argv0 === undefined ? {} : { argv0 }) };
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
function runProbeAsync(request: ProbeRequest): Promise<ProbeOutput> {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
error
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
async function runProbeAsync(request: ProbeRequest): Promise<ProbeOutput> {
|
|
69
|
+
const pinned = await pinCliAlias(request.executable, request.argv0 ?? basename(request.executable));
|
|
70
|
+
try {
|
|
71
|
+
return await new Promise((resolve, reject) => {
|
|
72
|
+
const child = execFile(pinned.executable, request.invocation.args, probeOptions(request),
|
|
73
|
+
(error, stdout, stderr) => {
|
|
74
|
+
// Numeric exit codes are probe results; launch, timeout, signal and buffer
|
|
75
|
+
// errors are failures to collect a fact, just as in the synchronous driver.
|
|
76
|
+
if (error?.killed && typeof error.code !== 'string') return reject(new CliProbeError(`timeout:${request.invocation.timeoutMs}ms`));
|
|
77
|
+
const failure = classifySpawnFailure(
|
|
78
|
+
error !== null && typeof error.code !== 'number' && error.signal == null ? error : undefined,
|
|
79
|
+
error?.signal ?? null, request.invocation.timeoutMs);
|
|
80
|
+
if (failure !== undefined) return reject(failure);
|
|
81
|
+
resolve({ status: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
|
|
82
|
+
stdout, stderr });
|
|
83
|
+
});
|
|
84
|
+
child.stdin?.end();
|
|
85
|
+
});
|
|
86
|
+
} finally {
|
|
87
|
+
await pinned.release();
|
|
88
|
+
}
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
function* probeSequence(
|
|
@@ -83,17 +97,27 @@ function* probeSequence(
|
|
|
83
97
|
): Generator<ProbeRequest, CliProbeResult, ProbeOutput> {
|
|
84
98
|
const executable = yield* executableSequence(cli, directory, sourceEnvironment);
|
|
85
99
|
if (executable === undefined) return { exists: false, authenticated: false };
|
|
86
|
-
|
|
100
|
+
// The declaration selects the adapter; the canonical executable selects
|
|
101
|
+
// the bytes. Package-manager links commonly name `claude` or `codex` while
|
|
102
|
+
// targeting a generic `cli.js`, whose basename must not rewrite the adapter
|
|
103
|
+
// contract after resolution.
|
|
104
|
+
const kind = cliAdapterKind(cli);
|
|
87
105
|
// Relay owns interactive CLI launch/injection. Its generic PTY path is not
|
|
88
106
|
// the headless wrapper protocol; do not demand that protocol from Gemini,
|
|
89
107
|
// Cursor, OpenCode, or other interactive tools. Never invent an auth pass.
|
|
90
108
|
if (execution === 'managed' && kind === 'relayflows-wrapper-v1') {
|
|
91
|
-
return { exists: true, supported: true, authenticated: 'unverified' };
|
|
109
|
+
return { exists: true, supported: true, authenticated: 'unverified', executable };
|
|
92
110
|
}
|
|
93
111
|
const environment = execution === 'managed'
|
|
94
|
-
? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(
|
|
112
|
+
? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment) }
|
|
95
113
|
: sourceEnvironment;
|
|
96
|
-
const probe = (invocation: CliInvocation): ProbeRequest => ({
|
|
114
|
+
const probe = (invocation: CliInvocation): ProbeRequest => ({
|
|
115
|
+
executable,
|
|
116
|
+
argv0: basename(cli),
|
|
117
|
+
directory,
|
|
118
|
+
invocation,
|
|
119
|
+
environment,
|
|
120
|
+
});
|
|
97
121
|
const identification = adapterIdentification(kind);
|
|
98
122
|
const identified = yield probe(identification.invocation);
|
|
99
123
|
if (
|
|
@@ -110,6 +134,7 @@ function* probeSequence(
|
|
|
110
134
|
exists: true,
|
|
111
135
|
supported: true,
|
|
112
136
|
authenticated: (yield probe(auth)).status === 0,
|
|
137
|
+
executable,
|
|
113
138
|
authCommand,
|
|
114
139
|
};
|
|
115
140
|
}
|
|
@@ -126,6 +151,7 @@ function* probeSequence(
|
|
|
126
151
|
supported: true,
|
|
127
152
|
authenticated: true,
|
|
128
153
|
modelAvailable: true,
|
|
154
|
+
executable,
|
|
129
155
|
authCommand,
|
|
130
156
|
modelCommand,
|
|
131
157
|
};
|
|
@@ -145,6 +171,7 @@ function* probeSequence(
|
|
|
145
171
|
supported: true,
|
|
146
172
|
authenticated,
|
|
147
173
|
modelAvailable: false,
|
|
174
|
+
executable,
|
|
148
175
|
authCommand,
|
|
149
176
|
modelCommand,
|
|
150
177
|
modelExitCode: scopedProbe.status,
|
|
@@ -181,16 +208,25 @@ function redactProbeOutput(text: string): string {
|
|
|
181
208
|
function* executableSequence(command: string, directory: string, environment: NodeJS.ProcessEnv): Generator<ProbeRequest, string | undefined, ProbeOutput> {
|
|
182
209
|
if (command.includes('/') || isAbsolute(command)) {
|
|
183
210
|
const path = isAbsolute(command) ? command : resolve(directory, command);
|
|
184
|
-
|
|
185
|
-
accessSync(path, constants.X_OK);
|
|
186
|
-
return path;
|
|
187
|
-
} catch {
|
|
188
|
-
return undefined;
|
|
189
|
-
}
|
|
211
|
+
return canonicalExecutable(path);
|
|
190
212
|
}
|
|
191
213
|
const result = yield { executable: 'which', directory: process.cwd(),
|
|
192
214
|
invocation: { args: [command], timeoutMs: 5_000 }, environment };
|
|
193
|
-
return result.status === 0
|
|
215
|
+
return result.status === 0
|
|
216
|
+
? canonicalExecutable(resolve(process.cwd(), result.stdout.trim()))
|
|
217
|
+
: undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function canonicalExecutable(path: string): string | undefined {
|
|
221
|
+
try {
|
|
222
|
+
accessSync(path, constants.X_OK);
|
|
223
|
+
// Dispatch must retain the exact target that readiness probed. Keeping a
|
|
224
|
+
// symlink path would allow the link to be retargeted between admission and
|
|
225
|
+
// worker spawn even though the compiled step carries an absolute path.
|
|
226
|
+
return realpathSync(path);
|
|
227
|
+
} catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
194
230
|
}
|
|
195
231
|
|
|
196
232
|
function classifySpawnFailure(
|