Skip to content

bug(agent-terminal): the command poll never sees agent output (sender mismatch), and a timed-out command is reported SUCCESS/EXIT_CODE 0 from a freshly recreated shell #17074

Description

@mrveiss

Problem

Two deterministic defects in services/agent_terminal/command_executor.py affect every agent-issued terminal command. The second one makes the reported result untrustworthy.

1. The output poll can never see agent command output, so every command waits the full timeout

  • _extract_terminal_output (:45), _search_for_exit_marker (:265) and the error-pattern fallback (:363) accept only chat messages with sender == "terminal".
  • The only writer of agent-issued command output, service.py:_save_command_to_chat (:787, :803), always writes sender="agent_terminal".
  • So _poll_for_current_output always returns "". The stability check in _intelligent_poll_output never short-circuits on an empty string, and every command burns the full 30 s default timeout, however fast it finishes.

2. A timeout cancel kills the PTY, then an exit-code marker is written into a freshly recreated shell

  • On timeout, _handle_poll_timeout → cancel_command sends SIGINT, waits 2 s (SERVICE_STARTUP_DELAY), and then SIGKILLs the PTY (_force_close_pty_session → simple_pty_manager.close_session).
  • _poll_and_detect_return_code then unconditionally calls _detect_return_code → _write_to_pty. It finds the session gone (not alive (exists=False), recreating...) and creates a new blank shell. The echo '__EXIT_CODE_…__:'$? marker then runs there.
  • The reported exit code belongs to the new shell, not to the command. $? is 0 in a fresh shell, so the user sees SUCCESS | EXIT_CODE: 0 for a command that was actually killed.

Evidence (live install, 2026-09-19, one chat session)

  • 00:24:57: approved git log --oneline -1 was written into an idle, freshly created PTY.
  • 00:25:28.995: [CANCEL] Cancelling command due to timeout, 30.5 s after the write.
  • 00:25:31.064: the PTY was force-closed.
  • 00:25:31.163: [PTY_WRITE] ... not alive (exists=False), recreating..., 99 ms later, in the same coroutine.
  • 00:25:43: SUCCESS | EXIT_CODE: 0 recorded.

Not a race: SimplePTYManager guards its registry with a lock, and every step runs sequentially in one coroutine. It's reproducible for any agent command that SIGINT doesn't stop within 2 s. It happened once tonight; the defect is always armed.

Acceptance criteria

  • The poll observes agent-issued output: it matches agent_terminal messages, or better, a per-command marker or session token instead of the sender string. A fast command completes as soon as its output is stable, not at the timeout.
  • When a command was cancelled for a timeout, no exit-code marker is written and no PTY is recreated for it. The result is reported as cancelled or timed out, never as SUCCESS | EXIT_CODE: 0.
  • The poll timeout comes from SSOT or env, not a hardcoded 30.0.
  • Tests:
    • a fast agent command returns well before the timeout;
    • a command that times out is reported as cancelled, with no marker write and no recreated shell;
    • negative control: the current code reports EXIT_CODE 0 after a kill.

Related: #17052 (approver identity; the same session recorded approved_by=web_user), #17053.

Activity

  1. added this to the v0.9.0 milestone on Sep 18, 2026
  2. mrveiss commented on Sep 20, 2026

    @mrveiss
    OwnerAuthor

    AC verification against merged main (post #17134 security train merge) — checked hard given the flagged doubt about adjacency

    This is a genuine architectural rewrite, not a patch, and I traced the actual control flow rather than trusting the docstring:

    • Fast completion, no sender-string dependency at all — services/pty_command.py introduces a UUID exit-code marker (new_marker(), unforgeable by command output) typed together with the command in one shot; execute_in_pty polls the PTY's own transcript for it (pty_command.await_exit_code), never chat history. This is the "better" option the AC explicitly allowed, and it eliminates the sender-mismatch bug class by construction rather than patching the string match.
    • No marker write, no PTY recreation after a timeout cancel — read execute_in_pty directly: the command (with its marker) is written exactly once, before polling starts; await_exit_code's on_timeout callback only cancels and returns timed_out=True — there is no second write step afterward in any code path. _build_pty_timeout_result returns status="timeout", return_code=TIMED_OUT_RETURN_CODE (124), never success.
    • Timeout from env, not hardcoded — AGENT_COMMAND_TIMEOUT_S = env_int("AUTOBOT_AGENT_COMMAND_TIMEOUT_S", 30).
    • All 3 required tests present, including the literal negative control — test_a_fast_command_returns_as_soon_as_it_finishes; test_a_timed_out_command_is_reported_as_timed_out_never_as_success, whose docstring is literally "The negative control: the old path killed the shell, wrote its marker into a new one, and got 0," and which asserts manager.created == [] ("no shell is recreated"), shell.writes[-1] == "\x03" (nothing written after cancel but the interrupt byte), and not any("__EXIT_CODE_" in write for write in shell.writes[1:]) — directly proving the exact incident mechanism can't recur; test_the_default_timeout_comes_from_the_environment_backed_constant.

    All 4 satisfied, verified against the real control flow, not adjacent work. Closed correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions