Skip to content

devguard, runner, server, store: lifecycle of governed executions after launch (CSRG-U5) - #92

Merged
novelKR merged 6 commits into
mainfrom
codex/csrg-u5-lifecycle
Oct 11, 2026
Merged

novelKR merged 6 commits into
mainfrom
codex/csrg-u5-lifecycle

Conversation

@novelKR

@novelKR novelKR commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Requested by Seongjae

CSRG-U5 covers the lifecycle of a governed execution after launch:

  • one reaper per process;
  • observation before the reap for governed pipe executions;
  • termination of a governed scope;
  • process control without the authority;
  • approvals bound to DevGuard's proof that a command never started;
  • tests for the order's no-replay, release and race cases.

Full local validation and CI passed on the final head (see Tests). No Codex or DevGuard pin change.

On PTY, observation still happens after the reap only. This is the interim state while a separate Codex pin PR is prepared, as the owner chose (option 3, 2026-10-11); it is not a final CS-RG limitation. The merge of this PR will either include that change, if it has merged first, or be asked about again.

Issue

Scope: the CSRG-U5 section of the owner's U1–U6 order (DevGuard docs/handoff/2026-10-02-cs-rg-u1-u6-instruction.md), on top of merged U1–U4 and #91. This PR does not close an issue. Tracking: #76 and novelKR/DevGuard#14.

Item Value
Base main at 470069c (#91's merge)
Head 7b65c76
Pins Codex gitlink 6b9826e3 and DevGuard source 6e7e065, both unchanged

Summary

Inventory (order section 2), taken on 470069c before this PR:

Path Pipe (CodeSpace's tokio Child) PTY (pinned Codex PTY)
normal wait reap_child (try_wait every 20 ms) Codex's blocking task calls child.wait() right after the spawn
timeout try_wait, else kill the root has_exited(), else session.kill()
user and workspace termination request_kill: try_wait, else kill the root has_exited(), else session.kill()
shutdown, backend drop kill_on_drop and tokio's orphan queue Codex ProcessHandle drop (terminate)
task cancellation Spawned tasks are detached. A managed launch already runs, preparation included, in its own task that the request awaits (Preparer::prepare_and_launch, U4). A cancelled request drops neither its transcript wait nor its settlement same
helper failure (U4) Settler polls status and kills through kill_host; it never waits same
UDS disconnect The worker serves one connection; a lost worker is killed and its children are orphaned same

A pipe child had up to four possible reapers sharing one Child. There was no double waitpid, but whichever path ran first reaped. A PTY child has one reaper, inside Codex.

Correction: the first version of this body said a cancelled request drops a managed launch's settlement. A test written to show it (a_cancelled_request_does_not_drop_a_committed_launch) passed before any change. It stays as the regression test for that race.

Observation before reap (section 3). DevGuard releases a claimed scope only once it has observed the scope's end. Survivors stay tracked only if the scope is observed while the exited root still holds its PID (Observe). The pinned client already has observe and terminate.

  • Pipe. The one reaper sees the root's exit without reaping it (waitid with WNOWAIT), has DevGuard observe, then reaps it and observes again.
  • PTY. The pinned Codex PTY reaps inside its own task and offers no hook before the reap. Per the owner's choice, this PR observes after Codex's reap. Descendants that DevGuard's reconciler already saw while the root held its PID stay tracked after Codex reaps. Tracking is lost only for a descendant that appears, with its root exiting, before any observation sees it. The Codex PTY hook is prepared separately, and any fork, pin merge or upstream issue gets its own approval.

Dispatching state (section 6). An approval's dispatching state (resuming) is written before the owner prepares the governed execution, so it never lags the launch boundary (BeginLaunch).

  • If CodeSpace restarts anywhere after that point, the approval reads as ambiguous on the next resume. It is never replayed.
  • A refusal that happens while CodeSpace is running, and that carries DevGuard's proof the command never started, returns the approval to granted.
  • Narrowing the state to BeginLaunch itself would need a wire change: in uds mode the worker would have to wait for the gateway's write before it sends BeginLaunch. Recovering governed attempts after a restart is P1-RECOVERY. Both are outside this unit.

Commits:

  • 024e477 runner: each pipe child has one reaper.
    • reap_child is the only path that waits.
    • Timeout and termination record their intent and kill only a child that is still unreaped, which they check with waitid without reaping.
    • kill_on_drop stays as the hand-off at shutdown.
  • 9c57245 devguard, runner, server: a governed scope is observed before its root is reaped.
    • The runner's spawn takes a generic exit observer (ProcessHooks, ExitPoint::{BeforeReap, AfterReap}). It runs on a blocking thread with a 2 s bound, and its timeout or failure only skips the observation.
    • For managed launches the observer is DevGuard Observe (Owner::observe).
    • PTY is observed after Codex's reap only.
  • efd5070 devguard, runner, server: killing a governed process also terminates its scope.
    • A kill hook calls Owner::terminate, which sends SIGKILL to every identity DevGuard can recheck. It runs on its own thread and is never awaited.
    • Process status, input, termination and timeouts keep working with the authority stalled or gone.
    • The commit also adds the separate-events test and the cancellation regression test.
  • c143b2c runner, server, store: a resumed approval whose governed execution is refused with proof of non-start keeps its hold (ManagedPreparation::proves_not_started, Store::return_resume). Every other outcome consumes it as before.
  • 956b3b8 runner, server: tests for the section 7–9 cases that were not yet covered. These are an authority restart, a worker lost after a launch, shutdown, and how DevGuard's record maps to "never started". The gateway's fixture authority gains a restart command for the restart test.
  • 7b65c76 docs: a new "lifecycle after launch" section in English and Korean, plus fixes to the architecture page's current behaviour and to the execution contracts' approval paragraph.

Cases in order sections 7–9 and their tests. Tests added by this PR are marked U5. The adapter and gateway tests run real process trees (background descendants) against DevGuard's fixture authority and its real launch helper.

Section 7: no replay Tests
timeout after launch commit runner a_transcript_that_does_not_settle_in_time_is_an_unknown_dispatch (U4); adapter a_timed_out_admission_may_still_have_reserved (U3)
lost helper transcript runner a_transcript_that_does_not_settle_in_time_is_an_unknown_dispatch (U4); adapter transcripts_keep_ready_apart_from_what_the_executable_did (U4)
connection EOF gateway a_launch_lost_with_the_worker_is_unknown_and_never_sent_again (U5); gateway a_preparation_lost_with_the_worker_is_unknown (U3); adapter a_timeout_or_a_lost_reply_is_unknown_not_a_refusal (U3)
gateway-side task cancellation runner a_cancelled_request_does_not_drop_a_committed_launch (U5)
process handle unavailable gateway a_launch_lost_with_the_worker_is_unknown_and_never_sent_again (U5: unknown dispatch, workspace busy, approval consumed); runner only_a_state_that_proves_no_start_releases_an_approval (U5)
root exit before observation adapter a_reap_before_any_observation_loses_a_surviving_descendant (U5: suspect, tracking lost); runner only_a_no_helper_release_reads_as_never_started (U5: a suspect attempt reads as launched)
authority restart gateway an_authority_restart_keeps_a_running_attempt_charged_and_never_replays_it (U5)
duplicate client request gateway the_in_process_gateway_launches_each_governed_execution_once and the_uds_worker_launches_each_governed_execution_once (U4: resuming again returns the same process); runner begin_launch_is_sent_once_and_never_after_an_unknown_answer (U4); adapter the_same_attempt_for_another_command_is_refused (U3); gateway a_denied_resume_keeps_its_approval_for_another_resume (U5: a new attempt, never a replayed refusal)
Section 8: release Tests
clean exit adapter a_granted_launch_runs_the_executable_once_in_its_bound_scope (U4); the gateway launch-once tests (everything released afterwards, U4)
exec failure adapter an_exec_failure_after_ready_is_settled_by_its_scope (U4); runner an_exec_failure_after_ready_is_not_reported_as_unstarted (U4)
terminated process adapter terminate_kills_the_whole_scope_and_it_is_released (U5); gateway a_governed_process_is_controlled_like_any_other (U4)
helper created, no payload adapter a_helper_that_cannot_present_the_grant_never_runs_the_executable (U4); runner a_helper_that_never_runs_the_command_is_reported_once_it_is_reaped (U4)
surviving descendants gateway a_surviving_descendant_keeps_a_governed_attempt_charged_until_it_ends (U5); adapter an_observation_before_the_reap_keeps_a_surviving_descendant_tracked (U5)
observer timeout runner an_observer_that_does_not_return_does_not_keep_the_child_unreaped (U5) and an_authority_that_stops_answering_leaves_process_control_to_codespace (U5). After that the release is DevGuard's, by its own evidence
authority unavailable gateway a_lost_authority_leaves_a_governed_process_under_codespaces_control (U5)
shutdown runner a_shutdown_leaves_each_child_to_one_reaper (U5)
uncertain scope adapter a_reap_before_any_observation_loses_a_surviving_descendant (U5); the gateway restart test (stays charged as suspect, U5)

Durability: CodeSpace states a release, or a proof that a command never started, only from DevGuard's record of the attempt in DevGuard's answer. It never infers either from its own process state.

Section 9: race Tests
exit and terminate runner a_terminated_child_keeps_its_one_reaper (U5)
exit and timeout runner a_timed_out_child_keeps_its_one_reaper (U5)
exit and shutdown runner a_shutdown_leaves_each_child_to_one_reaper (U5: a running child and an exited, unreaped one)
root exit and surviving descendant gateway a_surviving_descendant_keeps_a_governed_attempt_charged_until_it_ends (U5); runner root_exit_output_eof_and_release_are_separate_events (U5)
Observe and reap runner the_observer_sees_the_exited_root_unreaped_then_reaped (U5); the adapter's two observation-order tests (U5)
helper transcript and process exit runner a_helper_that_never_runs_the_command_is_reported_once_it_is_reaped (U4); adapter transcripts_keep_ready_apart_from_what_the_executable_did (U4)
UDS disconnect and completion gateway a_launch_lost_with_the_worker_is_unknown_and_never_sent_again (U5); runner uds_exec_lost_response_is_ambiguous (earlier)
cancellation and launch result runner a_cancelled_request_does_not_drop_a_committed_launch (U5)
authority loss and process termination gateway a_lost_authority_leaves_a_governed_process_under_codespaces_control (U5); runner an_authority_that_stops_answering_leaves_process_control_to_codespace (U5)

Contract changes

  • When CodeSpace kills a governed process (terminate_process, workspace termination or a timeout), it also asks DevGuard to terminate the process's scope. This is best effort and never awaited. Before, only the pipe root was killed.
  • A governed pipe root's exit status can be reported up to 2 s later, while DevGuard observes the exited root before the reap.
  • A resumed approval whose governed execution is refused with proof that the command never started goes back to granted instead of being consumed with the refusal. Resuming it again asks for a new attempt. U3's starts-nothing check now expects that new attempt and one more owner session.
  • A child that exits just before a timeout or a termination always reports how it ended (exit code or signal). Before, whichever path reached it first could record it.
  • No tool schema, error code or status value changes.

Tests

  • Verified on the final head 7b65c76, on the owner's Mac (macOS 27.0, Apple M1):
    • scripts/validate-upstream.py all passed every stage. linux-isolation needs Linux and was not run there. integration ran 653 tests (5 ignored), unit-devguard 58 (4 ignored), unit-codex-runtime 8 and unit-pty 7.
    • macos-core macos-fork-race passed. Adapter 58 (4 ignored), runner DevGuard filter 48, gateway DevGuard 36 (5 ignored); fork race 400 trials, 0 failed, 59,952 children, 0 without an exit code, 0 new crash reports.
    • The worker's first launch, which failed on U4's Mac runs, passed (server: wait up to 10 s for a started worker; steady three macOS timing tests #91's warm-ups).
    • Per-commit runs: runner 114 tests without features and 153 with --features devguard; gateway DevGuard 36, and 31 without features; store 48; the scripts' 55 tests.
    • Clippy is clean for runner and server with and without the feature, for the adapter including the fixture example, and for store. The documentation registry check passes.
  • CI:
    • Run 38155527726 on 7b65c76: success, every job (Linux isolation skipped by the plan). Linux integration 653 (5 ignored); unit-devguard 38 (3 ignored); macOS contracts 183 (9 ignored); fork race 400 trials, 0 failed. Docs site run 38155528045: success.
    • The CI runs on 024e477, 9c57245, efd5070 and 956b3b8 were cancelled by the next push. In each, every Linux job passed; only Rust / macOS contracts was cancelled, and the rust aggregate failed on that alone. The run on c143b2c passed.
  • Not verified here: the adapter and gateway DevGuard tests need native host evidence, so on Linux they return early, as in U4.

Security scenarios

  • A kill is sent only while the child is unreaped, so its PID cannot name another process. The scope termination goes through the identities DevGuard rechecks.
  • Observation and termination sessions carry the owner's credential exactly as the U2–U4 sessions do. No credential, carrier or descriptor handling changes.
  • A refused resume gets its approval back only on DevGuard's proof that the command never started. Any uncertainty consumes it, so nothing is replayed.
  • The fixture authority's restart command and the new test helpers are test-only (an example binary and cfg(test) code). No product links them.

Out of scope

  • Observation before the reap for PTY executions: the separate Codex pin PR (option 3), with its own approvals.
  • P1-RECOVERY; CS-RG qualification (CSRG-U6); any pin change, DevGuard release, or change to the installed DevGuard or CodeSpace services.
  • The past_the_credential test retries, dropped by the owner's decision.

A pipe child could be reaped by any of four paths sharing its tokio
Child: its reaper task, the timeout task and request_kill (both called
try_wait before killing), and tokio's orphan queue once the child was
dropped. Whichever ran first reaped, so no path could observe the exit
before the reap, as DevGuard needs for a governed scope.

reap_child is now the only path that waits. The timeout and termination
paths record their intent and kill only a child that has not exited,
which they see with waitid(WEXITED | WNOHANG | WNOWAIT) without reaping
it; an exited child is left to the reaper, which records how it ended.
Until the reaper waits, the PID is still this process's child, so a
kill never reaches another process. kill_on_drop stays as the hand-off
at shutdown, once the reaper task is gone.

Deterministic tests hold the reaper while the child is an exited,
unreaped zombie and check that termination and the timeout neither
reap it nor change its result, and that it is reaped exactly once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
novelKR and others added 5 commits October 12, 2026 00:38
… reaped (CSRG-U5)

DevGuard tracks what is left in a scope's group only while the exited
root still holds its PID: a scope observed only after its root was
reaped loses track of survivors, and the attempt stays charged as
Suspect. DevGuard's own CLI waits for the root without reaping it,
observes, reaps and observes again.

The runner gets a generic exit observer: a pipe child's one reaper sees
the exit without reaping (waitid with WNOWAIT), calls the observer while
the root is an unreaped zombie, reaps it, and calls it again. The
observer runs on a blocking thread for at most two seconds; when it
fails or runs longer the reaper reaps anyway, so it never adds a
reaper. A PTY child is reaped inside the pinned Codex PTY, whose API
has no hook before the reap, so its observer runs after the reap only:
a descendant DevGuard has not yet seen when the root exits is then lost
to tracking, and its attempt stays charged rather than released.

A managed launch's observer asks DevGuard to Observe the attempt
(Owner::observe, a bounded session like lookup); the answer changes
nothing CodeSpace does with the process.

Tests: the runner's observer is called with the root exited and
unreaped, then reaped; a slow observer does not keep the child
unreaped; a PTY's observer is called after the reap only; a managed
launch observes twice on a pipe and once on a PTY. Against DevGuard's
fixture authority and helper (macOS): observed before the reap, a
surviving descendant keeps the attempt charged and is released as
ScopeTerminated once it ends; reaped first, the attempt turns Suspect
with lost tracking and stays charged; and through the gateway, a
governed pipe execution whose root leaves a descendant stays charged
until the descendant ends, then is released.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ontrol without the authority (CSRG-U5)

CodeSpace's termination and timeout kill a pipe child's root only, so a
governed execution's descendants outlived them and kept its attempt
charged. A kill hook now joins the exit observer in the spawn's hooks:
when CodeSpace kills a process it calls the hook on its own blocking
thread, never awaited, and a managed launch's hook asks DevGuard to
Terminate the scope (Owner::terminate: SIGKILL to every identity it can
recheck, members and known escapes). CodeSpace's own kill never depends
on it, and delivery is not release evidence.

An authority that stops answering after the launch leaves the process
under CodeSpace's control: status comes from CodeSpace's own state,
termination and the timeout kill it at once, and its reaper waits at
most its bound for an observation before reaping.

Tests: the kill hook runs once per kill CodeSpace makes, on a pipe and
a PTY, and not for a process that ended on its own; a governed timeout
asks DevGuard to terminate the scope once; with the authority stalled,
termination and the timeout still end a governed process within the
observation bound; against the fixture authority and helper (macOS),
Terminate kills both the root and a background descendant and the
scope is released as ScopeTerminated; through the gateway, with the
fixture stopped, governed pipe and PTY processes report their status,
are terminated, and free their workspaces. A regression test pins that
a request cancelled after the launch's commit does not drop the
launch's settlement (already so: the launch runs in its own task).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…approval (CSRG-U5)

The CS-RG design binds approvals to the attempt's lifecycle: a resource
refusal leaves the approval's hold unconsumed, and only a refusal proven
not to have started the matching attempt permits the approval's reuse;
a timeout, a lost answer or a missing process handle is not that proof.
The gateway marked a resume as dispatching before the owner prepared
the execution, so a refusal consumed the approval with the error, and a
second resume replayed the refusal.

ManagedPreparation::proves_not_started holds when the authority's state
of the attempt proves the command never started: never asked, not
recorded, denied, cancelled, expired, or released on the owner's report
that it holds no helper. For such a refusal of a resumed approval, the
gateway gives the hold back (Store::return_resume: resuming to granted),
finish_resume persists nothing for it, and the approval can be resumed
again, as a new attempt. Every other refusal, and any unknown, launched
or scope-ended attempt, consumes the approval as before, and an
interrupted resume is still reported ambiguous without a restart.

Tests: the store gives back only an approval being resumed, keeps it
granted with nothing persisted, and claims it for execution again; a
resume the fixture authority denies keeps its approval, and resuming
again is refused for a new attempt instead of replaying the refusal;
U3's starts-nothing check now expects that new attempt, and one more
owner session for it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ecycle (CSRG-U5)

The order lists the ambiguity, release and race cases a governed
execution must handle (sections 7 to 9). Most are covered by the tests
of U3, U4 and the earlier U5 commits; this adds tests for the rest. No
product code changes.

- Authority restart while a governed process runs: CodeSpace keeps the
  process, its status and its workspace lease, and the owner registers
  again, so a new execution is admitted and released. DevGuard no
  longer knows the scope, so the ended attempt stays charged as Suspect,
  and resuming its approval again returns the same process. The
  gateway's fixture authority gains a `restart` command: it stops the
  authority and serves the same journal again, and answers once it
  admits.
- A worker lost after it took a governed launch: the dispatch is
  unknown, the launch is never sent again, the workspace stays busy,
  and the resumed approval keeps that answer.
- Shutdown: a running child is killed and, like a child that exited
  just before and is not reaped yet, left to tokio's orphan queue,
  never also to CodeSpace's reaper.
- DevGuard's record: only a release on the owner's no-helper report
  reads as never started. A Suspect attempt (after a restart or a lost
  track) reads as launched, and a scope-ended or previous-boot release
  as ended. Only a state that proves no start gives an approval back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The DevGuard integration roadmap gains a CSRG-U5 section, in English
and Korean:

- one reaper per process;
- DevGuard observing a governed pipe root's scope before the reap,
  bounded;
- PTY executions observed after Codex's reap. This is described as the
  interim state until the separate Codex pin change the owner chose,
  with the merge either including that change or asked about again;
- a kill of a governed process also terminating its scope;
- process control without the authority;
- root exit, output EOF, handle, lease, scope end and accounting as
  separate events;
- approvals whose execution provably never started keeping their hold;
- the cases that are never replayed.

The header notice and the current status name the unit, and U4's
"not yet" paragraph points to the new section.

The architecture page's current behaviour no longer says that three
pipe paths each call try_wait. The execution contracts' approval
paragraph states the hold returned on a proven non-start. Translations
recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@novelKR novelKR changed the title devguard, runner, server: one reaper and observation before reap for governed executions (CSRG-U5) devguard, runner, server, store: lifecycle of governed executions after launch (CSRG-U5) Oct 11, 2026
@novelKR
novelKR marked this pull request as ready for review October 11, 2026 16:45
@novelKR
novelKR merged commit 0c21a7f into main Oct 11, 2026
14 checks passed
novelKR pushed a commit to novelKR/DevGuard that referenced this pull request Oct 11, 2026
CSRG-U5 merged in CodeSpace on 2026-10-11 as novelKR/CodeSpace#92
(0c21a7f, at head 7b65c76, whose CI passed in full; the merge commit has
that head's tree). Record U5 as merged-verified in the units dated
2026-10-11, keep U6 not started, and say so in AGENTS.md, the milestone
table and note, the planning index and the CS-RG plan, English and
Korean. The CS-RG plan also notes that until a separate Codex change
lands, U5 observes PTY executions only after Codex reaps them, as the
owner chose on 2026-10-11. CS-RG stays in-progress with qualification
not-run, and the suspension of the CSRG-C00-C09 plan is unchanged.
Korean counterparts reviewed; planning-readme and
planning-milestones-cs-rg hashes recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
novelKR pushed a commit to novelKR/DevGuard that referenced this pull request Oct 12, 2026
The owner directed CSRG-U6 to start on 2026-10-12, after CSRG-U5 merged
in CodeSpace as novelKR/CodeSpace#92 (0c21a7f), recorded here by #31,
and governed PTY executions came to be observed before the reap through
novelKR/CodeSpace#93 and novelKR/CodeSpace#95, recorded by #32. Record
U6 as active with the units dated 2026-10-12, and say so in AGENTS.md,
the milestone table and note, the planning index and the CS-RG plan,
English and Korean. CS-RG stays in-progress with qualification not-run,
DG-1's qualified release is unchanged, and the suspension of the
CSRG-C00-C09 plan is unchanged. Korean counterparts reviewed;
planning-readme and planning-milestones-cs-rg hashes recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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