Repository navigation
runner, server, store: answer for a lost Runner's processes and let an operator release them (P1R-C05) - #108
Merged
Conversation
…n operator release them (P1R-C05) A gateway whose independent Runner was killed, crashed or stopped refused every request with RUNNER_DISCONNECTED, process_status and read_process included, though processes of that Runner might still run, and the next gateway kept each workspace with an unended record of that Runner refused with RECOVERY_PENDING that nothing could release. An ended process that the Runner no longer kept was PROCESS_NOT_FOUND, and nothing said why output was missing. read_process and process_status now say why output is missing, or why what became of a process cannot be learned, in lost_reason: ring_overflow, retention_expired, runner_lost or host_rebooted. The client tells a lost Runner, when nothing accepts connections at its socket or another worker answers there, from a Claim refused for a reason that waiting cannot end, and reports the loss once. The gateway then marks each unended record of that Runner unknown and answers for it from the record: state unknown with runner_lost, which is never an end, or after a restart of the host exited with termination unknown and host_rebooted. Reconciliation marks the unended records of any other Runner the same way. Each such record keeps its workspace refused, and nothing of it runs again, until an operator releases it. codespace-mcp records list prints, as JSON, every record that may still hold a workspace, with whether its root process still runs as itself and whether a process holds its Runner's PID; it reads the database while a gateway runs. records release releases a lost Runner's record once its root process no longer runs, only while no gateway holds the database. It marks the records of a Runner that died while no gateway ran lost only once no process holds that Runner's PID. It ends no process and asks DevGuard nothing; an approval whose execution the record held is answered APPROVAL_AMBIGUOUS once it is released. The processes table gains lost and released_at_ms in place. A new operations runbook, in English and Korean, says how to find what still runs, when a required record may be released given DevGuard's charges (DevGuard has no command that only reads them), and why a new Runner cannot register while DevGuard keeps the lost Runner's instance. The mode stays unsupported. The P1R-C02 test of start identities compared a child's identity with its parent's; on Linux two processes that start in one clock tick have the same start time, so it now compares identities only with the same PID. macOS CI also runs the gateway's operator tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cence (P1R-C05) devguard admission --open prints quiescence as null when the serving release does not report it. Nothing then tells whether DevGuard still charges a lost Runner's attempt, so the runbook keeps a required record until the host restarts, which ends the attempt in DevGuard too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#107 names a failed stage's tests in the job log, changing scripts/validate-upstream.py and docs/upstream-update.md; both merge cleanly with this branch's macOS operator tests and runbook. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Loops of this unit's operator tests found `records release` refused right after a test let go of the lock. Closing the lock's descriptor does not let go of its flock while a child that another thread is forking holds a copy of the descriptor, until that child runs its program. The lock now unlocks when it is dropped, and a test keeps a copy of the descriptor open across the drop. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
Author
|
CI: the Linux Integration failure on
Patch against main 3fac934diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs
index c7b35a7..12d2f8c 100644
--- a/crates/server/src/mcp.rs
+++ b/crates/server/src/mcp.rs
@@ -6287,7 +6287,7 @@ mod independent_tests {
// A record that says this Runner started an execution it never received.
let this = runner_ref(RunnerIdentity::this_process());
left_running(&database, &this, "proc-phantom", None, "gov");
- // A process that a controller started without recording it.
+ // A process that a controller started without recording it. It says when it runs.
let unrecorded = ProcessId("proc-unrecorded".into());
{
let (client, server) = UnixStream::pair().unwrap();
@@ -6302,7 +6302,11 @@ mod independent_tests {
&raw,
ws,
RunnerExecRequest::for_host(
- vec["/bin/sleep".into(), "30".into()],
+ vec[
+ "/bin/sh".into(),
+ "-c".into(),
+ "echo started; exec /bin/sleep 30".into(),
+ ],
unrecorded.clone(),
ws.profile,
),
@@ -6353,6 +6357,24 @@ mod independent_tests {
.0;
assert_eq!(status.state, ProcessState::Running);
+ // Terminate it once it runs: a sandboxed command whose launcher is killed while its
+ // sandbox is still being set up runs on outside it, and keeps its output open.
+ let deadline = Instant::now() + Duration::from_secs(10);
+ loop {
+ let read = gateway
+ .read_process(Parameters(ReadProcessParams {
+ process_id: unrecorded.clone(),
+ cursor: 0,
+ }))
+ .await
+ .unwrap()
+ .0;
+ if read.chunk.contains("started") {
+ break;
+ }
+ assert!(Instant::now() < deadline, "{read:?}");
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
gateway
.terminate_process(Parameters(TerminateProcessParams {
process_id: unrecorded.clone(),
Separately, loops of this PR's new operator tests found a race of their own: the writer lock stayed held while a child that another thread was forking kept a copy of its descriptor. Generated by Claude Code |
This was referenced Oct 15, 2026
novelKR
pushed a commit
that referenced
this pull request
Oct 15, 2026
CI sets CARGO_TERM_COLOR=always, so cargo writes its own lines with terminal colors, and the summary that #107 added did not match cargo's "error: test failed, to rerun pass ..." line: #108's Integration failure printed the failed test and its panic but not the target to re-run. Colors are now removed before a line is matched. 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 15, 2026
P1R-C05 (novelKR/CodeSpace#108) was merged on the owner's approval as CodeSpace main dadb58c, and its post-merge CI is green. On the same approval the owner started P1R-C06. The ledger records P1R-C05 as merged-verified and P1R-C06 as active, and the dated notes say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Seongjae · project thread
Issue
Part of #101: P1R-C05, the fifth P1-RECOVERY unit. This PR does not close an issue.
mainat3fac934: P1R-C04 with its follow-up #106, and #107, merged into this branch at5a41aaf5deb102: the change in272ca06, one runbook sentence in8dbab2b, the merge of main in5a41aaf, and the writer lock fix in5deb1026e7e065; both unchangedrequiredrecord is released only once DevGuard no longer charges its attempt, which the runbook has the operator read from DevGuard's administrator quiescence (D12); the reason field, its values and the status after a Runner loss are an MCP contract change for the owner to approve with the merge (D15)Summary
Before: a gateway whose independent Runner was killed, crashed or stopped refused every request with
RUNNER_DISCONNECTED,process_statusandread_processincluded, though some of the Runner's processes might still run. The next gateway kept each workspace with an unended record of that Runner refused withRECOVERY_PENDING, and nothing could release it. An ended process that the Runner no longer kept wasPROCESS_NOT_FOUND, and nothing said why output was missing.After:
read_processandprocess_statussay why output is missing, or why what became of a process cannot be learned, in a newlost_reason:ring_overflow,retention_expired,runner_lostorhost_rebooted. A gateway finds its Runner lost when nothing accepts connections at its socket or another worker answers there, marks each unended record of that Runnerunknown, and answers for it from the record:state: unknownwithrunner_lost, which is never an end, or, after a restart of the host,exitedwith terminationunknownandhost_rebooted. Each such record keeps its workspace refused, and nothing of it runs again, until an operator confirms that nothing of it runs and releases it with the newcodespace-mcp records listandrecords release. A new operations runbook, in English and Korean, says how to find what still runs, when arequiredrecord may be released given DevGuard's charges, and why a new Runner cannot register while DevGuard keeps the lost Runner's instance. The mode stays unsupported.How.
crates/domain):LostReason, andlost_reasononReadProcessResultandProcessStatusResult, absent when there is no reason;ProcessState::Unknown, whose invariants requirerunner_lostand no termination, exit code or signal.crates/runner/src/uds.rs): claiming the Runner again after a lost connection now tells a lost Runner (nothing accepts connections at the socket, or another identity or another worker answers) from a Claim refused for a reason that waiting cannot end. The loss is reported once, asLinkEvent::RunnerLostand throughUdsRunner::lost(), and every later request is refused with aRUNNER_DISCONNECTEDthat says the Runner was lost.lifecycle.rsgainshost_boot_id,process_runs, whether a PID still runs as the process with a given start identity, andpid_runs, whether any process holds a PID; neither counts an exited process that is not reaped yet, on Linux and macOS.crates/store): two columns,lostandreleased_at_ms, added in place with the schema version unchanged.record_runner_lostmarks a Runner's unended recordsunknownforrunner_lostorhost_rebooted; an end recorded afterwards still ends a record;release_recordreleases one, and released records are no longer reconciled.unsettled_records_atreads a database read-only, so listing works while a gateway runs.crates/server):ProcessRecords::follow_lossmarks the records when the client loses the Runner, andprocess_statusandread_processanswer from the record when the Runner answersPROCESS_NOT_FOUNDfor a recorded execution or was lost (records.rs,mcp.rs). Reconciliation (recovery.rs) marks the unended records of any other Runner lost, ashost_rebootedwhen that Runner ran on an earlier boot, and keeps their workspaces refused with aRECOVERY_PENDINGthat names the process andcodespace-mcp records. An approval that such an execution resumed staysresuming, andoperation_resumeanswers it withAPPROVAL_AMBIGUOUSonce the record is released.crates/server/src/operator.rs,config.rs,main.rs):codespace-mcp records listprints, as JSON, every record that may still hold a workspace, with whether its root process still runs as itself and whether a process holds its Runner's PID.records release <process_id>...releases a lost Runner's record once its root process no longer runs, only while no gateway holds the database's lock. It marks the records of a Runner that died while no gateway ran lost only once no process holds that Runner's PID, and those of a Runner of an earlier boot ashost_rebooted. It ends no process and asks DevGuard nothing.crates/store/src/writer.rs): the database's writer lock now lets go when it is dropped. Closing its descriptor did not while a child that another thread was forking held a copy of it, so arecords releaseright after a test let go of the lock could be refused; loops of the operator tests found this.scripts/validate-upstream.py): the macOS leg also runs the gateway'soperatortests, which read macOS's boot session and process start.a_start_identity_names_one_process_until_it_is_reapedcompared a child's start identity with its parent's. On Linux two processes that start within one clock tick have the same start time, so it failed when the test binary and its child started in the same tick, as a filtered run here showed; it now compares identities only with the same PID.Contract changes
These need the owner's approval through the merge (D15).
lost_reasononread_processandprocess_statusresults, absent when there is no reason:ring_overflow: only the most recent output is kept (output_loston a read,output_retained_fromabove 0 on a status), in every mode;retention_expired: an independent Runner no longer keeps the ended process, and the answer comes from its record, without output;runner_lost: the Runner that ran the process was lost;host_rebooted: the host restarted since the process started.state: unknown, only after an independent Runner was lost before the process's end was recorded: no termination, exit code or signal, andlost_reason: runner_lost. It is not an end and not success.process_statusof a process that the Runner no longer holds, or that a lost Runner held, answers from its record:exitedas recorded (signaledwithout a recorded signal becomes terminationunknown),unknown, or after a host restartexitedwith terminationunknown;eofis true.read_processanswers an emptychunkwitheofandoutput_losttrue,retained_fromat the furthest output offset the gateway saw, and the cursor at that offset, or at the cursor asked for when that is further. Both werePROCESS_NOT_FOUNDorRUNNER_DISCONNECTEDbefore.RUNNER_DISCONNECTEDfor a lost Runner says so and that the records answer.RECOVERY_PENDINGfor a workspace with a lost Runner's record names the process, why, andcodespace-mcp records, where before it said that another Runner was sent the process.read_processandprocess_statusdescribelost_reasonandunknown; the independent mode's lifetime paragraph says what follows a Runner loss and not to start a duplicate.codespace-mcp records listandcodespace-mcp records release <process_id>..., with--operations-dborCODESPACE_OPERATIONS_DB, JSON on stdout, and exit status 1 when a release was refused. Without a subcommand the gateway starts as before.processestable gainslostandreleased_at_mswhen a gateway opens it; the schema version is unchanged. An earlier CodeSpace ignores the columns, so a record released here keeps its workspace refused there again, the safe direction.unknown, with why; P1R-C04 left itrunning. Such a record becomes releasable.Tests
The brief's cases and the tests that cover them.
an_ended_process_is_answered_from_its_record_once_the_runner_let_it_go(the Runner's answer while it keeps the process, then the record's withretention_expired, the recorded exit and no output)a_killed_runners_processes_stay_held_until_an_operator_releases_them(SIGKILL of the real Runner;unknownwithrunner_lost,RUNNER_DISCONNECTEDfor new work,records listandreleasethrough the binary, refused while the gateway runs and while the root runs, released once it ended, then a new Runner and gateway work in the workspace). Without the Linux command sandbox the pipe process survives the Runner; under it, in a workspace without network, the command ends with the Runner, which the test checks insteadan_independent_gateway_answers_for_a_lost_runners_processes_until_released,an_independent_gateway_blocks_a_workspace_another_runner_left_work_in; operatorrecords_release_takes_a_restart_as_the_end; storea_loss_names_a_restart_only_across_two_known_bootsan_independent_gateway_answers_for_a_lost_runners_processes_until_released(unknown/runner_lost,exited/host_rebooted, an end recorded before the loss,RECOVERY_PENDINGnaming the processes, release, then new work); storea_lost_runners_unended_records_wait_for_a_releasea_lost_runners_unended_records_wait_for_a_release(an end recorded after the loss still ends the record); clienta_client_that_cannot_claim_its_runner_again_is_disconnected(another Runner, then nothing at the socket: lost, not reclaimed); gatewayan_ended_process_is_answered_from_its_record_once_the_runner_let_it_go(eviction)records_release_waits_for_the_gateway_and_the_root(a root PID that a later process took),records_release_takes_a_restart_as_the_end; lifecyclea_process_runs_as_itself_until_it_exits(identity, zombie, reaped)records_release_takes_a_runner_gone_unseen_as_lost, andrecords_release_waits_for_the_gateway_and_the_root(refused while a process holds the Runner's PID)an_independent_gateway_answers_for_a_lost_runners_processes_until_released(the deadline and the execution count stay),an_approval_whose_execution_a_lost_runner_held_is_never_run_again(APPROVAL_AMBIGUOUSafter the release; the command's marker is written once)a_writer_lets_go_though_a_copy_of_its_descriptor_lives_on(keeps a copy of the descriptor open across the drop; fails without the fix)reasons_and_the_unknown_state_cross_as_names,an_unknown_state_says_why_and_is_no_end; configthe_records_commands_take_the_operations_database; storerecords_from_before_losses_are_listed_as_they_are_and_gain_the_columns,listing_records_reads_only_a_database_it_knows; operatorrecords_list_shows_what_may_still_hold_a_workspace,records_commands_need_their_databaseVerified here at
5deb102, in a Linux cloud container running as root, on the Codex checkout with its recorded patch applied and the worker, patch and Linux sandbox binaries built:cargo fmt --checkfor the workspace and for the worker.cargo clippy --locked --all-targets -- -D warningsin CI's six configurations: the workspace; the server, Runner and domain withdevguard; the worker with and withoutdevguard.cargo test --locked --workspace: 446 passed, none failed, in 34 test binaries, among them the 9independent_runnertests and the 5 operator tests.-p codespace-server --features devguard: 173 passed.-p codespace-domain --features devguard --lib: 34 passed. The worker: 11 passed, and 2 withdevguard.-p codespace-runner --features devguard --lib: 194 passed and 1 failed,launch::tests::nothing_is_committed_when_the_owner_cannot_launch, which fails only as root; this PR does not touchlaunch.rs, and the same test binary passed it when run as an unprivileged user.python3 scripts/check_docs.py check(20 paired documents), and after merging main, which brought only scripts: name a failed stage's tests in the job log #107's script and docs change, the scripts' Python tests (105, and the 36 CI policy tests).an_independent_gateway_blocks_a_workspace_whose_records_and_runner_disagreefailed in 4 of them, as it does onmain; its cause and a proposed test patch are in the PR's comment.Not verified here: the hosted macOS job, which now also runs the operator tests; the docs site build, which needs Node 24.21.0; a real host restart (tests use a boot ID fixture); and a real DevGuard authority, so the runbook's DevGuard steps rest on DevGuard's source and docs at
7cd86e4.Security scenarios
resumingand isAPPROVAL_AMBIGUOUSonce released; the tests count executions and marker writes.records releasemarks a Runner's records lost only once no process holds its PID. A PID that another process took keeps the records held, the safe direction; a gateway then finds the Runner lost at its socket.unknowncarries no termination or exit code, andhost_rebootedisexitedwith terminationunknown, never an exit code.requiredrecord.records listprints IDs, PIDs, boot IDs and times, no argv, environment or secret.process_status,read_processand the records; never close unresolved records as success in bulk. An earlier CodeSpace reads the database and ignores the new columns; a record released here keeps its workspace refused there.Out of scope
codespaceconsumer's instance count, or anything in DevGuard. DevGuard has no command that only reads its quiescence (devguard admission --openalso reopens a closed admission); that is proposed to the lead thread and the owner, not made here.