Repository navigation
Description
What to build
A Run must remain interruptible when its Harness has exited but a descendant still holds the session's stdout open. Gather live session execution and the already supervised Muse/OpenCode checks and OpenCode export into one deep Harness process module. Its implementation owns launching, prompt I/O, completion waiting and cleanup, so callers cannot apply different lifetime rules.
This is one standalone Ticket: the shared lifetime implementation, its callers and its regression tests fit one fresh context window.
Acceptance criteria
- Live sessions for all six Harnesses and the existing captured Muse/OpenCode checks and OpenCode export use one shared process-lifetime implementation. Remove the two independent spawn/write/wait/cleanup loops and retire the old captured-execution module; leave no forwarding-only compatibility module.
- Interruption remains observable until the child and every owned I/O worker have finished, including after CLI exit. A live-session regression with a finite descendant holding stdout in the original process group stops promptly on SIGINT, reports
interrupted, and cleans up before that descendant's natural expiry. Keep the equivalent captured-output regression. - Prompt stdin is null when absent; when present, write it once and close it. A prompt larger than pipe capacity cannot prevent interruption when the child does not read stdin.
- A stdin failure, stdout consumer/read/log failure, captured stderr read failure, worker panic or wait failure after spawn stops and reaps the owned child through the shared cleanup path and joins all started workers. Report the original I/O/worker/wait cause; an observed interruption reports
interrupted. - Streaming preserves raw Session log bytes and live progress, including unknown/malformed lines, with stderr inherited. Captured execution preserves both stdout and stderr. Normal completion waits for all owned I/O, returns the child's status without interpreting protocol success, and does not introduce extra process killing.
- Preserve adapter arguments, fixed environments, current directories, trust/approval flags, prompt transport, stop signals and grace periods. Preserve after-exit recovery/fallback, warning and summary behavior, failed-turn detection, final text, the last Session log and the once-only Resume rules. An interrupted OpenCode export must still fail rather than become fallback success.
- Add behavior tests through the owning module's interface for streamed/captured completion, blocked and broken stdin, consumer failure/panic and cleanup; keep process-isolated integration tests for signals and escaped process groups. Assert outcomes and owned-process termination, rather than thread counts or polling steps.
- Existing Harness/process-tree, background-work, Resume, recovery and Setup regressions pass, as do formatting, linting and the full test suite. Keep Linux/macOS support and use only exact recorded test PIDs for cleanup.
- Keep the previously unsupervised Claude minimal check, free Codex/agy/Grok catalog reads, child Runs and Setup signal-handler policy outside this Ticket.
Blocked by
None (can start immediately).
Decisions
The design tree is settled in three rounds: scope and constraints; interface and ownership; completion, failure and verification. Each dependent decision below follows the scope already settled.
-
Round 1 — scope. Consolidate the two already supervised execution paths: live Harness sessions and captured Muse/OpenCode checks/export. Reason: these paths already promise isolated process groups and adapter-directed stopping, and their duplicated lifetime rules have an observable divergence. Moving the attached Claude/catalog checks would also require deciding new behavior when direct Setup retains default fatal signals; that is a separate change. Child Runs likewise have distinct cooperative interruption and progress relaying.
-
Round 1 — constraints. Keep the six adapters, their protocols, fixed environments, stop-signal order and existing ten-second grace per signal. Install no new signal handler, timeout or trust/approval setting. Reason: ADRs 0012 and 0013 settle unattended session execution, and Command interruption is installed outside the execution module. Direct Setup and the first-Run Setup offer can call checks before that installation; the existing interruption predicate safely returns false there.
-
Round 1 — seam and deletion test. Put the owning process module within the Harness implementation, using the existing adapter seam. Add no additional process adapter trait or new public domain concept. Reason: six real Harness adapters already supply the variation; real local child processes and fake CLIs supply test stand-ins. Deleting the deepened module would spread prompt I/O, reader completion and cleanup ordering back into sessions and checks/export, which demonstrates depth and leverage rather than a forwarding abstraction.
-
Round 2 — interface, dependent on 1–3. Expose two operations: captured output returns the standard process Output, and streaming returns the process exit status together with an opaque result from a stdout consumer. Each takes the selected adapter, a prepared Command and optional stdin text. Streaming supplies a stdout consumer for raw logging/progress; captured output supplies byte collectors. Reason: callers need two real output behaviors, but neither needs a Child handle, reader handles, process-group ids or cleanup ordering. One private lifetime implementation serves both operations.
-
Round 2 — responsibility split, dependent on 4. Adapters and their callers prepare arguments and current directories. The owning module applies the adapter's fixed environment, creates the isolated process group, configures the appropriate pipes, and owns the Child from successful spawn onward. Session logging and stream condensation remain the streaming consumer's work; protocol parsing and after-exit recovery stay with the adapters. Reason: process ownership gains locality without moving Harness-specific knowledge into a generic parser or changing Session log semantics.
-
Round 2 — I/O ownership, dependent on 4–5. Start stdout and, for captured output, stderr consumers promptly; streaming inherits stderr. Write optional stdin in an owned worker concurrently with draining output, closing stdin when finished even for empty input. Reason: neither a full output pipe nor a child refusing to consume a large prompt may prevent the controller from noticing interruption. The module owns and ultimately joins every worker it starts.
-
Round 3 — completion, dependent on 6. Cache the exit status when observed, but continue checking interruption and worker outcomes until the child has exited and all I/O workers have finished. Join only completed workers during supervision; check interruption again before returning success. EOF alone does not mean the process is done, and CLI exit alone does not mean output is done. Reason: this removes the demonstrated retained-stdout gap and the equivalent blocked-input gap without adding a completion deadline.
-
Round 3 — failure, dependent on 5–7. All fallible steps after spawn share an owning cleanup guard. Detect worker errors and panics while the child is still running; an observed interruption takes precedence, otherwise preserve the first reported failure with its existing context. Stop the process, reap it and join all started workers before returning an error. Spawn failure has no child to clean up. Reason: stdout/log failures currently kill only the initial process group, and dropping a Child or unjoined reader does not complete cleanup.
-
Round 3 — stopping, dependent on 8. The controller invokes the adapter's existing whole-process-tree stop path; workers never issue their own group-only kill. Retain existing grace and escalation. On ordinary successful completion, do not add unconditional descendant killing or infer killed background work from process liveness. Reason: cleanup must use the same Harness policy, while Resume continues to depend on documented stream evidence. The existing stop implementation can stop the original group and descendants still discoverable when stopping; this Ticket does not promise tracking every escaped descendant already reparented after CLI exit.
-
Round 3 — outcomes, dependent on 4 and 7–9. The process module returns transport results and does not treat a nonzero status as a protocol error. Sessions and check/export callers retain status/failed-turn checks, after-exit reads, warnings, elapsed summaries and error wording; the Sessions module retains final text, logs and Resume decisions. Reason: Muse/OpenCode recovery and failed-turn rules differ from process success, and a shared lifetime module should hide lifetime complexity without absorbing these adapter decisions.
-
Round 3 — module tests, dependent on 4–10. Test both operations through their interface with finite local child commands and temporary files: exact captured bytes and streaming results, EOF before child exit, output held after child exit, large unread stdin, broken stdin, and stdout-consumer error/panic while the child is alive. A controlled failing writer in the streaming consumer provides a portable log-write failure. Assert the original cause and termination of recorded owned processes. Reason: the interface is the test surface; these tests survive changes to thread/channel organization and exercise cleanup that parser-only tests cannot.
-
Round 3 — integration tests, dependent on 11. Use separate thirdshift child processes for signal regressions, following the existing Scenario fixtures. Keep finite pipe-holder commands so a broken implementation eventually fails its latency assertion. Record task-owned PIDs and clean them up on assertion failure. Keep existing escaped-group and stop-grace cases, all six adapter session cases, captured check/export cases, Resume/recovery and Setup cases; replace any lifetime-only tests made redundant by the interface tests. Reason: the interruption flag is process-wide and cannot safely be reset by concurrent in-process tests, while integration tests prove that real callers cross the owning seam.
-
Round 3 — documentation and size, dependent on 1–12. Keep this as one standalone Ticket, with no dependencies. No CONTEXT.md or ADR change is needed: Harness and Resume retain their existing meanings, and the new private implementation is reversible. Reason: the work removes two lifetime implementations and migrates only their existing callers; it needs no new product policy or stable domain term.
-
Validation of the settled design. Reviewed Base branch main at
1a6f21ea8cb46c933725effb270a314064ec798c, equal to origin/main. A temporary process-isolated regression on that head measured SIGINT waiting 4.064 seconds for a finite stdout-holding descendant. A throwaway completion-loop change requiring both CLI exit and stdout-reader completion passed the same regression's under-two-second assertion. The prototype and regression were removed, thencargo test --test process_tree --test background_work --test harness_choice --test muse_sessions --test opencode_sessionspassed 47 tests on the restored head. Reason: this proves the friction and the completion rule without publishing implementation work. Both diagrams below passed Mermaid parsing.
Architecture review
Strength: Strong · Dependencies: local-substitutable · Covered by: none among all 46 open issue titles/bodies, rechecked before publication.
Files: src/session.rs, src/harness/process_output.rs, src/harness/process_tree.rs, src/harness.rs, src/harness/muse.rs, src/harness/opencode.rs, src/harness/opencode/export.rs, tests/process_tree.rs; a private Harness process module replaces the duplicated lifetime implementation.
Before
flowchart LR
S[Sessions] --> L[Live execution]
K[Muse and OpenCode checks] --> C[Captured execution]
E[OpenCode export] --> C
L --> A[Harness adapters]
C --> A
A --> T[Process tree]
L -. CLI exit then blocking join .-> R[Pipe readers]
C -->|wait while checking interruption| R
classDef leak stroke:#dc2626,stroke-width:2px
class L,R leak
After
flowchart LR
S[Sessions] --> L
K[Muse and OpenCode checks] --> C
E[OpenCode export] --> C
subgraph P[Harness process module]
direction TB
L[Live execution] --> W[Shared lifetime]
C[Captured execution] --> W
W --> R[Pipe readers]
end
W --> A[Harness adapters]
A --> T[Process tree]
classDef deep stroke-width:3px
class P deep
Problem: live execution stops interruption polling before joining its stdout reader, while captured execution keeps polling until readers finish (live wait, captured wait).
Solution: deepen the Harness process module by absorbing shallow lifetime plumbing into one interface, while each adapter keeps its protocol implementation behind the existing seam.
Wins
- Depth: lifetime behind one interface
- Locality: completion and cleanup together
- Leverage: six Harness adapters benefit
- Tests cross the owning seam
- Delete duplicated lifetime plumbing
Activity