Skip to content

feat(agentic_isolation): thin Python client for itmux run (Plan B Task 7)#250

Open
NeuralEmpowerment wants to merge 4 commits into
feat/itmux-run-contractfrom
feat/itmux-python-client
Open

feat(agentic_isolation): thin Python client for itmux run (Plan B Task 7)#250
NeuralEmpowerment wants to merge 4 commits into
feat/itmux-run-contractfrom
feat/itmux-python-client

Conversation

@NeuralEmpowerment

Copy link
Copy Markdown
Contributor

Plan B Task 7. A thin Python run_agent that shells out to the Rust itmux run binary and parses its AgentRunEvent JSONL + AgentRunResult. Owns NO orchestration - the Rust binary owns the whole provision->stop lifecycle. This is the syn137-facing half of the Rust contract migration; supersedes the Python orchestrator/contract in #240 (which will be closed once this + #247 land). Stacked on #247.

Surface

def run_agent(recipe, task, *, image=None, itmux_bin="itmux",
              on_event=None, timeout=None) -> AgentRunResult

Builds itmux run --recipe <r> --task <t> [--image <i>] --json true, streams stdout JSONL, fires on_event per event, returns the result from the Result-payload event. Raises ItmuxRunError (carrying returncode/stderr) on non-zero-exit-no-result, unparseable output, missing terminal result, or timeout.

Contract parity

Pydantic v2 discriminated union: AgentRunEvent = Annotated[ToolStart | ToolEnd | TokenUsage | SessionEnd | Result, Field(discriminator="type")], each variant frozen + extra="forbid", serde field names matched exactly. This mirrors the Rust #[serde(tag="type", deny_unknown_fields)] behaviorally: discriminator picks the variant, per-variant forbid rejects stray keys.

Process-group cleanup (R10)

Child spawned start_new_session=True (leads its own group). On KeyboardInterrupt / parse failure / timeout, os.killpg(pgid, SIGTERM) -> grace -> os.killpg(pgid, SIGKILL) reaches the Rust process AND its docker exec grandchildren. try/except BaseException tears down before re-raise; finally kills any survivor + joins a stderr-drain thread (no deadlock on a full stderr pipe); a threading.Timer enforces timeout.

Tests: 12, all pass

Model discrimination + unknown-field rejection (incl. Result line + unknown discriminator); happy-path result + per-event on_event; argv construction; the 3 error paths; a killpg-pgid monkeypatch unit test (SIGTERM + SIGKILL); a real orphan-reaping test (fake spawns a grandchild, blocks, timeout fires, grandchild confirmed dead). All fakes are on-disk stubs via itmux_bin - no real docker/itmux.

Reviewer note

  • Adds pydantic>=2 (+ pydantic.mypy plugin) to a previously zero-dep package - deliberate, per the task's Pydantic-v2 mandate and for serde parity. Flagging given the minimize-deps preference; the dep-free alternative is dataclasses + hand-rolled tag dispatch (loses the extra="forbid" parity).
  • --json true (not bare --json) - the clap arg is ArgAction::Set and requires a value.

Gates: pytest 12/12, ruff check + format clean, mypy --strict clean (no Any). Tracks okrs-o78.

…k 7)

Add run_client.py: a thin Python client that shells out to the Rust
`itmux run` binary. It owns no orchestration or business logic - all of
that stays in the Rust subcommand. Responsibilities:

- Mirror the wire contract (AgentRunResult, AgentRunEvent + payload
  variants) as strict Pydantic v2 models (frozen=True, extra="forbid")
  whose field names match the Rust serde names exactly. The event stream
  is an internally-tagged discriminated union on the `type` field,
  matching serde's #[serde(tag = "type", deny_unknown_fields)].
- run_agent(): spawn `itmux run --recipe --task [--image] --json true`,
  stream stdout event JSONL line-by-line, invoke on_event per event, and
  return the AgentRunResult from the terminal `result`-payload event.
- Process-group cleanup (R10): child runs in its own session/group
  (start_new_session=True); on cancel/crash/timeout the whole group is
  signalled SIGTERM then SIGKILL so the Rust process and any docker exec
  grandchildren are reaped, never orphaned.

Supersedes the hand-written Python contract from #240 (closed separately).

Adds pydantic>=2 as a dependency of the previously zero-dep package to
get the discriminated union + deny-unknown-fields guarantees for free.

Gates: 12 new tests pass, ruff check/format clean, mypy --strict clean.

Claude-Session: https://claude.ai/code/session_019TtekhJft4s2qBGRGgxToG
…ct models, result/timeout ordering

Dual-review fixes for the thin itmux run client:

- Process-group teardown is now kill-before-reap (codex must-fix 1+2):
  SIGTERM the whole group first (lets the Rust leader tear its container
  down gracefully; a premature SIGKILL would orphan the container, #248),
  poll a bounded grace for the GROUP to drain, SIGKILL any survivor, then
  reap the leader zombie last. No longer short-circuits when the leader
  has exited but a docker-exec grandchild survives, and never killpg's a
  pgid it has already reaped (PID-reuse safe). The stderr drain is joined
  only after the group is dead.
- Strict Pydantic scalars (codex must-fix 3): u64-backed fields (seq,
  input_tokens, output_tokens) use strict + ge=0 and bools use StrictBool,
  so seq:-1, input_tokens:"1", output_tokens:-2 and success:"false" are
  rejected exactly as serde rejects them.
- Result wins over a simultaneous timeout (Claude nit 1): a delivered
  terminal result is returned even if the watchdog fired in the same
  instant.
- Break on the terminal ResultEvent (Claude nit 2): liveness no longer
  depends on EOF, so a child that emits the result then hangs (timeout
  None) cannot block run_agent.
- Cleanup test uses a real survival check via ps state (codex nit 2), so
  a killed-but-unreaped zombie is not mistaken for a live process.

Keeps pydantic a range with an upper major cap (>=2,<3): this is a
library, so exact-pinning would break consumer resolution; the cap
guards against a future pydantic 3 break.

Gates: 19 tests pass, ruff check/format clean, mypy --strict clean on
run_client.py + test_run_client.py.

Claude-Session: https://claude.ai/code/session_019TtekhJft4s2qBGRGgxToG
…SIGKILL

Codex re-review must-fix on _terminate_process_group: proc.poll() in the
grace loop reaped the leader zombie the instant it exited, freeing its pid.
Because pgid == leader pid, the pgid became eligible for OS recycling before
the final killpg(SIGKILL), so a concurrent fork could get that pgid and eat
the signal.

Invariant now enforced: the leader stays UNREAPED (alive or zombie) until
AFTER the final group signal, which keeps its pid/pgid reserved so every
killpg provably targets our group. Grace phase does NO reaping - no poll(),
no wait(), no waitpid. New ordering: SIGTERM group -> plain chunked sleep
(no early exit; killpg(pgid,0) can't distinguish a lingering zombie leader
from live descendants, so there is no correct non-reaping early exit) ->
SIGKILL group -> reap the leader LAST. The early returncode guard still
short-circuits a second teardown pass on an already-reaped (recyclable) pgid.

Because there is no early exit, teardown now always sleeps the full grace.
To keep that off the happy path, run_agent uses a short post-result grace
(0.5s, leader already done) vs the full crash/timeout grace (5s), and the
watchdog + finally teardown paths are serialized with a lock + once-flag so
they can never both run a full teardown concurrently (which would reintroduce
the cross-thread reap-then-signal race).

Tests: result-wins-over-timeout is now deterministic (a blocking on_event on
the ResultEvent lets the watchdog fire while run_agent holds the result); the
grandchild survival check already uses ps state, not os.kill(pid,0). A
fast-teardown fixture shrinks the grace so no-result/timeout tests stay quick.

Gates: 19 tests pass (stable across repeats), ruff check/format clean, mypy
--strict clean on run_client.py + test_run_client.py.

Claude-Session: https://claude.ai/code/session_019TtekhJft4s2qBGRGgxToG
@NeuralEmpowerment

Copy link
Copy Markdown
Contributor Author

Dual-review gate: PASS

  • Claude adversarial review: APPROVE - traced every teardown exit path; contract mirror faithful.
  • codex cross-model review: CHANGES REQUESTED (3 must-fix: grandchild leak, strict models, result/timeout ordering) -> fixed -> re-review found 1 residual PGID-reuse race (poll() reaping the leader before final SIGKILL) -> fixed (leader stays unreaped through SIGKILL; watchdog/finally teardown serialized with a lock + once-flag) -> 2nd re-review APPROVE, no deadlock, no residual race.

Final teardown ordering: killpg(SIGTERM) -> chunked grace sleep (no reaping) -> killpg(SIGKILL) -> proc.wait() reaps leader last, so the unreaped leader keeps the pgid reserved through the kill. Strict scalars (NonNegativeInt/StrictBool) reject serde-incompatible lines; result beats simultaneous timeout; loop breaks on the terminal ResultEvent. 19 tests, ruff + mypy --strict clean on both files. pydantic>=2,<3 (library-appropriate range + major cap).

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.

1 participant