Skip to content

Process management: signals, stdin forwarding, events, output fixes - #16

Merged
mosquito merged 17 commits into
masterfrom
feature/new-process-management
Aug 19, 2026
Merged

Process management: signals, stdin forwarding, events, output fixes#16
mosquito merged 17 commits into
masterfrom
feature/new-process-management

Conversation

@mosquito

Copy link
Copy Markdown
Collaborator

Summary

  • run: first Ctrl-C sends SIGINT to the spawned process (spid=1) and keeps streaming its remaining output; a second Ctrl-C (or a failed signal) cancels the whole operation.
  • run: forward local stdin to a running instance in the background instead of buffering it whole; fixes a race where -d/--detach could return before stdin was fully sent.
  • operation events (new): print an operation's raw event log.
  • output: JSON-like formatters keep nested dict/list fields as-is; tabular formatters (CSV/TSV/Table) flatten them to a compact JSON string instead of silently dropping the field. TableFormatter now drops optional columns before shrinking any that remain, so wide operation rows fit a narrow terminal instead of getting badly truncated. Padding/alignment is TTY-only now, so piped output isn't padded to the widest row seen.
  • show: embed stdout/stderr as JSON fields on the operation result instead of a raw-text tail; unified the STREAM vs. tabular code paths.
  • __main__: treat a closed stdout pipe (e.g. contree run | head) as expected shutdown (exit 141, SIGPIPE convention) instead of logging it as a network error.
  • cli: fix a crash where auth remove/auth's overwrite prompt, session delete, and skill remove all called input() with no EOF handling -- piping a non-interactive invocation with closed stdin and no -y/-f raised an unhandled EOFError. Added a shared ask() prompt helper that reprompts on an unrecognized answer and falls back to the given default on EOF/empty input.

Ctrl-C during `contree run` used to hard-cancel the entire operation
(tearing down the instance) immediately. Now it mirrors a real
terminal: send SIGINT to the main process (spid=1) via
operation_subprocess_kill and resume streaming so the process's
remaining output (e.g. a trailing summary line) still shows up. A
second Ctrl-C, or a failure to deliver the signal, falls back to the
old hard-cancel behavior.
ListSorter used to drop dict/list values for every formatter, so
nested API data (an operation's full result, an event's payload) was
unrenderable in JSON output without hand-flattening every field.
STREAM formatters (JSON/JSON-pretty) can serialise nested structures
natively, so let them keep what tabular formatters still can't.

grep's `submatches` relied on the old blanket drop to stay out of
normal JSON rows; exclude it explicitly now that JSON keeps nested
fields by default.
cmd_show printed decoded stdout/stderr as a separate raw-text write
after the JSON row for every stream formatter, including JSON/
JSON-pretty -- mixing a parseable JSON line with raw command output
on the same stdout stream broke `contree -o json show UUID | jq`.

JSON-like formatters now get stdout/stderr embedded directly in a
"result" field (nested, kept intact by ListSorter's new allow_nested),
matching run.py's own `_display_operation` convention. The raw-text
tail stays, but only for DefaultFormatter.
Replace the old single-shot _read_piped_stdin() (one full buffered
read, sent as one payload) with StdInReader/StdinForwarder: stdin is
read in a background thread, coalesced into bounded chunks, and
forwarded via operation_subprocess_stdin as it arrives, so piping in a
large file no longer buffers it all in memory or as one oversized
request body. The pipe stays open until local stdin actually closes.

The forwarder only starts once the spawned process's first event
(spid=1) confirms it's registered server-side -- an earlier write
404s/409s. Detach mode used to skip this confirmation entirely (no
event stream of its own), which could silently drop all forwarding
and hang the remote process waiting for stdin until timeout; it now
reuses the same event-gated path via stream_events_until_close's new
stop_after_forwarder, returning once stdin is sent rather than waiting
for the command itself.
Adds `contree operation events UUID... (alias ev)`: fetches and
prints every recorded event for each operation as one row per event.
JSON/JSON-pretty keep the payload nested; table/csv/tsv flatten it to
a compact JSON string in the same column, since ListSorter can't
represent a nested dict in a tabular row but the body is the point of
this command. Events are stored independently of the operation's
summarized result and available regardless of whether the operation
ran in the foreground, detached, or is still running -- useful when
`show`'s result snapshot looks incomplete.
run.md/agent.md/manual.md/skill_body.md described stdin as a single
non-TTY read sent as one base64 blob, and said Ctrl-C always cancels
the operation -- both stale relative to the background stdin
forwarding and the process-signal-first Ctrl-C behavior already in
the code. Also documents the new `operation events` command.
Piping any command's output into a pager or `head` and quitting
early already gets a clean "Network error: ... Broken pipe" from the
handler's own _NETWORK_ERRORS catch (BrokenPipeError subclasses
OSError). But formatter.close(), registered as an ExitStack callback,
runs during that exception's own unwind and can raise the same
BrokenPipeError again while flushing -- outside the try/except that
handled it the first time, so it surfaced as a second, unhandled
traceback instead of just exiting.
ListSorter now flattens dict/list values to a compact JSON string for
tabular formatters instead of dropping them, and TableFormatter drops
optional columns (back-to-front) before shrinking any that remain, so
wide operation rows still fit a narrow terminal instead of getting
badly truncated. Padding/alignment is now TTY-only, so piped output
isn't padded to the widest row seen.

show/events reuse this instead of hand-rolling their own STREAM vs.
tabular branches, and __main__ treats a closed stdout pipe (SIGPIPE
convention) as expected shutdown rather than a network error.
auth remove/overwrite, session delete, and skill remove all called
input() directly with no EOF handling, so piping a non-interactive
invocation (no -y/-f, closed stdin) crashed with an unhandled
EOFError instead of a clean error or safe default. Adds a shared
ask() prompt helper (types.py) that reprompts on an unrecognized
answer and falls back to the given default on EOF or empty input,
and wires it into all four confirmation sites.
StdInReader used to outlive the command it read for, racing a
long-lived shell's next input read. Move it into pty_utils.py as a
platform-conditional ABC (POSIXStdInReader interrupts a blocked read
via select() + self-pipe, never touching the fd's blocking mode;
Win32StdInReader polls msvcrt) and use it as a context manager in
cmd_run so it's always stopped, including if spawn itself raises.
Win32StdInReader always polled msvcrt regardless of whether stdin was
a real console, so piped/redirected input was silently dropped and
the first-chunk read always timed out into an open-empty-pipe state.
Dispatch on os.isatty(fd): console keeps the msvcrt poll, anything
else falls back to a plain blocking read like the POSIX reader.
@mosquito
mosquito requested a review from insomnes August 18, 2026 20:50
Comment thread contree_cli/cli/run.py Outdated
Comment thread contree_cli/pty_utils.py
Comment thread contree_cli/cli/run.py Outdated
Comment thread contree_cli/cli/run.py Outdated
Comment thread contree_cli/cli/operation.py Outdated
Comment thread contree_cli/cli/show.py Outdated
A full queue blocks the reader thread in queue.put(), which the wake
signal (self-pipe write / stop_event.set()) can't interrupt -- and a
blocked Windows piped os.read() has no interrupt primitive at all.
Move stop() onto the shared base class: it drains the queue (freeing
a producer stuck on put()), joins with a bounded timeout, then forces
a close=True sentinel so any consumer unblocks regardless of whether
the underlying read itself ever actually exited.
CliClient's global RetryPolicy is unbounded and unsafe, but
contree-client documents a 504 on this endpoint as ambiguous -- the
chunk may already have been written -- so blind retries can duplicate
stdin content. Bypass the retrying call() wrapper for this one
request (single attempt only), record any failure on the forwarder
instead of just logging at debug, and surface it to the user from
cmd_run.
stop_after_forwarder only re-checked whether the forwarder had
finished when a new SSE event arrived, so a quiet detached process
(forwarder done, no more output) left `run -d` blocked until the next
event or process exit. Join the forwarder right where it's started
instead of polling is_alive() on a later iteration.
httpx.TransportError, urllib3's HTTPError/TimeoutError, and the other
backend-specific transport exceptions don't inherit from OSError, so
a failed SIGINT delivery or cancel could escape both handlers as an
unhandled network error and skip the hard-cancel fallback entirely.
SIGNAL_ERRORS combines ContreeAPIError/OSError with whatever the
resolved CliClient backend declares as retryable/nonretryable.
cmd_events routes rows through whatever formatter is active --
DefaultFormatter renders a table, not JSONL -- but the subcommand's
help/description/epilog all unconditionally claimed JSONL output.
Describe the default as formatter-selected rows and note that -o
json is what actually produces JSONL.
cmd_show always embedded decoded stdout/stderr in the "result" field
passed to the formatter, but TableFormatter/DefaultFormatter flatten
nested dicts into a column -- so the default (non-TTY) path printed
the captured stream once in that column and once again raw below.
Only attach "result" for structured formatters; keep the raw write
for DefaultFormatter unchanged.

@insomnes insomnes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!

@mosquito
mosquito merged commit 2d1e64e into master Aug 19, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants