Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a0d3e19
Stop depending on GNU coreutils that macOS does not ship
weishi-imbue Jul 10, 2026
d3a4114
Merge remote-tracking branch 'origin/main' into mngr/timeout-tac
weishi-imbue Jul 10, 2026
9bbf489
Fix CI, remove per-line forks, and verify the impact by execution
weishi-imbue Jul 10, 2026
cb5c240
Do not report a failed tmux wait-for as a successful submission
weishi-imbue Jul 10, 2026
f73b435
Harden the macOS portability fixes after review
weishi-imbue Jul 10, 2026
a9c4541
Merge remote-tracking branch 'origin/main' into mngr/timeout-tac
weishi-imbue Jul 10, 2026
e861ac4
Test the signal-only script's exit code and reframe the watchdog comm…
weishi-imbue Jul 10, 2026
b3bca1b
Collapse get_directory_size to a single du -sk call
weishi-imbue Jul 11, 2026
d35b939
Drop transport details from the get_directory_size docstring
weishi-imbue Jul 11, 2026
eb9621d
Document two unstated contracts in mngr_transcript_lib.sh
weishi-imbue Jul 11, 2026
c6a99d4
Correct the transcript-streamer perf claim in the changelog
weishi-imbue Jul 11, 2026
15a356f
Merge remote-tracking branch 'origin/main' into mngr/timeout-tac
weishi-imbue Jul 14, 2026
8f56cd9
Bound the tmux waiter with perl's alarm instead of rewriting the subm…
weishi-imbue Jul 14, 2026
56a8cdf
Drop comparative and historical narration from the new comments
weishi-imbue Jul 14, 2026
cbf8642
Flatten the timeout shim into a single constant named _timeout
weishi-imbue Jul 14, 2026
cfd91f9
Fix the perl-fallback tests, which only passed on a host without timeout
weishi-imbue Jul 14, 2026
d604aa5
Merge origin/main into mngr/timeout-tac
weishi-imbue Jul 15, 2026
a1f2907
Record the portable-shell technique in the style guide
weishi-imbue Jul 15, 2026
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
13 changes: 13 additions & 0 deletions libs/mngr/changelog/mngr-timeout-tac.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Fixed four places where mngr shelled out to GNU-coreutils binaries and flags that stock macOS does not ship, each of which ran on the user's own machine whenever an agent used the `local` provider. All four were verified failing on macOS 26.4.1 with a stock `PATH`, then verified fixed.

`mngr message` to a local codex or antigravity agent raised `SendMessageError("Timeout waiting for message submission signal")` on every send. The submission path bounded its `tmux wait-for` with `timeout(1)`, which exits 127 (`timeout: command not found`) on macOS -- immediately, while reporting that it had waited the full timeout. It is now bounded by a `sleep`-then-`kill` watchdog. Reproducing `timeout(1)` by hand takes more than `sleep` and `kill`: a `tmux wait-for` client exits 0 whether it was signalled or killed, so the watchdog marks a file *before* killing and that marker, not the wait status, decides the exit code (the way `timeout` returns 124); the waiter must be the tmux client itself rather than a wrapper subshell, or the kill hits the subshell and orphans the client; and every background job redirects stdout, because a job that inherits it holds the caller's stdout open until it exits, which would stall each submission for the full deadline even when the signal lands immediately. For Claude agents (which also watch an acceptance marker) the same bug degraded silently instead: the `timeout` failure was swallowed inside a backgrounded subshell, so the hook path never confirmed and only the transcript marker could -- meaning `/clear` and `/compact`, which fire the hook but never enqueue a model turn, always burned the full timeout.

Raw-transcript streaming re-emitted already-emitted lines on macOS. `mngr_transcript_reconcile_offset` reverse-scanned the session file with `tac` to find the last emitted line; with `tac` absent the scan read nothing and the offset reset to 0. It now scans forward and tracks the last match, which needs no reverse at all. This also fixes a latent off-by-one: the old code combined `wc -l` (which does not count an unterminated final line) with `tac` (which emits it), so a session file whose last line was still being appended -- the normal state of a live transcript -- reconciled one line too low.

Transcript-streamer startup also got much faster. Both `mngr_transcript_build_id_set` and `mngr_transcript_reconcile_offset` used to extract each line's correlation field through a command substitution, which forks a subshell per line; they now match it with an inline bash regex and fork nothing. The dominant cost was `build_id_set`, which scans the whole already-emitted output: on a 50,000-line file it drops from about 32s to 0.7s on macOS, and about 14s to 0.6s on Linux (three trials each; forks are cheaper on Linux). `reconcile_offset` is subtler -- removing `tac` also removed its early exit on the first match from the end, so the new forward scan always reads the whole file (about 0.7s at 50,000 lines) instead of sometimes stopping early. But because it no longer forks, its worst case -- a match near the start, which made the old reverse scan fork through the entire file at about 12s -- is now that same bounded 0.7s. Combined startup on a 50,000-line pair is about 1.5s, down from tens of seconds. The inline match uses bash's built-in `[[ =~ ]]`, so unlike the `tac` it replaced it needs no external binary and behaves identically on macOS and Linux.

`mngr gc` mis-measured orphaned work directories on macOS. `du -sb` has no BSD equivalent (`-b` is rejected), and because the command was piped into `cut`, the pipeline exit status was `cut`'s, so the failure looked like success with empty output and every orphan was reported as 0 bytes. `stat -c %Y` is likewise rejected by BSD `stat`, so each orphan's `created_at` fell back to `datetime.now()` and looked brand-new, meaning age-based collection never ran on macOS.

Both `gc` call sites now go through the host interface instead of hand-rolled shell. `OuterHostInterface` gains a concrete `get_directory_size`, alongside the existing `path_exists` and `get_file_mtime`: it runs POSIX `test -d ... && du -sk` on the host -- locally via subprocess, remotely over SSH -- so callers never branch on `is_local`. Sizes are whole kibibytes, since `-k` is the only `du` block size POSIX defines, and a path that is not a directory reports 0. The trailing slash resolves a symlinked path to its target directory, and `du` exiting non-zero is tolerated because it still prints a correct total after skipping an unreadable subdirectory (the piped-into-`cut` form this replaced happened to mask that, and dropping the pipe would otherwise have turned a working directory into a reported 0 bytes). The exact `du -sk <dir>/` invocation was verified on stock macOS (BSD `du`) and on Linux (GNU and busybox `du`) to agree on the load-bearing cases: a hard-linked inode counted once, a nested symlinked directory not followed, and a top-level symlink resolved. A single `du` call rather than a Python reimplementation for local hosts follows the same rule as the `timeout` fix above -- call the tool instead of reimplementing it.

A submission whose `tmux wait-for` fails outright -- no tmux server, a dead session -- is no longer reported as successfully submitted. `timeout(1)` passed the client's exit status through when the client exited on its own, and only overrode it on expiry; the hand-rolled watchdog now does the same, using the deadline marker to detect expiry and the wait status to detect an outright failure. If the watchdog cannot create its marker file at all, the submission fails rather than silently treating every subsequent timeout as a success.
6 changes: 3 additions & 3 deletions libs/mngr/imbue/mngr/agents/tui_agent_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,11 @@ def test_send_enter_waits_on_hook_only_without_a_marker_command() -> None:
accept_marker_command=None,
)
assert result is True
# Exactly one host round-trip, and it waits on the hook with no marker probe
# (the signal-only path uses no sentinel file).
# Exactly one host round-trip, and it blocks on the hook rather than polling
# (no marker to poll for).
assert len(commands) == 1
assert "tmux wait-for" in commands[0]
assert "mktemp" not in commands[0]
assert "while" not in commands[0]


def test_send_enter_watches_marker_and_hook_concurrently_with_a_marker_command() -> None:
Expand Down
85 changes: 51 additions & 34 deletions libs/mngr/imbue/mngr/agents/tui_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,10 @@ def send_enter_via_tmux_wait_for_hook(
"""Strategy 3: send Enter and wait for a tmux wait-for channel fired by a TUI hook.

Used by agents whose TUI exposes a UserPromptSubmit-style hook that fires
``tmux wait-for -S $channel`` once the message is submitted. The waiter
is run in the **foreground** so it registers with the tmux server
synchronously, then Enter is sent from a backgrounded subshell after a
short delay. This avoids a race where the hook signal fires before the
waiter has registered (signals wake exactly one waiter; if none exists
the signal is lost).
``tmux wait-for -S $channel`` once the message is submitted. The waiter is
started before Enter is sent, and Enter is sent from a backgrounded subshell
after a short delay, so that the waiter has registered with the tmux server
by the time the hook can fire.

``accept_marker_command`` (when supplied) is an agent-provided shell snippet
that prints a lexicographically-monotonic token -- e.g. an ISO-8601
Expand Down Expand Up @@ -301,19 +299,32 @@ def _send_enter_and_wait_for_signal(


def _build_signal_only_command(full_timeout: float, wait_channel: str, tmux_target: TmuxWindowTarget) -> str:
"""The original behavior: send Enter, then block on the hook's wait-for channel.

Used for TUIs with no acceptance-marker command to watch. The waiter is started (in the
foreground here) before Enter is sent from a backgrounded subshell, so the
signal cannot fire before a waiter is registered (signals wake exactly one
waiter; a signal with none registered is lost).
"""Send Enter, then block on the hook's wait-for channel until it fires or the deadline passes.

Used for TUIs with no acceptance-marker command to watch. The waiter is
started before Enter is sent from a backgrounded subshell, so it is
registered by the time the hook can fire. Exit 0 = signalled, non-zero =
deadline reached or tmux failed.

This runs on the agent's host, Linux or macOS, so the deadline cannot use
``timeout``; it is a ``sleep``-then-``kill`` watchdog instead. ``$waiter`` is
the tmux client itself, so the kill reaps it. A killed ``tmux wait-for`` exits
0, so the watchdog records the deadline in ``$tmo`` before killing, and the
wait status then separates a signal from an outright tmux failure. Background
jobs redirect stdout, which they would otherwise hold open until they exit,
stalling the caller for the full deadline.
"""
return (
f"bash -c '"
f'( sleep 0.1 && tmux send-keys -t "$1" Enter ) & '
f'timeout {full_timeout} tmux wait-for "$0"'

@joshalbrecht joshalbrecht Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk, maybe we could use this alternative suggested by claude instead?

Perl one-liner (no install, works everywhere)

perl -e 'alarm shift; exec @ARGV' 10 ./slow-thing

This execs the command, so it replaces the perl process — signals and exit codes behave sensibly. It's the closest drop-in.

f"' {shlex.quote(wait_channel)} {tmux_target.as_shell_arg()}"
script = (
'tmo="$(mktemp)" || exit 1; '
'trap \'kill "$waiter" "$watchdog" "$submit" 2>/dev/null; rm -f "$tmo"\' EXIT; '
'tmux wait-for "$2" & waiter=$!; '
'( sleep "$1"; echo 1 > "$tmo"; kill "$waiter" ) >/dev/null 2>&1 & watchdog=$!; '
'( sleep 0.1 && tmux send-keys -t "$3" Enter ) >/dev/null 2>&1 & submit=$!; '
'wait "$waiter" 2>/dev/null; waited=$?; '
'kill "$watchdog" 2>/dev/null; '
'[ ! -s "$tmo" ] && [ "$waited" -eq 0 ]'
)
return f"bash -c {shlex.quote(script)} _ {full_timeout} {shlex.quote(wait_channel)} {tmux_target.as_shell_arg()}"


def _build_signal_or_marker_command(
Expand All @@ -325,13 +336,18 @@ def _build_signal_or_marker_command(
"""Succeed as soon as EITHER the hook signal fires OR a fresh acceptance marker appears.

A single remote command so the two conditions are watched concurrently with
no dangling process: it registers the (full-timeout) hook waiter in the
background -- which writes a sentinel file on success, preserving the
register-before-Enter ordering so the signal is never missed (which matters
for submissions that only ever fire the signal, never recording a marker)
-- sends Enter, then polls both the sentinel and the acceptance marker until
either confirms or the deadline passes. Exit 0 = confirmed, non-zero =
timeout.
no dangling process: it registers the hook waiter in the background,
preserving the register-before-Enter ordering so the signal is never missed
(which matters for submissions that only ever fire the signal, never
recording a marker), sends Enter, then polls both the waiter and the
acceptance marker until either confirms or the deadline passes. Exit 0 =
confirmed, non-zero = timeout.

Like the signal-only path, the waiter is bounded by a ``sleep``-then-``kill``
watchdog so it runs on Linux and macOS alike. ``$waiter`` is the tmux client,
so a fired hook shows up as the client having exited (``kill -0``) with a zero
wait status and no ``$tmo`` marker; once the waiter is gone without confirming,
only the acceptance marker can, so the loop keeps polling it.

``accept_marker_command`` is the agent-supplied shell snippet that prints the
agent's latest acceptance-marker token (empty if none yet). A baseline is
Expand All @@ -343,20 +359,21 @@ def _build_signal_or_marker_command(
agent that supplies it.
"""
script = (
'sig="$(mktemp)"; '
# Clean up on every exit path: remove the sentinel file and reap any
# still-running background job (notably the hook waiter, which otherwise
# outlives a fast marker-win and would recreate "$sig" -- leaking the
# temp file -- when the hook finally fires). Runs exactly once on exit.
'trap \'p="$(jobs -p)"; [ -n "$p" ] && kill $p 2>/dev/null; rm -f "$sig"\' EXIT; '
'tmo="$(mktemp)" || exit 1; '
# Any of the three background jobs can outlive a fast marker-win.
'trap \'kill "$waiter" "$watchdog" "$submit" 2>/dev/null; rm -f "$tmo"\' EXIT; '
f'base="$({accept_marker_command})"; '
# Register the hook waiter first (full timeout), sentinel on success.
f'( timeout {full_timeout} tmux wait-for "$1" >/dev/null 2>&1 && echo 1 > "$sig" ) & '
# Register the hook waiter first, bounded by the watchdog.
'tmux wait-for "$1" & waiter=$!; '
f'( sleep {full_timeout}; echo 1 > "$tmo"; kill "$waiter" ) >/dev/null 2>&1 & watchdog=$!; '
# Then submit, after a beat so the waiter is registered.
'( sleep 0.1 && tmux send-keys -t "$2" Enter ) & '
'( sleep 0.1 && tmux send-keys -t "$2" Enter ) >/dev/null 2>&1 & submit=$!; '
f'end="$(( $(date +%s) + {int(full_timeout) + 1} ))"; '
"hook_pending=1; "
'while [ "$(date +%s)" -lt "$end" ]; do '
'if [ -s "$sig" ]; then exit 0; fi; '
'if [ "$hook_pending" -eq 1 ] && ! kill -0 "$waiter" 2>/dev/null; then '
'wait "$waiter" 2>/dev/null && [ ! -s "$tmo" ] && exit 0; '
"hook_pending=0; fi; "
f'cur="$({accept_marker_command})"; '
Comment thread
weishi-imbue marked this conversation as resolved.
Outdated
'if [[ -n "$cur" && "$cur" > "$base" ]]; then exit 0; fi; '
"sleep 0.25; "
Expand Down
Loading