FN-7838: make CLI agent cold-start timeouts configurable via env vars

Raises Grok and Droid CLI cold-start timeout defaults from 60s to 120s and lets operators override them via environment variables.
- Grok runtime adapter: new GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS env override for the first-output cold-start guard; default raised 60000ms → 120000ms; invalid/non-positive values fall back to the default
- Droid provider: new PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS env override for the first-line cold-start guard; default raised 60000ms → 120000ms; invalid/non-positive values fall back to the default
- 30-minute inactivity safety net left unchanged on both adapters
- Added/updated unit tests covering the new env-driven timeout resolution and fallback behavior for both plugins
- Documented the new settings in docs/settings-reference.md and both plugin READMEs
- Added a minor changeset for @runfusion/fusion

Files changed:
 .changeset/fn-7838-cli-timeout-configurable.md     |  7 ++
 docs/settings-reference.md                         | 13 ++-
 plugins/fusion-plugin-droid-runtime/README.md      |  8 ++
 .../src/__tests__/provider.test.ts                 | 97 +++++++++++++++++++++-
 .../fusion-plugin-droid-runtime/src/provider.ts    | 26 ++++--
 plugins/fusion-plugin-grok-runtime/README.md       |  8 ++
 .../src/__tests__/runtime-adapter.test.ts          | 52 +++++++++++-
 .../src/runtime-adapter.ts                         | 23 ++++-
 8 files changed, 222 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-7838

Fusion-Task-Lineage: 7aad4470-b28f-42bd-be01-8363a1dd05e5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-11 21:17:30 -07:00
parent b3ed63dae9
commit cc743eec38
8 changed files with 222 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: CLI agent cold-start timeouts now default to 2 minutes and are configurable.
category: feature
dev: Grok honors GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS; Droid honors PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS. Defaults raised 60000 → 120000. Inactivity ceilings unchanged.

View File

@@ -1192,7 +1192,18 @@ fn plugin install ./plugins/fusion-plugin-openclaw-runtime
3. Assign the agent to tasks that should use this runtime.
For more details, see the [Paperclip Runtime Plugin documentation](../plugins/fusion-plugin-paperclip-runtime/README.md), [Hermes Runtime Plugin documentation](../plugins/fusion-plugin-hermes-runtime/README.md), and [OpenClaw Runtime Plugin documentation](../plugins/fusion-plugin-openclaw-runtime/README.md).
For more details, see the [Paperclip Runtime Plugin documentation](../plugins/fusion-plugin-paperclip-runtime/README.md), [Hermes Runtime Plugin documentation](../plugins/fusion-plugin-hermes-runtime/README.md), [OpenClaw Runtime Plugin documentation](../plugins/fusion-plugin-openclaw-runtime/README.md), [Grok Runtime Plugin documentation](../plugins/fusion-plugin-grok-runtime/README.md), and [Droid Runtime Plugin documentation](../plugins/fusion-plugin-droid-runtime/README.md).
### CLI Runtime Cold-Start Timeout Configuration
Grok and Droid have first-output cold-start guards for subprocesses that never emit initial stdout. These guards are configured by environment variable only and resolve as: environment variable → built-in default. Blank, non-numeric, zero, or negative values are ignored and the default remains active.
| Runtime | Environment Variable | Default if Unset | Description |
|---|---|---|---|
| Grok | `GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS` | `120000` | Fusion-side cold-start / first-stdout-byte kill ceiling for `grok` headless prompts. |
| Droid | `PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS` | `120000` | Fusion-side cold-start / first-stdout-line kill ceiling for `droid` CLI streams. |
These cold-start guards are distinct from OpenClaw/Hermes `cliTimeoutMs` settings (full-turn subprocess hard kills, default `300000`) and from Grok/Droid's separate 30-minute inactivity safety nets after output has begun.
### OpenClaw Runtime Configuration

View File

@@ -24,6 +24,14 @@ Core implementation files live in `src/`:
- `probe.ts`
- prompt/tool/thinking/control helpers
## Configuration
| Environment variable | Default | Description |
|---|---|---|
| `PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS` | `120000` | Cold-start / first-stdout-line kill ceiling for Droid CLI streams. Blank, non-numeric, zero, or negative values fall back to the default. |
The first-line guard is separate from the 30-minute inactivity safety net that applies after stdout has begun.
## Dashboard UI contribution surfaces
The plugin registers `uiSlots` for:

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
@@ -58,6 +58,12 @@ function makeProc() {
describe("streamViaCli", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS;
});
afterEach(() => {
delete process.env.PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS;
vi.useRealTimers();
});
it("spawns droid and writes prompt", async () => {
@@ -112,4 +118,93 @@ describe("streamViaCli", () => {
expect.objectContaining({ mcpConfigPath: "/tmp/mcp.json" }),
);
});
it("kills the subprocess if no stdout line arrives within the default cold-start ceiling", async () => {
vi.useFakeTimers();
const proc = makeProc();
mocks.spawnDroid.mockReturnValue(proc);
const stream = streamViaCli(
{ id: "droid-pro", provider: "droid-cli" } as any,
{ messages: [{ role: "user", content: "hi" }] } as any,
) as any;
const push = vi.spyOn(stream, "push");
await vi.advanceTimersByTimeAsync(119_999);
expect(mocks.forceKillProcess).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(mocks.forceKillProcess).toHaveBeenCalledWith(proc);
expect(push).toHaveBeenCalledWith(expect.objectContaining({
type: "done",
message: expect.objectContaining({
content: [expect.objectContaining({
text: expect.stringContaining("Droid CLI produced no output within 120s"),
})],
}),
}));
proc.emit("close", null, "SIGKILL");
await Promise.resolve();
});
it("uses PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS when it is a positive integer", async () => {
vi.useFakeTimers();
process.env.PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS = "25";
const proc = makeProc();
mocks.spawnDroid.mockReturnValue(proc);
const stream = streamViaCli(
{ id: "droid-pro", provider: "droid-cli" } as any,
{ messages: [{ role: "user", content: "hi" }] } as any,
) as any;
const push = vi.spyOn(stream, "push");
await vi.advanceTimersByTimeAsync(24);
expect(mocks.forceKillProcess).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(mocks.forceKillProcess).toHaveBeenCalledWith(proc);
expect(push).toHaveBeenCalledWith(expect.objectContaining({
type: "done",
message: expect.objectContaining({
content: [expect.objectContaining({
text: expect.stringContaining("Droid CLI produced no output within 0.025s"),
})],
}),
}));
proc.emit("close", null, "SIGKILL");
await Promise.resolve();
});
it.each(["", " ", "nope", "0", "-1", "1.5"])(
"falls back to the default cold-start ceiling for invalid PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS=%j",
async (value) => {
vi.useFakeTimers();
process.env.PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS = value;
const proc = makeProc();
mocks.spawnDroid.mockReturnValue(proc);
const stream = streamViaCli(
{ id: "droid-pro", provider: "droid-cli" } as any,
{ messages: [{ role: "user", content: "hi" }] } as any,
) as any;
const push = vi.spyOn(stream, "push");
await vi.advanceTimersByTimeAsync(119_999);
expect(mocks.forceKillProcess).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(mocks.forceKillProcess).toHaveBeenCalledWith(proc);
expect(push).toHaveBeenCalledWith(expect.objectContaining({
type: "done",
message: expect.objectContaining({
content: [expect.objectContaining({
text: expect.stringContaining("Droid CLI produced no output within 120s"),
})],
}),
}));
proc.emit("close", null, "SIGKILL");
await Promise.resolve();
},
);
});

View File

@@ -66,10 +66,25 @@ const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
* Cold-start ceiling: kill the subprocess if it hasn't produced a single line
* of stdout within this window. Distinct from INACTIVITY_TIMEOUT_MS so a hung
* binary (no output ever) is reported with a clear cause instead of being
* indistinguishable from a slow-thinking turn. Observed cold-start on a healthy
* droid is ~20s; 60s gives 3x headroom for slow machines / cold caches.
* indistinguishable from a slow-thinking turn.
*
* FNXC:DroidCli 2026-07-11-00:00:
* FN-7838 raises Droid's cold-start ceiling from 60s to 120s because slow/cold first-token starts were killed prematurely. Operators can override the first-line guard with PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS; invalid values fall back to the default so the guard is never disabled. This mirrors the OpenClaw/Hermes *_CLI_TIMEOUT_MS precedence pattern while keeping the 30-minute inactivity safety net separate.
*/
const FIRST_LINE_TIMEOUT_MS = 60_000;
const DEFAULT_FIRST_LINE_TIMEOUT_MS = 120_000;
const PI_DROID_FIRST_LINE_TIMEOUT_ENV = "PI_DROID_CLI_FIRST_LINE_TIMEOUT_MS";
function parsePositiveInteger(value: string | undefined): number | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
if (!Number.isSafeInteger(parsed) || parsed <= 0) return undefined;
return parsed;
}
function resolveFirstLineTimeoutMs(): number {
return parsePositiveInteger(process.env[PI_DROID_FIRST_LINE_TIMEOUT_ENV]) ?? DEFAULT_FIRST_LINE_TIMEOUT_MS;
}
function isDebugStreamEnabled(): boolean {
return process.env.PI_DROID_CLI_DEBUG === "1";
}
@@ -117,6 +132,7 @@ export function streamViaCli(
try {
const cwd = options?.cwd ?? process.cwd();
const firstLineTimeoutMs = resolveFirstLineTimeoutMs();
// Resume if pi provides a session ID AND this isn't the first turn.
// Pi passes sessionId on every call (including first), but we can only
@@ -282,9 +298,9 @@ export function streamViaCli(
if (firstLineReceived) return;
forceKillProcess(proc!);
endStreamWithError(
`Droid CLI produced no output within ${FIRST_LINE_TIMEOUT_MS / 1000}s — likely binary hang or auth failure (try \`droid --version\` and \`droid auth status\`)`,
`Droid CLI produced no output within ${firstLineTimeoutMs / 1000}s — likely binary hang or auth failure (try \`droid --version\` and \`droid auth status\`)`,
);
}, FIRST_LINE_TIMEOUT_MS);
}, firstLineTimeoutMs);
proc.on("close", () => clearTimeout(firstLineTimer));
// Process NDJSON lines from stdout using event-based callback

View File

@@ -39,6 +39,14 @@ grok -p "<text>" --output-format json -m "grok-4.5" --cwd "/path/to/project"
- A wrong-binary/wrong-flag run that emits no parseable JSON surfaces a concrete diagnostic instead of a blank assistant response.
- **Auth implication:** because the `grok` binary resolves its own credentials for this path, a CLI-routed selection needs **no Fusion-visible `GROK_API_KEY`** — unlike the direct xAI OpenAI-compatible streaming path.
## Configuration
| Environment variable | Default | Description |
|---|---|---|
| `GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS` | `120000` | Cold-start / first-stdout-byte kill ceiling for headless `grok` prompts. Blank, non-numeric, zero, or negative values fall back to the default. |
The first-output guard is separate from the 30-minute inactivity safety net that applies after stdout has begun.
See `docs/grok-cli-contract.md` for the full contract, live captures, and the reason Fusion no longer uses the old `grok --prompt <text> --format json` / `step_*` schema or the flaky streaming-json prompt path.
## Routing Grok through the CLI runtime (FN-7725 / FN-7753 / FN-7790)

View File

@@ -334,23 +334,71 @@ describe("GrokRuntimeAdapter", () => {
describe("lifecycle timeouts (fake timers)", () => {
beforeEach(() => {
vi.useFakeTimers();
delete process.env.GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS;
});
afterEach(() => {
delete process.env.GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS;
vi.useRealTimers();
});
it("kills the subprocess and resolves if no stdout line arrives within the cold-start ceiling", async () => {
it("kills the subprocess and resolves if no stdout line arrives within the default cold-start ceiling", async () => {
const { proc, kill } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn });
const { session } = await adapter.createSession({});
const promise = adapter.promptWithFallback(session, "hi");
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(119_999);
expect(kill).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await promise;
expect(kill).toHaveBeenCalledWith("SIGKILL");
expect(session.state.errorMessage).toBe(
"Grok CLI produced no stdout within 120000ms for a headless prompt; the process was killed.",
);
});
it("uses GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS when it is a positive integer", async () => {
process.env.GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS = "25";
const { proc, kill } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn });
const { session } = await adapter.createSession({});
const promise = adapter.promptWithFallback(session, "hi");
await vi.advanceTimersByTimeAsync(24);
expect(kill).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await promise;
expect(kill).toHaveBeenCalledWith("SIGKILL");
expect(session.state.errorMessage).toBe(
"Grok CLI produced no stdout within 25ms for a headless prompt; the process was killed.",
);
});
it.each(["", " ", "nope", "0", "-1", "1.5"])(
"falls back to the default cold-start ceiling for invalid GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS=%j",
async (value) => {
process.env.GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS = value;
const { proc, kill } = makeFakeProc();
const spawn = vi.fn().mockReturnValue(proc);
const adapter = new GrokRuntimeAdapter({ spawn });
const { session } = await adapter.createSession({});
const promise = adapter.promptWithFallback(session, "hi");
await vi.advanceTimersByTimeAsync(119_999);
expect(kill).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await promise;
expect(kill).toHaveBeenCalledWith("SIGKILL");
expect(session.state.errorMessage).toBe(
"Grok CLI produced no stdout within 120000ms for a headless prompt; the process was killed.",
);
},
);
});
it("resolves without throwing if the injected spawn function throws synchronously and records the diagnostic", async () => {

View File

@@ -15,8 +15,24 @@ FN-7753's auto-derived `grok` runtime routing from a `grok-cli/*` model selectio
* bytes within this window, treat it as a hung/failed subprocess and resolve
* (never reject — mirrors the Droid adapter's resolve-on-error lifecycle so pi
* always gets a well-formed, if diagnostic, result instead of an unhandled rejection).
*
* FNXC:GrokCli 2026-07-11-00:00:
* FN-7838 raises Grok's cold-start ceiling from 60s to 120s because slow/cold first-token starts were killed prematurely. Operators can override the first-output guard with GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS; invalid values fall back to the default so the guard is never disabled. This mirrors the OpenClaw/Hermes *_CLI_TIMEOUT_MS precedence pattern while keeping the 30-minute inactivity safety net separate.
*/
const FIRST_OUTPUT_TIMEOUT_MS = 60_000;
const DEFAULT_FIRST_OUTPUT_TIMEOUT_MS = 120_000;
const GROK_FIRST_OUTPUT_TIMEOUT_ENV = "GROK_CLI_FIRST_OUTPUT_TIMEOUT_MS";
function parsePositiveInteger(value: string | undefined): number | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
if (!Number.isSafeInteger(parsed) || parsed <= 0) return undefined;
return parsed;
}
function resolveFirstOutputTimeoutMs(): number {
return parsePositiveInteger(process.env[GROK_FIRST_OUTPUT_TIMEOUT_ENV]) ?? DEFAULT_FIRST_OUTPUT_TIMEOUT_MS;
}
/**
* Inactivity safety net: kill the subprocess if no stdout bytes arrive for
@@ -203,6 +219,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
const cwd = options?.cwd;
const signal = options?.signal;
appendMessage(grokSession, "user", prompt);
const firstOutputTimeoutMs = resolveFirstOutputTimeoutMs();
return new Promise<void>((resolve) => {
let proc: GrokStreamProcess;
@@ -332,11 +349,11 @@ export class GrokRuntimeAdapter implements AgentRuntime {
firstOutputTimer = setTimeout(() => {
if (firstOutputReceived) return;
setErrorMessage(
`Grok CLI produced no stdout within ${FIRST_OUTPUT_TIMEOUT_MS}ms for a headless prompt; the process was killed.`,
`Grok CLI produced no stdout within ${firstOutputTimeoutMs}ms for a headless prompt; the process was killed.`,
);
forceKillGrokStream(proc);
finish();
}, FIRST_OUTPUT_TIMEOUT_MS);
}, firstOutputTimeoutMs);
proc.stdout?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();