DEV Community

Jordan Huang
Jordan Huang

Posted on

FAQ: Five Myths About Agent Hangs and Silent TTYs

Did your last agent run freeze with no stack trace?
You blamed the model, then you reran the same prompt.
Silence is not a reasoning bug by default.

I keep this FAQ beside my agent command transcripts.
The same five claims appear after almost every mysterious hang.
Each myth has a shell check you can run today.

What I mean by a hang

The child stays in interruptible sleep and burns no CPU.
The log stops, and the wrapper never receives an exit status.
The agent loop then looks dead while something waits on I/O.

Does that match your last "the model got stuck" story?

Myth 1: The model hung, so the command hung

The claim is simple, common, and usually wrong.
People treat a quiet chat as a quiet inference loop.
The freeze often lives in a child syscall instead.

I collect process state before I touch the prompt again.

# Replace AGENT_PID with the wrapper that launched the command.
ps -o pid,ppid,stat,wchan:32,etime,cmd --ppid "$AGENT_PID"
Enter fullscreen mode Exit fullscreen mode

Read STAT and wchan before you spend another retry.
Sleeping with pipe_read is not a stalled token stream.
It is a blocking read, which is a different failure class.

The corrected mental model is blunt and testable.
Assume I/O wait until ps proves something else.
Why would a tokenizer sit in pipe_read anyway?

Myth 2: Non-interactive shells already skip every prompt

The claim sounds reasonable if you live in CI.
People assume agent shells export the same guard variables.
Many tools still key off a TTY, CI, or both.

I print the contract before I blame the test suite.

printf 'CI=%s\n' "${CI-<unset>}"
printf 'TERM=%s\n' "${TERM-<unset>}"
printf 'PAGER=%s\n' "${PAGER-<unset>}"
printf 'GIT_PAGER=%s\n' "${GIT_PAGER-<unset>}"
python - <<'PY'
import sys
for name in ("stdin", "stdout", "stderr"):
    stream = getattr(sys, name)
    print(name, "isatty=", stream.isatty(), "encoding=", stream.encoding)
PY
Enter fullscreen mode Exit fullscreen mode

CI runners usually export CI=true without extra thought.
Agent wrappers often forget that one cheap environment line.
Then git opens less, and npm asks a question.

The corrected model is a contract you set yourself.
Non-interactive behavior is not a kernel feature.
Have you watched less wait for q with no keyboard?

export CI=true
export PAGER=cat
export GIT_PAGER=cat
export npm_config_yes=true
export DEBIAN_FRONTEND=noninteractive
git config --global core.pager cat
Enter fullscreen mode Exit fullscreen mode

Those exports are a starting contract, not a personality transplant.
A tool can still call input() after you set CI=true.
Did you verify the binary, or only the environment file?

Myth 3: A Python one-liner proved the suite is headless

The claim is that isatty() on stdout tells the whole story.
People check one stream and then trust every other descriptor.
stdin, stdout, and stderr can disagree on the same process.

I check all three before I call the suite headless.

python - <<'PY'
import sys, os
for fd, name in ((0, "stdin"), (1, "stdout"), (2, "stderr")):
    print(name, "isatty", os.isatty(fd), "fd", fd)
print("stdin_isatty", sys.stdin.isatty())
PY
Enter fullscreen mode Exit fullscreen mode

Piped stdout can still sit beside a live stdin TTY.
The reverse also happens on many agent wrappers.
input() then blocks even when logs look fully piped.

The corrected model treats each file descriptor separately.
One green isatty check is not a hang vaccine.
Would you trust a test that never inspects stdin?

Myth 4: Timeout wrappers explain the hang and fix it

The claim is that timeout 30 turns mystery into signal.
People add a wrapper, catch 124, and call the bug solved.
A killed process still has no named root cause.

I use timeout as a bound, not as a diagnosis.

timeout --signal=TERM --kill-after=2s 15s ./run_tests.sh
echo "timeout_exit=$?"
# 124 means the clock fired. It does not name the waiter.
Enter fullscreen mode Exit fullscreen mode

Exit 124 only says the clock won the race.
It does not say pager, input(), or a forgotten yes flag.
You will retry the same hang on the next loop.

The corrected model records why the read blocked.
Kill the waiter only after you name the waiter.
Otherwise the next agent pass repeats the stall.

Myth 5: A remote Linux box behaves like your laptop TTY

The claim is that Linux is Linux, so prompts match.
People copy a local command into a remote agent shell.
The remote command has no keyboard, and often no TERM.

I treat laptop TTY and agent host as different machines.
When I need that split, I park the hang lab remotely.
I use MonkeyCode's free model access and free server option.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Those two availability claims are the only product facts here.
I am not assuming extra hardware details, quotas, or model names.
The point is isolation from an interactive local shell.

# On the remote shell the agent actually uses:
tty || true
echo "TERM=${TERM-<unset>}"
stty -a 2>/dev/null || echo "no controlling terminal"
Enter fullscreen mode Exit fullscreen mode

tty saying not a tty should change your test plan.
Color, pagers, and confirmation prompts all key off that.
Your laptop hid those branches every single day.

Docker adds a third TTY story on the same host.
A container without -t has no terminal either.
-i without -t still leaves isatty() false.

# Proposal only: compare descriptor truth inside a throwaway container.
docker run --rm python:3.12-slim python -c "import sys; print('stdin', sys.stdin.isatty(), 'stdout', sys.stdout.isatty())"
docker run --rm -i python:3.12-slim python -c "import sys; print('stdin', sys.stdin.isatty(), 'stdout', sys.stdout.isatty())"
Enter fullscreen mode Exit fullscreen mode

The corrected model is a two-host picture.
Laptop TTY is a privilege the agent host may lack.
Why copy an interactive command into a headless shell?

Artifact: a fifteen-minute hang lab

This lab is a proposal you can run locally or remotely.
I am not reporting production timings from a real fleet.
Label it as a fixture, not as a benchmark.

Create hanglab/ with three tiny files.

mkdir -p hanglab

cat > hanglab/wait_for_input.py <<'PY'
import sys
print("about to read stdin", flush=True)
line = sys.stdin.readline()
print("got:", repr(line), flush=True)
PY

cat > hanglab/maybe_pager.sh <<'SH'
#!/bin/sh
set -eu
# Some git builds still consult a pager when stdout looks like a TTY.
git --no-pager log -1 >/dev/null 2>&1 || true
printf 'long output\n%.0s' $(seq 1 200) | ${PAGER:-less}
SH
chmod +x hanglab/maybe_pager.sh

cat > hanglab/run.sh <<'SH'
#!/bin/sh
set -eu
mode=${1:-input}
case "$mode" in
  input)
    python hanglab/wait_for_input.py
    ;;
  pager)
    hanglab/maybe_pager.sh
    ;;
  ci)
    export CI=true
    export PAGER=cat
    export GIT_PAGER=cat
    export npm_config_yes=true
    python hanglab/wait_for_input.py < /dev/null
    ;;
  *)
    echo "usage: $0 input|pager|ci" >&2
    exit 2
    ;;
esac
SH
chmod +x hanglab/run.sh
Enter fullscreen mode Exit fullscreen mode

Run three comparisons and write the results down.
Do not trust memory. Trust the recorded exit codes.

# 1) This should block until you type a line.
python hanglab/wait_for_input.py

# 2) This should exit fast with EOF on stdin.
python hanglab/wait_for_input.py < /dev/null
echo "eof_exit=$?"

# 3) Bound the pager case so it cannot eat the session.
timeout 8s hanglab/maybe_pager.sh
echo "pager_exit=$?"

# 4) The contract path should return without a keyboard.
hanglab/run.sh ci
echo "ci_exit=$?"
Enter fullscreen mode Exit fullscreen mode

Decision table I actually use

Observation Do not conclude Conclude instead First fix
Quiet chat, child S plus pipe_read Model stalled Blocking read Inspect stdin and pager
timeout exit 124 Root cause found Clock won Name the waiter, then bound
stdout is not a TTY Whole process is headless Only stdout is piped Check stdin and stderr too
Works on the laptop Agent host is broken TTY privilege differed Set CI and PAGER=cat
CI unset in printenv Tooling is flaky Contract was never set Export the CI guards
Container without -t Image is defective No PTY was allocated Decide if you need -t

I follow this order when a command goes quiet.

  1. Capture ps STAT and wchan for the child.
  2. Print CI, TERM, PAGER, and the three isatty flags.
  3. Replay the command under timeout with stdin from /dev/null.
  4. Re-run with PAGER=cat and CI=true as one change.
  5. Keep the first setting that turns a hang into an exit code.

What this workflow is not

This is not a profiler for infinite loops in your code.
Busy R state with rising CPU is a different FAQ.
Do not use these checks for GPU hangs or kernel deadlocks.

Skip this approach if your product is the TTY itself.
Interactive debuggers, full-screen TUIs, and real serial consoles need a terminal.
A headless agent host is the wrong lab for those tools.

I also do not treat a free remote server as a secret vault.
Hang labs should use fake prompts and throwaway fixtures.
If a command must read a real secret, keep it off that host.

Limitations I will not hand-wave

isatty lies under some allocators and some CI wrappers.
script and pty.spawn can fake a TTY you did not want.
timeout cannot interrupt an uninterruptible disk wait.

Clock bounds are not diagnosis by themselves.
Killing less does not document why less started.
Your next agent retry will summon it again without a named fix.

I am not claiming any model quality ranking here.
Free model access only means I can rerun the lab without mixing TTYs.
It does not prove the suite is correct, fast, or complete.

Who should ignore this FAQ entirely?
People shipping ncurses products, hardware consoles, or true interactive REPLs.
People whose hang is a livelock with hot CPUs and growing logs.

The mental model I want you to keep

Hangs are usually waits, not silent intelligence failures.
Name the waiter, then set the contract, then bound the clock.
Ask what is blocked before you spend another prompt.

If you already have a MonkeyCode workspace, try the hang lab there.
Run hanglab/run.sh ci once and save the transcript.
Keep that transcript beside your next agent retry.

Top comments (0)