Skip to content

Rectify: Orphaned Detached Child Processes — Spawner-Death Immunity - #4695

Merged
Trecek merged 22 commits into
developfrom
impl-rectify-orphan-process-immunity-20260818-145810
Aug 19, 2026
Merged

Rectify: Orphaned Detached Child Processes — Spawner-Death Immunity#4695
Trecek merged 22 commits into
developfrom
impl-rectify-orphan-process-immunity-20260818-145810

Conversation

@Trecek

@Trecek Trecek commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Every child-session lifecycle mechanism (timeout, idle/stale watchdogs, extension cap, reap bookkeeping) runs inside the spawning Python process, while children are deliberately detached into fresh process groups. When the spawner dies, enforcement dies with it (issue #4678, Incident A: a codex cook session survived its dead spawner 4 days at ~87% CPU). Four prior fixes (#4246, #4536, #4569, #4573) each closed one orphan signature; each new topology escapes because nothing makes registration-for-out-of-process-enforcement structurally inseparable from spawning.

Closes #4678

Implementation Plan

Plan file: .autoskillit/temp/rectify/rectify_orphan_process_immunity_2026-08-17_151729.md

🤖 Generated with Claude Code via AutoSkillit

Trecek and others added 19 commits August 18, 2026 18:49
…ring

Implements the spawner-death immunity mechanism for issue #4678 (Incident A):
every detached child spawned through spawn_owned_process now durably records
a process tether (spawner identity, child identity, absolute not_after
ceiling) as a mandatory, fail-closed side effect. sweep_orphaned_tethers is
the single generic reaper wired into every boot/open chokepoint, reaping only
identity-verified targets of a dead-or-expired guardian.

New module: execution/process/_process_tether.py
- TetherRecord/TetherSpec/TetherSweepReport/TetherSweepOutcome/OrphanedTetherRecord
- write_tether/remove_tether/update_tether_workload (via write_versioned_json/
  read_versioned_json)
- sweep_orphaned_tethers[_async] and find_orphaned_tethers (read-only listing)
- DEFAULT_TETHER_CEILING_SECONDS (24h) / INTERACTIVE_TETHER_CEILING_SECONDS (48h)

Core wiring:
- spawn_owned_process gains a required `tether: TetherSpec` kwarg; writes the
  record after group-leadership validation, fails closed on unwritable dirs
  or unreadable child identity (Linux only). cleanup() removes the tether on
  a complete settlement.
- run_managed_async/run_managed_sync/DefaultSubprocessRunner thread a new
  `ceiling_seconds` parameter through to the tether; the SubprocessRunner
  Protocol and RecordingSubprocessRunner/ReplayingSubprocessRunner gained the
  matching parameter for protocol conformance.
- PTY-wrapper hazard: run_managed_async resolves and attaches the real
  workload's identity to the tether for every PTY-wrapped spawn on Linux,
  decoupled from whether linux_tracing is enabled — a wrapper-only tether
  would recreate the orphan class one level down when the wrapper dies but
  the workload survives reparented.
- run_cook_attempt gains a required `not_after` ceiling enforced in both the
  non-PTY poll loop and the PTY observer's cancelled callback — the live
  spawner's own in-process backstop, independent of the tether sweep.
- New IL-008 exception: exploration/collectors/_bounded.py migrates its one
  raw subprocess.Popen(start_new_session=True) onto the funnel (the tether
  plan's mandated closure of the last unfunneled detached spawn).

Chokepoint wiring: sweep_orphaned_tethers_async in all three lifespan boot
gates (fleet/food-truck/skill) and the periodic _cleanup_stale_loop; the
codex/daemon manual-only reapers promoted to automatic in fleet/food-truck
via _reap_self_excluded_codex_and_daemon_orphans; open_kitchen's new
tether_sweep transition step covers interactive sessions.

Config: ProcessTetherConfig (orphan_ceiling_seconds/cook_ceiling_seconds,
literal defaults parity-tested against the module constants since config
cannot import execution) + a warn-only coherence gate; threaded through cook
launch (_session_cook.py/_session_launch.py/_fleet_session.py) and the three
headless invocation sites, mirroring max_extension_seconds's existing
threading pattern.

Ops surface: `autoskillit process-orphans [--reap]` CLI command mirroring
codex-orphans/daemon-orphans, plus a warn-only doctor check (46).

Registry-sync: core/__init__.pyi (read_pid_namespace_inode), execution
process/__init__.py and execution/__init__.py re-export facades,
DURABLE_ARTIFACT_WRITERS, test_process_submodules.py's strict-equality
export set, the kill-funnel and StrEnum-compare AST allowlists, doctor check
count, and various line/file-count arch-guard exemptions.

task test-check: 38057 passed, 616 skipped, 27 xfailed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e-mark

- _record_spawn captures spawner_pid/spawner_starttime_ticks/boot_id/
  child_starttime_ticks/pidns_inode/not_after on the manifest (optional
  additions, schema_version stays 1) so the codex store is self-sufficient
  for its own recovery decisions independent of the Phase-1 tether.
- recover() replaces the unconditional mark-without-kill with
  verify-before-mark: identity-verified live child is killed via the shared
  kill funnel before being marked reaped; dead/identity-mismatched children
  are marked reaped without a kill attempt; legacy manifests with no
  identity fields fail closed (never kill, never lie) and leave the view
  retained with state="failed"; kill failures also leave the view retained.
  These leave-unreaped outcomes warn-and-continue rather than raising, so
  an unresolvable view can never block the two unguarded
  recover_cook_history() call sites (_session_cook.py, _session_order.py).
- ceiling_seconds threaded through prepare_attempt/record_spawn from both
  interactive cook launch paths (cook, fleet/nonpersistent launch), with a
  literal-default fallback on the CodingAgentBackend protocol itself since
  core (IL-0) cannot import execution (IL-1).
- Registry-sync: _codex_session_storage.py added to the kill-funnel AST
  allowlist alongside its new kill_process_tree call.
- Death-assertion upgrades: idle-stall, PTY stale/timeout, heartbeat, and
  Channel B lifecycle tests now assert the child process actually died,
  not just the termination reason enum.
- New test_extension_cap_kills_real_process proves max_extension_seconds
  kills a real continuously-active process, not just a fake pid=1 scope
  decision.

Phase 2 of the orphan-process-immunity rectify plan
(.autoskillit/temp/rectify/rectify_orphan_process_immunity_2026-08-17_151729.md).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…emd ceiling

- test_no_detached_spawn_outside_owned_funnel: codebase-wide AST guard that
  flags any subprocess.Popen/asyncio.create_subprocess_exec/_shell call
  setting start_new_session=True or process_group=0 outside the funnel
  (spawn_owned_process), plus a violations-by-default fallback for calls
  whose kwargs cannot be statically proven safe (**kwargs unpack, non-literal
  keyword values) — closing the exact indirection the funnel itself uses
  internally. Two-row allowlist ties rationale to entry so it cannot drift
  the way the kill-funnel guard's docstring already had.
- test_spawn_owned_process_requires_tether: locks the required-param
  invariant spawn_owned_process already enforces (Phase 1) against future
  soft-defaulting.
- resource_exhaustion_guard.py: new PreToolUse guard denying Bash/run_cmd
  commands matching a backgrounded-infinite-loop or `kill %N` job-control
  pattern — the exact shape of issue #4678 Incident B. Documents its own
  threat model (GuardFall bypass classes) rather than claiming completeness;
  the tether ceiling and host limits are the structural backstop this
  pattern guard cannot be.
- Optional, default-off systemd-run --user --scope ceiling: probe_systemd_
  scope_available + wrap_systemd_scope in _process_tether.py, threaded
  through run_managed_async/DefaultSubprocessRunner/the SubprocessRunner
  protocol, the three headless invocation sites, and both interactive cook
  spawn branches (non-PTY and PTY-wrapper). Inert unless
  ProcessTetherConfig.systemd_scope_enabled is set; the config field's
  docstring records the three conditions that make it real (WSL2 systemd,
  loginctl enable-linger, probe success) and the one that makes it
  unreliable (RuntimeMaxSec is not reliably enforced) — the tether's
  wall-clock not_after stays the ceiling of record.
- Fixture/budget maintenance: refreshed the codex hook-event snapshot and
  HOOK_REGISTRY_HASH for the new guard registration; bumped the three
  architectural size budgets (headless/_headless_execute.py,
  hooks/guards/ file count, _codex_session_storage.py line limit) that the
  legitimate parameter/file additions in Phases 2-3 pushed past their
  prior ceilings, with rationale attached at each site.
- Test-double repair: several hand-written CodingAgentBackend stubs across
  tests/cli/ implemented cook_session_context with an explicit parameter
  list (not **kwargs), so Phase 2's ceiling_seconds addition and this
  phase's systemd_scope_enabled addition to the protocol surfaced as
  TypeErrors when real callers passed them — fixed each stub in place
  rather than loosening them to **kwargs, matching the existing style at
  each call site.

Phase 3 (final phase) of the orphan-process-immunity rectify plan
(.autoskillit/temp/rectify/rectify_orphan_process_immunity_2026-08-17_151729.md).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three registry-style contract tests only run under the full suite (they
were absent from every targeted subset I'd run through Phase 3):

- test_moved_scripts_must_be_in_retired: new subdir script
  resource_exhaustion_guard.py needs a NEW_SUBDIR_BASENAMES entry (it was
  never flat, so RETIRED_SCRIPT_BASENAMES doesn't apply).
- test_safety_hook_counts_match_registry: docs/safety/hooks.md's counts
  (51 total / 37 PreToolUse) and the missing per-hook section for the new
  guard, now 52 / 38 with an entry matching the module's threat-model
  docstring.
- test_coding_agent_backend_new_lifecycle_signatures_are_exact: this one
  predates Phase 3 — Phase 2 added ceiling_seconds to
  CodingAgentBackend.cook_session_context but never updated this exact-
  signature registry test. Fixed in the same commit as the other two
  since all three surfaced together from the same full-suite run.

Verified via the test_check MCP gate (unfiltered — "large_changeset" full
run): 38156 passed, 619 skipped, 27 xfailed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…velop

Rebasing onto upstream/develop brought in the #4664 decompose refactor's
new execution/process/_termination.py (9 -> 10 files), and this branch's
own _process_tether.py (Phase 1) pushes the same subdir to 11 -- one over
the default 10-file cap with no prior exemption entry for this subdir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… note

Two MISSING findings from the 2026-08-18 audit-impl run, both real gaps
against Phase 1 Step 2 item 6 / Step 1 test 6 of the rectify plan:

- cook/order startup did not sweep orphaned tethers before
  recover_cook_history() at the unguarded call sites
  (_session_cook.py:325, _session_order.py:189), leaving the Phase 1
  "every chokepoint" invariant broken for cook/order startup. Both now
  run sweep_orphaned_tethers(default_tether_dir()) fail-open before the
  call, matching every other chokepoint's try/except-warn pattern.
- test_cook_attempt_enforces_ceiling_in_both_wait_branches was absent,
  so the live-spawner ceiling enforcement claim (both the non-PTY
  _wait_for_owned_exit poll and the PTY observer.relay
  cancelled-callback terminate at not_after) had no regression test.
  Added to tests/cli/test_cook_process_lifecycle.py, mirroring this
  file's existing _wait_until_gone/_spec idioms and the elapsed-time
  assertion style from test_extension_cap_kills_real_process.

Also addresses the Phase 2 ODD note: test_record_spawn_captures_spawn_identity
used os.getpid() as both spawner and child pid, so child_starttime_ticks
trivially matched spawner_starttime_ticks and never proved independent
derivation from the child's own /proc entry. Now spawns a real child.

Not addressed (documented disagreement / non-blocking per the audit's
own labels, see conversation record):
- ODD R6 (write_tether machine_local=False): the plan's machine_local=True
  + detection=None combination is self-contradictory —
  _validate_durable_artifact_writer_defs asserts every machine_local=True
  entry must have a detection callable, so that combination would crash
  at import time. machine_local in this registry means "bakes an
  AutoSkillit install path that a relocation could break" (see the four
  existing machine_local=True entries, all paired with a find_broken_*
  self-heal detector); tether records carry only process identity
  (PIDs, boot_id) with zero AutoSkillit-install-path content and a
  wholly different staleness mechanism (the sweep, not repair-on-detect).
  False is the semantically correct value.
- ODD R13/R16 (Phase 3/Phase 2 work landed in the Phase 1 commit) and
  R17/R18 (Phase 3 tests alongside Phase 1 tests): all four are
  commit-boundary-only observations the audit itself labels
  non-blocking / "not a defect" / "no behavioral issue". Every symbol
  each note describes is correctly wired in the tree regardless of
  which commit it landed in; moving them would require rewriting this
  branch's commit history (interactive rebase), which this environment
  disallows and which AGENTS.md's commit discipline does not require
  for informational findings.
- Phase 2 "out-of-scope work visible in diff": the audit labels this
  informational/not blocking; no separate action beyond what's above.

task test-check: 38171 passed, 621 skipped, 27 xfailed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review finding (critical/defense): TetherSpec accepted any ceiling_seconds
value (negative, zero, NaN, inf) without validation. A non-positive value
makes not_after pre-past, causing immediate reap of a live spawner's
child; NaN silently disables the ceiling check forever. Add a
__post_init__ mirroring ProcessTetherConfig.validate()'s existing check
and the codebase's math.isfinite() convention used elsewhere.
…ction

Review finding (warning/defense): TetherRecord (frozen dataclass,
durably written to disk and re-read on every sweep) had no range
validation on child_pid/child_pgid/spawner_pid/starttime_ticks/not_after/
workload_pid. Add __post_init__ raising ValueError on invalid values;
callers already wrap deserialization in
except (ValueError, KeyError, TypeError), so no call-site changes
are needed.
Review finding (warning/cohesion): run_managed_sync lacked the
systemd_scope_enabled defense-in-depth knob that its async sibling
run_managed_async already has, leaving sync callers structurally
unable to opt into the kernel-enforced ceiling. Mirror
run_managed_async's wrap_systemd_scope call.
Review finding (warning/tests): test_pty_wrapped_spawn_updates_tether_with_workload_identity
only asserted workload_pid != result.pid and workload_starttime_ticks
is not None, which would pass for negative/zero garbage values. Add
bound checks so a regression in the real /proc-based identity
resolution fails the test.
Follow-up to the prior commit: Pyright flagged `workload_pid > 0` as
unsafe since the tuple-unpacked value is typed object. Add an
isinstance narrow, matching the workload_starttime_ticks assertion
right below it.
…pers

Review finding (info/tests): _run_guard and _run_bash_guard were
near-identical, differing only in the tool_input key and tool_name.
Merge into one _run(cmd, shape=...) helper and add a shape parametrize
dimension to the 4 pairs of duplicated test functions, collapsing
8 near-duplicate functions to 4 while preserving every (command, shape)
test case. 51/51 tests still pass.
…tests

Review finding (warning/tests): test_malformed_json_fail_open and
test_missing_cmd_field_fail_open only asserted output == "", not that
main() actually exits 0 as documented — the hook exits explicitly on
every fail-open path. Add a _run_capture_exit helper (used only by
these two tests) and assert exit_code == 0 alongside the existing
output assertion. Other suggested coverage (missing tool_name/
tool_input, non-string cmd, cross-shape leak, metacharacter bypass)
intentionally not added — already covered elsewhere or explicitly
out of this guard's documented scope. 51/51 tests still pass.
Review finding (warning/slop): a 20-line inline comment above
ProcessTetherConfig.systemd_scope_enabled (WSL2/linger/probe
preconditions, RuntimeMaxSec unreliability) was the sole canonical
location for load-bearing operational rationale, disguised as a field
comment. Extract it to docs/decisions/0010-systemd-scope-defense-in-depth.md
following the existing ADR-0001..0009 convention, replace the inline
block with a short cross-referencing comment, and update
wrap_systemd_scope's docstring cross-reference to point at the same ADR.
Review finding (info/cohesion): RecordingSubprocessRunner.run,
RecordingSubprocessRunner._record_session, and ReplayingSubprocessRunner.run
each carried a literal ceiling_seconds: float = 86400.0 default with no
parity test guarding drift, unlike the core/execution pair. Verified no
import-layer restriction applies (execution/recording.py is IL-1, same
layer as execution/process) and no import cycle exists. Import
DEFAULT_TETHER_CEILING_SECONDS instead. 52/52 tests still pass.
…probe tests

Review finding (info/tests): the four TestSystemdScopeWrap probe tests
each re-imported `subprocess as _subprocess` inside the function body
despite the module already importing subprocess at top level. Use the
module-level import instead. 27/27 tests still pass.
Review finding (info/cohesion): _skill_auto_gate_boot deliberately omits
the codex/daemon orphan reap that _fleet_auto_gate_boot and
_food_truck_auto_gate_boot both call (per test_boot_step_symmetry.py's
explicit carve-out), but the asymmetry was only documented in the test
file, not at the call site. Extend the existing docstring omissions
sentence.
Review finding (info/cohesion): the CLI (_process_orphans.py) and doctor
(_doctor_runtime.py) rendered the same OrphanedTetherRecord+reason shape
via independently-maintained f-strings with different prefixes and no
shared source of truth. Add format_orphaned_tether_fields() next to
OrphanedTetherRecord, export it through execution/process and execution
facades, and use it at both call sites. 321/321 relevant tests still pass.
test_check surfaced 3 pinned-fixture failures caused by the review-fix
commits in this branch, not by the original PR:
- test_rule_4_max_three_segments: the new ADR-0010 filename exceeds the
  3-kebab-segment naming rule, same as every other numbered ADR in
  docs/decisions/ — add it to the existing segment_count allowlist.
- test_req_api_003_params: run_managed_sync gained systemd_scope_enabled
  (REQ-API-003 pin), mirroring run_managed_async's already-pinned
  REQ-API-001 param set which includes the same field.
- test_process_facade_reexports_all_public_symbols: process.__all__
  gained format_orphaned_tether_fields.

All three are deliberate, reviewed additions from this branch's own
fix commits, not regressions — update the pins to match.
@Trecek
Trecek added this pull request to the merge queue Aug 19, 2026
@Trecek
Trecek removed this pull request from the merge queue due to a manual request Aug 19, 2026
@Trecek
Trecek enabled auto-merge August 19, 2026 05:42
Trecek and others added 3 commits August 19, 2026 08:31
The develop-merge (2edda61, #4694) brought in the FORWARDING_SITES /
DYNAMIC_READ_EXEMPTIONS ground-truth registry in tests/_ambient_env_surface.py.
This branch's own diff shifted line numbers in six of the registered files
(cli/session/_session_launch.py, execution/backends/_codex_probes.py,
execution/backends/claude.py, execution/backends/codex.py,
execution/evidence_reader.py, server/_lifespan/_session_boots.py), so the
registry's line-number keys no longer matched the AST scanner's findings on
the merged tree. Updated the 12 stale keys to their current line numbers;
justification text and site set are unchanged.
…4699)

Empty commit only. The prior CI runs on this branch were pinned to a
pre-fix merge-ref snapshot (rerunning a workflow replays the original
frozen github.sha rather than recomputing the PR merge ref against the
current base branch), so they kept exercising the flaky apt/ripgrep
path even after the fix merged to develop. This forces a fresh
pull_request synchronize event so the checks run against develop's
current tip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Trecek
Trecek added this pull request to the merge queue Aug 19, 2026
Merged via the queue into develop with commit 58ea53c Aug 19, 2026
4 checks passed
@Trecek
Trecek deleted the impl-rectify-orphan-process-immunity-20260818-145810 branch August 19, 2026 17:51
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