--- model: claude-opus-4-7[1m] service: claude session: trio-0.33-subproc-supervisor-retroactive timestamp: 2026-06-01T23:14:29Z git_ref: 0e3e008b scope: code substantive: true raw_file: 20260601T231429Z_0e3e008b_prompt_io.raw.md --- ## Prompt **RETROACTIVE LOG** — original session prompts not preserved; reconstructed from the staged work product. The work designs a `trio.Nursery.start()`-style wrapper around `trio.run_process()` for SC-friendly subprocess supervision. From the resulting code shape, the prompting intent was: 1. Surface rc!=0 `CalledProcessError` DETERMINISTICALLY, without the nursery-eg-wrapping that complicates `collapse_eg()` usage and races the relay reader on trio's `check=True`-driven cancel cascade. 2. ALWAYS isolate the parent controlling-tty so a spawned child can't emit terminal control-seqs onto the launching tty (clobbering scrollback). Default `stdin=DEVNULL`; default `stdout=DEVNULL` unless explicitly relayed/overridden; distinguish "caller passed nothing" from "caller passed `None` for inherit". 3. Optional live per-line relay of child std-streams to the `tractor` log — STREAMED (not buffered-until-exit) so long-lived daemon output is visible during the run. Pick a custom log level that shows at usual `info`/`devx` console levels but is separately filterable. 4. Concurrent pipe-drain reader MANDATORY when piping without `capture_*` — without it the child blocks on `write()` once the OS pipe buffer fills (~64KiB), causing deadlocks on output bursts. 5. Non-blocking `tn.start()` semantics: hand the live `trio.Process` to the parent immediately; supervise/relay run to completion in the supervisor coro. 6. Hermetic `trio`-only unit tests (no actor-runtime) covering each of: per-line relay, tty isolation, no-deadlock on >64KiB unnewlined output, CPE rebuild w/ stderr relay, CPE rebuild on the silent drain+capture path. ## Response summary Adds `tractor/trionics/_subproc.py` (296 LOC) + `tests/trionics/test_subproc.py` (230 LOC) + a re-export in `tractor/trionics/__init__.py`. **`supervise_run_process()`** (public, re-exported) - `check=False` is forced to `trio.run_process`; the rc-check runs in the supervisor coro AFTER `own_tn` unwinds (both the child AND the relay readers have hit EOF + fully drained). A BARE `subprocess.CalledProcessError` is rebuilt + raised from there, with `.stderr` bytes passed in the constructor AND attached as an `add_note()`'d `|_.stderr:` block for legible teardown logs. - `stdin=DEVNULL` always. `stdout` default chosen via a `_UNSET` sentinel: `relay_stdout=True` → PIPE, explicit `stdout=...` → as given, else `DEVNULL`. `stderr` defaults to PIPE whenever we relay OR need the CPE note (when `check=True`), else `DEVNULL`. - `relay_level='io'` (custom level 21; sorts just above stdlib `INFO`=20 so it shows at usual `info`/`devx` levels and stays separately filterable). `runtime`=15 would silently filter at default levels, so it's rejected as a default. - `task_status.started(trio_proc)` delivers the live process immediately. The internal `own_tn` supervises `trio.run_process` + any relay readers to completion. - `**run_process_kwargs` forward verbatim; `stdin/stdout/stderr/check` are MANAGED keys (override on conflict). - Crash-handling deliberately NOT baked in — compose `maybe_open_crash_handler()` on top at the call-site. **`_relay_stream_lines()`** (internal helper) - Three modes (combinable): `emit`-only (live per-line relay), `accum`-only (silent drain+capture for a CPE note), or both (live relay AND capture). - Per-line split handles cross-chunk residuals via a rolling `residual` bytes buffer; flushes any trailing un-newline-term'd line at EOF. - `async with stream:` ensures aclose at EOF/cancel (mirrors trio's internal `_subprocess` drain idiom). **`_add_stderr_note()`** (internal helper) - `add_note()`s a `textwrap.indent(...)`'d `|_.stderr:` block onto a `CalledProcessError` for teardown logs. **Tests** (5 hermetic, trio-only) — `_capture_relay` fixture monkeypatches `_subproc.log.` to a list: - `test_stdout_relayed_per_line`: per-line stdout relay carries each `line=N` to the records. - `test_parent_tty_isolated`: `readlink /proc/self/fd/0` and `fd/1` from the child show `pipe:` (fd1) + `/dev/null` (fd0); NO `/dev/pts/*`. - `test_no_deadlock_on_big_unnewlined_output`: 200KiB of `x` with no newlines completes inside `fail_after(2)` — exercises the concurrent drain. - `test_stderr_relay_and_cpe_rebuild`: rc=3 with `relay_stderr=True` raises bare CPE (via `collapse_eg()`) with `b'boom' in cpe.stderr`, the note attached, AND per-line live relay. - `test_nonrelay_cpe_note`: rc=7 with no relay still produces CPE with `.stderr` + note via the silent drain+capture path. ## Files changed - `tractor/trionics/_subproc.py` — NEW. Public `supervise_run_process()` + helpers `_relay_stream_lines()` / `_add_stderr_note()` + the `_UNSET` sentinel. - `tests/trionics/test_subproc.py` — NEW. 5 hermetic trio-only tests + `_capture_relay` monkeypatch fixture. - `tractor/trionics/__init__.py` — re-export `supervise_run_process`. ## Human edits **RETROACTIVE**: this log is being written from the staged diff, not from a live session. The code as staged is the canonical artifact; any human edits the user made during the originating design session are already integrated and cannot be separated post-hoc. The `.raw.md` sibling is a diff-pointer placeholder, NOT a pre-edit transcript. Future prompt-io entries for in-flight work should be written DURING the design session per the skill contract so the pre-edit `.raw.md` captures the unedited model output for genuine provenance.