Repository navigation
fix(agent): fail loud when an agent dispatch delivers nothing #8846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
9753b66
fix(agent): fail loud when an agent dispatch delivers nothing
Dongni-Yang 0c2ede8
fix(agent): make the empty-dispatch recovery command runnable
Dongni-Yang 5243690
fix(agent): redact the empty-dispatch recovery command before logging
Dongni-Yang 6c92cdb
test(agent): pin the surviving command in the redaction case
Dongni-Yang b95df07
Merge branch 'main' into fix/8796-agent-gateway-pin
cv 1b49897
fix(agent): mark redacted recovery commands non-replayable
apurvvkumaria a0befe2
docs(agent): explain redacted dispatch recovery
apurvvkumaria 3049322
Merge remote-tracking branch 'origin/main' into codex/pr8846-readiness
apurvvkumaria 7f95c1b
test(sandbox): include shared gateway launcher in fixtures
apurvvkumaria 912a767
test(cli): include shared gateway launcher in startup fixture
apurvvkumaria 1ed28e0
merge(main): refresh agent dispatch readiness
apurvvkumaria 89fbfbe
docs(cli): clarify JSON diagnostic streams
apurvvkumaria ea18c20
fix(agent): exit non-zero for an incomplete agent turn (#8857)
Dongni-Yang ee44661
fix(agent): select authoritative response metadata
prekshivyas 1214167
docs(agent): identify authoritative response envelope
prekshivyas 9b227e7
docs(agent): qualify gateway pinning scope
apurvvkumaria 13b178d
Merge remote-tracking branch 'origin/main' into codex/pr8846-readiness
apurvvkumaria 625fec1
docs(agent): align command prose format
apurvvkumaria ff1d173
fix(agent): detect incomplete envelopes without payloads
prekshivyas a83aaa1
fix(agent): ignore JSON progress records
prekshivyas d86efba
Merge branch 'main' into fix/8796-agent-gateway-pin
cv File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
src/lib/actions/sandbox/agent/passthrough-dispatch.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| agentDispatchStdio, | ||
| isSilentAgentDispatch, | ||
| SILENT_AGENT_DISPATCH_EXIT_CODE, | ||
| } from "./passthrough-dispatch"; | ||
|
|
||
| describe("isSilentAgentDispatch", () => { | ||
| it("classifies a zero-exit dispatch with no bytes on either stream as silent", () => { | ||
| expect(isSilentAgentDispatch({ status: 0 }, "", "")).toBe(true); | ||
| }); | ||
|
|
||
| it("does not classify a dispatch that wrote to stdout", () => { | ||
| expect(isSilentAgentDispatch({ status: 0 }, "PONG\n", "")).toBe(false); | ||
| }); | ||
|
|
||
| it("does not classify a dispatch that wrote only to stderr", () => { | ||
| expect(isSilentAgentDispatch({ status: 0 }, "", "openclaw warning\n")).toBe(false); | ||
| }); | ||
|
|
||
| it("does not classify a non-zero dispatch, which already fails on its own", () => { | ||
| expect(isSilentAgentDispatch({ status: 7 }, "", "")).toBe(false); | ||
| }); | ||
|
|
||
| it("does not classify a transport error, which reports its own diagnosis", () => { | ||
| expect(isSilentAgentDispatch({ status: null, error: new Error("ENOENT") }, "", "")).toBe(false); | ||
| }); | ||
|
|
||
| it("does not classify a signal-killed dispatch with a null status", () => { | ||
| expect(isSilentAgentDispatch({ status: null }, "", "")).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("agentDispatchStdio", () => { | ||
| it("withholds an interactive terminal from fd 0", () => { | ||
| expect(agentDispatchStdio(true)).toEqual(["ignore", "pipe", "pipe"]); | ||
| }); | ||
|
|
||
| it("forwards a non-terminal stdin so scripted input keeps working", () => { | ||
| expect(agentDispatchStdio(false)).toEqual(["inherit", "pipe", "pipe"]); | ||
| }); | ||
|
|
||
| it("captures both output streams in either stdin posture", () => { | ||
| expect([agentDispatchStdio(true).slice(1), agentDispatchStdio(false).slice(1)]).toEqual([ | ||
| ["pipe", "pipe"], | ||
| ["pipe", "pipe"], | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("SILENT_AGENT_DISPATCH_EXIT_CODE", () => { | ||
| it("reports a dispatch failure rather than success", () => { | ||
| expect(SILENT_AGENT_DISPATCH_EXIT_CODE).toBe(1); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| // Source-of-truth boundary for the agent dispatch contract (#8796). | ||
| // | ||
| // Both `nemoclaw <name> agent` transports capture the child's streams and | ||
| // forward its exit code. That makes "the child exited 0" the only success | ||
| // signal, so a dispatch that never ran the turn is indistinguishable from a | ||
| // turn that ran and answered. | ||
| // | ||
| // 6. Empty-dispatch guard (delivery contract). | ||
| // | ||
| // - Invalid state: `openshell sandbox exec` returns status 0 with zero | ||
| // bytes on both captured streams. A delivered OpenClaw turn cannot look | ||
| // like this — the in-sandbox NemoClaw plugin writes its registration | ||
| // banner to stderr on every invocation (docs/reference/commands.mdx), | ||
| // so a healthy turn is never byte-empty on both streams. Reporting exit | ||
| // 0 here tells CI jobs and evaluation harnesses that a turn happened | ||
| // when the agent never received the message. | ||
| // - Source boundary: OpenShell owns the exec transport and OpenClaw owns | ||
| // the turn. NemoClaw cannot repair either from the host, but it does own | ||
| // what it reports to its own caller, so it fails loud instead of | ||
| // laundering an empty dispatch into a success. | ||
| // - Removal condition: drop this guard when the exec transport reports a | ||
| // non-zero status (or a structured error) for a command it did not | ||
| // actually run. | ||
| // | ||
| // 7. Non-interactive stdin posture. | ||
| // | ||
| // - Invalid state: `nemoclaw <name> agent` is documented as a | ||
| // non-interactive one-shot, yet PR #8191 moved the non-JSON transport | ||
| // off `execSandbox` onto a raw `spawnSync` with a hard-coded | ||
| // `stdio[0] = "inherit"`, dropping the TTY-aware stdin guard that | ||
| // `buildSandboxExecStdio` applies to every other sandbox exec. The JSON | ||
| // transport has carried the same hard-coded inherit since #5683. The | ||
| // result is a live terminal on fd 0 handed to a dispatch whose stdout | ||
| // and stderr are pipes and whose argv says `--no-tty`. | ||
| // - Source boundary: NemoClaw owns which fds it hands to OpenShell. | ||
| // Forwarding a real pipe stays supported so `printf ... | nemoclaw | ||
| // <name> agent` keeps working; only an interactive terminal is withheld. | ||
| // - Removal condition: drop the TTY carve-out if `openclaw agent` gains a | ||
| // documented interactive stdin mode reachable through this wrapper. | ||
| // | ||
| // Regression tests: `passthrough-dispatch.test.ts` owns the classifier and the | ||
| // stdio shape; `passthrough-help.test.ts` owns the diagnostic text. | ||
|
|
||
| import type { StdioOptions } from "node:child_process"; | ||
|
|
||
| import { isStdinTty } from "../../../core/stdin"; | ||
|
|
||
| /** | ||
| * Exit code for a dispatch that reported success without delivering a turn. | ||
| * Matches the wrapper's other non-recoverable dispatch failures. | ||
| */ | ||
| export const SILENT_AGENT_DISPATCH_EXIT_CODE = 1; | ||
|
|
||
| /** The subset of a `spawnSync` return the delivery classifier reads. */ | ||
| export type AgentDispatchOutcome = { | ||
| error?: unknown; | ||
| status: number | null; | ||
| }; | ||
|
|
||
| /** | ||
| * Stdio for a non-interactive agent dispatch. An interactive terminal is | ||
| * withheld from fd 0; a genuine pipe or redirect is still forwarded so | ||
| * scripted stdin keeps working. | ||
| */ | ||
| export function agentDispatchStdio(stdinIsTty: boolean = isStdinTty()): StdioOptions { | ||
| return [stdinIsTty ? "ignore" : "inherit", "pipe", "pipe"]; | ||
| } | ||
|
|
||
| /** | ||
| * True when the exec transport reported success but produced no bytes at all. | ||
| * Requires both streams to be empty so a quiet-but-real turn (any banner, | ||
| * warning, or reply) is never misread as an empty dispatch. | ||
| */ | ||
| export function isSilentAgentDispatch( | ||
| result: AgentDispatchOutcome, | ||
| stdout: string, | ||
| stderr: string, | ||
| ): boolean { | ||
| return !result.error && result.status === 0 && stdout.length === 0 && stderr.length === 0; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.