Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions agents/langchain-deepagents-code/dcode-launcher.sh
Original file line number Diff line number Diff line change
Expand Up @@ -211,16 +211,4 @@ case "${1:-}" in
status | whoami | identity | --version | -v | -V) exec "$MANAGED_DCODE_WRAPPER" "$@" ;;
esac

# DCode's one-shot mode owns and cleans up its server lifecycle before exiting.
# Keep that established automation path outside the interactive-session
# supervisor; this also preserves the wrapper's exact parser diagnostics.
_nemoclaw_dcode_args=("$@")
for ((_nemoclaw_arg_index = 0; _nemoclaw_arg_index < ${#_nemoclaw_dcode_args[@]}; _nemoclaw_arg_index++)); do
_nemoclaw_arg="${_nemoclaw_dcode_args[_nemoclaw_arg_index]}"
case "$_nemoclaw_arg" in
-n | -n?* | --non-interactive | --non-interactive=*) exec "$MANAGED_DCODE_WRAPPER" "$@" ;;
esac
done
unset _nemoclaw_dcode_args _nemoclaw_arg_index _nemoclaw_arg

exec /opt/venv/bin/python3 -I "$MANAGED_SESSION_SUPERVISOR" "$MANAGED_DCODE_WRAPPER" "$@"
33 changes: 33 additions & 0 deletions agents/langchain-deepagents-code/dcode-session-supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import ctypes
import errno
import os
import select
import signal
import subprocess
import sys
Expand Down Expand Up @@ -120,6 +121,29 @@ def _wait_after_disconnect(child: subprocess.Popen[bytes]) -> int:
return child.wait()


def _stdin_disconnect_poller() -> select.poll | None:
try:
fd = sys.stdin.fileno()
if not os.isatty(fd):
return None
poller = select.poll()
poller.register(fd, select.POLLHUP | select.POLLERR | select.POLLNVAL)
except (AttributeError, OSError, ValueError):
return None
return poller


def _stdin_disconnected(poller: select.poll | None) -> bool:
if poller is None:
return False
try:
events = poller.poll(0)
except (OSError, ValueError):
return True
disconnect_events = select.POLLHUP | select.POLLERR | select.POLLNVAL
return any(event & disconnect_events for _fd, event in events)


def run(argv: Sequence[str]) -> int:
if not argv:
print("dcode session supervisor requires a command.", file=sys.stderr)
Expand All @@ -133,6 +157,7 @@ def run(argv: Sequence[str]) -> int:

_enable_child_subreaper()
child: subprocess.Popen[bytes] | None = None
disconnect_poller = _stdin_disconnect_poller()
pending_signals: list[int] = []
disconnect_received = False

Expand Down Expand Up @@ -166,6 +191,14 @@ def forward(sig: int, _frame: object) -> None:
returncode = child.wait(timeout=_POLL_SECONDS)
break
except subprocess.TimeoutExpired:
if _stdin_disconnected(disconnect_poller):
try:
child.terminate()
except ProcessLookupError:
returncode = child.wait()
break
returncode = _wait_after_disconnect(child)
break
if disconnect_received:
returncode = _wait_after_disconnect(child)
break
Expand Down
2 changes: 1 addition & 1 deletion docs/get-started/quickstart-langchain-deepagents-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ dcode -n "Summarize this repository"
```

The managed `dcode`, `dcode.real`, and `deepagents-code` launchers use `/opt/venv/bin/python3 -I` to run the pinned package with an isolated import path and `HOME=/sandbox`.
For interactive sessions, each launcher supervises its own process descendants so terminal exit or disconnect terminates the associated LangGraph server tree without affecting another session.
For interactive and headless sessions, each launcher supervises its own process descendants so completion, terminal exit, or disconnect terminates the associated LangGraph server tree without affecting another session.
After a disconnect, the supervisor uses bounded grace periods before it kills unresponsive processes from that session.
The supervisor runs inside the Linux OpenShell sandbox and fails closed if invoked outside Linux; the host operating system does not change this sandbox guarantee.
They disable Deep Agents Code package update checks and the LangGraph server version check, block CLI and TUI update/install commands, and disable nested remote sandbox providers, remote async subagents, MCP commands and project auto-loading, startup commands, executable hooks, ACP mode, interpreter tool calling, shell allow-list overrides, native LangSmith tracing, and ambient OpenTelemetry exporter configuration.
Expand Down
59 changes: 59 additions & 0 deletions test/dcode-session-supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,63 @@ describe.runIf(canRun)("managed DCode session supervisor", () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("bounds cleanup when the relay closes its PTY without sending a signal (#6720)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-stdin-disconnect-"));
const pidFile = path.join(dir, "processes.pid");
const session = path.join(dir, "session.py");
const harness = path.join(dir, "harness.py");
fs.writeFileSync(
session,
[
"import os",
"import pathlib",
"import signal",
"import subprocess",
"import sys",
"import time",
"signal.signal(signal.SIGTERM, signal.SIG_IGN)",
"descendant = subprocess.Popen([sys.executable, '-c', 'import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)'])",
`pathlib.Path(${JSON.stringify(pidFile)}).write_text(f'{os.getpid()}\\n{descendant.pid}\\n', encoding='utf-8')`,
"time.sleep(30)",
].join("\n"),
);
fs.writeFileSync(
harness,
[
"import os",
"import pathlib",
"import pty",
"import subprocess",
"import sys",
"import time",
`pid_file = pathlib.Path(${JSON.stringify(pidFile)})`,
"master_fd, slave_fd = pty.openpty()",
`supervisor = subprocess.Popen([sys.executable, ${JSON.stringify(supervisor)}, sys.executable, ${JSON.stringify(session)}], stdin=slave_fd)`,
"os.close(slave_fd)",
"deadline = time.monotonic() + 5",
"while not pid_file.exists() and time.monotonic() < deadline:",
" time.sleep(0.05)",
"if not pid_file.exists():",
" supervisor.kill()",
" raise SystemExit('session did not publish process ids')",
"pids = [int(value) for value in pid_file.read_text(encoding='utf-8').splitlines()]",
"os.close(master_fd)",
"supervisor.wait(timeout=10)",
"for pid in pids:",
" try:",
" os.kill(pid, 0)",
" except ProcessLookupError:",
" continue",
" raise SystemExit(f'process still alive: {pid}')",
].join("\n"),
);

try {
const result = spawnSync("python3", [harness], { encoding: "utf8", timeout: 15_000 });
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
54 changes: 50 additions & 4 deletions test/deepagents-code-tui-startup-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ const itWithTclsh = it.runIf(tclshAvailable);

function runTuiExpectStateMachine(
events: TuiExpectEvent[],
options: { closeAfterFirstCtrlC?: boolean; expectNamePrompt?: boolean } = {},
options: {
closeAfterFirstCtrlC?: boolean;
expectNamePrompt?: boolean;
terminationMode?: "ctrl-c" | "disconnect";
} = {},
) {
const captureDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-tui-expect-"));
const capture = path.join(captureDir, "raw.log");
Expand All @@ -85,6 +89,7 @@ function runTuiExpectStateMachine(

const prelude = String.raw`
rename after real_after
rename exec real_exec
rename exit real_exit
set ::fake_events [list ${events.map((event) => tclEventLiterals[event]).join(" ")}]
set ::fake_sent {}
Expand All @@ -93,6 +98,15 @@ set ::fake_closed 0
proc log_file {args} {}
proc spawn {args} {}
proc after {args} {}
proc exp_pid {} {
return 4242
}
proc exec {args} {
if {[lindex $args 0] ne "kill" || [lindex $args 1] ne "-TERM" || [lindex $args 2] ne "4242"} {
error "unexpected fake exec: $args"
}
set ::fake_closed 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
proc send {args} {
binary scan [lindex $args end] H* key_hex
if {$::fake_closed} {
Expand Down Expand Up @@ -177,6 +191,7 @@ proc exit {{code 0}} {
NEMOCLAW_TUI_CLOSE_AFTER_FIRST_CTRL_C: options.closeAfterFirstCtrlC ? "1" : "0",
NEMOCLAW_TUI_COMPOSER_PATTERN: "(dcode[^\\r\\n]*v0\\.1\\.34)",
NEMOCLAW_TUI_EXPECT_NAME_PROMPT: options.expectNamePrompt === false ? "0" : "1",
NEMOCLAW_TUI_TERMINATION_MODE: options.terminationMode ?? "ctrl-c",
NEMOCLAW_TUI_MARKERS: markers,
NEMOCLAW_TUI_FIRST_RUN_PATTERN: "(choose a recommended model)",
NEMOCLAW_TUI_NAME_PROMPT_PATTERN:
Expand Down Expand Up @@ -386,6 +401,22 @@ describe("Deep Agents Code TUI startup check helpers", () => {
expect(markerText).toContain("NEMOCLAW_TUI_EXIT_CAPTURED:0");
});

itWithTclsh("terminates the PTY relay without sending Ctrl-C (#6720)", () => {
const { markerText, result, traceText } = runTuiExpectStateMachine(
["composer", "ready", "eof"],
{
expectNamePrompt: false,
terminationMode: "disconnect",
},
);

expect(result.status, result.stderr).toBe(0);
expect(traceText).toBe("2f,61,67,65,6e,74,73,0d");
expect(markerText).toContain("NEMOCLAW_TUI_READY");
expect(markerText).toContain("NEMOCLAW_TUI_DISCONNECTED");
expect(markerText).not.toContain("NEMOCLAW_TUI_EXIT_CAPTURED");
});

it("does not treat generic TUI exit status 1 as a clean Ctrl-C exit", () => {
const assertExit = (exitCode: string) =>
runTuiStartupCheckHelper(
Expand Down Expand Up @@ -418,13 +449,15 @@ describe("Deep Agents Code TUI startup check helpers", () => {
"10-deepagents-code-tui-startup.repeat.sanitized.log",
);
const processCounts = path.join(captureDir, "process-counts.txt");
fs.writeFileSync(processCounts, "0\n1\n0\n1\n0\n");
fs.writeFileSync(processCounts, "0\n1\n0\n1\n0\n1\n0\n");

try {
const result = runTuiStartupCheckHelperResult(
[
"sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:complete\\n'; }",
"ensure_expect_available() { return 0; }",
'run_headless_session() { printf "PONG\\n"; }',
"sandbox_is_ready() { return 0; }",
"dcode_process_count() {",
' value="$(sed -n "1p" "$COUNT_FILE")"',
' sed "1d" "$COUNT_FILE" >"$COUNT_FILE.next"',
Expand All @@ -433,7 +466,12 @@ describe("Deep Agents Code TUI startup check helpers", () => {
"}",
"sleep() { :; }",
"run_tui_expect() {",
' printf "Select Agent\\nNEMOCLAW_TUI_READY\\nNEMOCLAW_TUI_EXIT_CAPTURED:130\\n" >>"$2"',
' printf "Select Agent\\nNEMOCLAW_TUI_READY\\n" >>"$2"',
' if [ "$4" = disconnect ]; then',
' printf "NEMOCLAW_TUI_DISCONNECTED\\n" >>"$2"',
" else",
' printf "NEMOCLAW_TUI_EXIT_CAPTURED:130\\n" >>"$2"',
" fi",
" return 0",
"}",
"main",
Expand All @@ -444,6 +482,11 @@ describe("Deep Agents Code TUI startup check helpers", () => {
const sanitizedText = fs.readFileSync(sanitizedCapture, "utf8");
const repeatedSanitizedText = fs.readFileSync(repeatedSanitizedCapture, "utf8");
expect(result.status).toBe(0);
expect(result.stdout).toContain("headless dcode request returned PONG");
expect(result.stdout).toContain(
"headless completion returned the DCode/LangGraph process count to baseline",
);
expect(result.stdout).toContain("sandbox remained Ready after headless completion");
expect(result.stdout).toContain(
"session 1: finite expect harness reached startup and observed exit",
);
Expand All @@ -452,8 +495,9 @@ describe("Deep Agents Code TUI startup check helpers", () => {
);
expect(result.stdout).toContain("dcode TUI exited cleanly after Ctrl-C (exit 130)");
expect(sanitizedText).toContain("NEMOCLAW_TUI_READY");
expect(sanitizedText).toContain("NEMOCLAW_TUI_EXIT_CAPTURED:130");
expect(sanitizedText).toContain("NEMOCLAW_TUI_DISCONNECTED");
expect(repeatedSanitizedText).toContain("NEMOCLAW_TUI_READY");
expect(repeatedSanitizedText).toContain("NEMOCLAW_TUI_EXIT_CAPTURED:130");
expect(result.stdout).toContain(
"session 2: DCode/LangGraph process count returned to baseline",
);
Expand All @@ -471,6 +515,8 @@ describe("Deep Agents Code TUI startup check helpers", () => {
[
"sandbox_exec() { printf 'NEMOCLAW_DCODE_PROBE:deepagents\\nNEMOCLAW_DCODE_ONBOARDING:complete\\n'; }",
"ensure_expect_available() { return 0; }",
'run_headless_session() { printf "PONG\\n"; }',
"sandbox_is_ready() { return 0; }",
"dcode_process_count() { printf 'NEMOCLAW_DCODE_PROCESS_COUNT:0\\n'; }",
"wait_for_dcode_process_baseline() { return 0; }",
"run_tui_expect() {",
Expand Down
Loading
Loading