Repository navigation
runner, server, store: reconcile the independent Runner per workspace (P1R-C04) - #105
Merged
Merged
Conversation
… (P1R-C04) A gateway that claims an independent Runner, when it starts or again after a lost connection, now reconciles what earlier controllers left instead of refusing all new work until the Runner is restarted. It compares three accounts of each execution that a record leaves unsettled: the process record, what the Runner holds or remembers of the process, and for a `required` execution its DevGuard attempt, which the Runner looks up with its own session. A process found running holds its workspace again, so new work there gets WORKSPACE_BUSY; one that ended is recorded with how it ended, and one that the Runner proves it never received or created is recorded as not started, each releasing its workspace. An approval left resuming is consumed with the process that the Runner created for it, given back when the Runner proves that it created none, and otherwise left resuming. Until a workspace is reconciled, the gateway refuses new executions, patches and approval resumes there with RECOVERY_PENDING before it claims the approval, and the Runner refuses work in each workspace of its barrier, now kept per workspace, until the gateway states that it reconciled it. Other workspaces take work at once, and inherited processes can always be queried, read, written to, resized and terminated. A workspace that waits for an answer is reconciled again after a pause growing from 500 ms to 30 s, and every refused workspace after each exit. One whose accounts disagree, whose record names another Runner, or where the Runner holds a running process that no record accounts for stays refused, with the reason in the error and the log. The Runner remembers, for its lifetime and by ID alone, whether it created a process for each process_id it was asked to start. It keeps each ended process, with its status and output, until a controller acknowledges that its record says how the process ended; handles are released when they were before (D11), and at most 64 released processes are kept. The worker protocol is now 11, with Recall, Reconciled, Acknowledge and, with DevGuard, Lookup; the Claim's answer names the barrier, and Stop answers with how each process ended, which the gateway records. The client skips the exit events that the Claim's inventory reflects, which the reconciliation accounts for, and a write that fails no longer discards the answers the Runner wrote before it closed the connection, so a late acknowledgement cannot cost a stop its answer. Records of other Runners are always reconciled, as epochs are per Runner. The docs, English and Korean, describe the reconciliation, the retention and the approvals. The mode stays unsupported. 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-C04 (novelKR/CodeSpace#105) was merged on the owner's approval as CodeSpace main 385e177, and its post-merge CI is green. On the same approval the owner started P1R-C05. The ledger records P1R-C04 as merged-verified and P1R-C05 as active, and the dated notes say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Oct 15, 2026
novelKR
pushed a commit
that referenced
this pull request
Oct 15, 2026
The macOS stage of main CI after #105 failed once in the Runner's DevGuard tests, and its re-run passed. Looping those tests here found one that fails about 1 run in 25, and 43 runs in 120 with 8 at a time: an_independent_runner_answers_registration_before_the_next_claim compared the moments two test tasks read the registration's answer and the next Claim's answer, while the Runner only orders its writes. It now takes the Claim's answer first and checks that the registration's answer is already waiting on the first connection. Five tests that #105 added or changed took a process's exit for its end. The Runner counts a process as ended once its output ended too: that starts its retention, makes an acknowledgement count and numbers its exit event. The output can end later, when a child that another test spawns holds the pipe until its exec, which is slow on macOS for a new executable. The retention tests now wait for the end of the output, and the two Claim tests wait until the Runner numbered the exit. Only tests change. 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-C04, the fourth P1-RECOVERY unit. This PR does not close an issue.
mainat1fda789, with P1R-C03 merged7cabcba6e7e065; both unchangedSummary
Before: a gateway that claimed an independent Runner after any controller had started work on it, its own connection before a lost connection included, refused every new execution, patch and approval resume with
RECOVERY_PENDINGuntil the Runner was stopped and a new one started: in every workspace, and even after the inherited processes had ended. Its process records were written but never read back. An approval leftresumingstayedAPPROVAL_AMBIGUOUS, a process that was still running held no workspace in the gateway that inherited it, and an exit that no gateway received was kept by the Runner only until the next controller's exit event.After: after each Claim, the gateway reconciles what earlier controllers left, workspace by workspace, from three accounts: its process records, what the Runner holds and remembers of each process it was asked to start, and for a
requiredexecution its DevGuard attempt, which the Runner looks up with its own session. Until a workspace is reconciled, the gateway and the Runner both refuse new executions, patches and approval resumes there withRECOVERY_PENDING, before the approval is claimed, so it stays as it was. Other workspaces take work at once, and inherited processes can always be queried, read, written to, resized and terminated, DevGuard down included. A process found running holds its workspace again, so new work there getsWORKSPACE_BUSY; one that ended is recorded with how it ended; one that the Runner proves it never received or created is recorded as not started. An approval leftresumingis consumed with the process the Runner created for it, granted again when the Runner proves that it created none, and otherwise leftresuming. A workspace whose accounts disagree, whose record names another Runner, or where the Runner holds a running process that no record accounts for stays refused, with the reason in the error and the log. The mode stays unsupported.How.
crates/runner,process.rs): an independent Runner remembers, for its whole life and by ID alone, whether it created a process for eachprocess_idit was asked to start, or received the request and created nothing. It keeps each ended process, with its status and output, until a controller acknowledges that its record says how the process ended. Handles are released exactly when they were before, after 15 minutes or beyond 64 ended processes (D11, risk 7); of the processes whose handles were released it keeps at most 64 (DEFAULT_MAX_UNACKNOWLEDGED), dropping the oldest first, and of a dropped one it remembers only that it created it.wire.rs,uds.rs,admission.rs): new controller requestsrecall,reconciled,acknowledgeand, with DevGuard,lookup, which looks an attempt up only when this Runner admitted it for that process, with the admission it asked for. The barrier is now per workspace: the Claim's answer names the workspaces where a controller before it started work, and the Runner refuses new work in each until the controller states that it reconciled it.stopanswers with how each process ended. The client skips exit events that the Claim's inventory already reflects (seq ≤ events_through), since the reconciliation accounts for them. A write that fails, as one sent after the Runner closed the connection does, no longer discards the answers the Runner wrote before it closed: an exit's acknowledgement sent while the gateway stopped its Runner could cost thestopits answer, and the gateway then exited with an error.crates/server/src/recovery.rs): at each Claim the gateway bars each workspace of the barrier, each one where the Runner holds a running process, and each one that a record left to reconcile names: a record that is not terminal and was not sent to this Runner under this Claim or a later one, or one of an approval stillresuming. Each pass recalls those processes and the running ones no record accounts for in one request, looks up the governed attempts, decides each record by the brief's table, settles the approvals, tells the Runner which workspaces are reconciled and acknowledges the ends it recorded. A record that names another Runner is never asked about, since a Runner proves "never received" only for itself; it keeps its workspace blocked for P1R-C05. Passes run again after each exit while a workspace is refused, and after a pause growing from 500 ms to 30 s while one waits for an answer.mcp.rs,records.rs,crates/store):operation_resumechecks the approval's workspace beforeclaim_resume(risk 15). An interrupted exec resume whose record proves that the Runner started it is answered with thatprocess_idinstead ofAPPROVAL_AMBIGUOUS.settle_interrupted_resumeconsumes or gives back only aresumingapproval, under the in-flight guard, and never one that an execution under the current Claim resumes.hold_processholds a workspace for a process found running and fails queued waiters withWORKSPACE_BUSY; exits noted during a pass keep a process that exited after the Runner reported it running from being held again.records.rs,crates/store): each end recorded from an exit event is acknowledged to the Runner, a stop records how each process ended, andrecords_to_reconcilenow takes the Runner: records of other Runners are always reconciled, because epochs are per Runner.Contract changes
These need the owner's approval through the merge (D15). No new MCP error code, field, tool or option.
recall(process_ids), answeredrecalledwith, per process,not_received,refused,held(workspace_id,status) ordropped;reconciled(workspace_ids);acknowledge(process_ids), answeredacknowledged; with DevGuard,lookup(attempts:[{attempt_id, process_id}]), answeredlooked_upwithnot_admitted,not_found,found(the attempt's state) orfailed. A managed worker refuses all four; a connection that does not control the Runner getsSTALE_CONTROLLER;reconciledandacknowledgecarry the epoch.barrier(absent reads as none), andprevious_controllernow means thatbarrieris not empty.stopanswersstoppedwithprocesses, as the Claim lists them.RECOVERY_PENDINGonly in a workspace of the barrier, untilreconcilednames it.RECOVERY_PENDINGis per workspace and lifts once the gateway has reconciled the workspace. Its message says why it waits: when the gateway waits for an answer it tries again by itself, so retrying after a pause helps; when the accounts disagree or a record names another Runner, an operator resolves it.WORKSPACE_BUSYinstead ofRECOVERY_PENDING.operation_resumeof an approval leftresumingby an exec:consumedwith the execution'sprocess_idanddispatch_status: confirmedwhen its record proves that the Runner started it;grantedagain, resumable, when the Runner proved that it created none;APPROVAL_AMBIGUOUSotherwise, as before.WORKSPACE_BUSYfor a process found running.RECOVERY_PENDINGfor every later Runner's gateway, as the brief requires (another identity: keep the occupancy, P1R-C05). Before this PR, a new Runner started work there. A stop through the gateway (--runner-exit-intent stop) records how each process ended and leaves nothing to block. The operator's release comes with P1R-C05's runbook.records_to_reconcileincludes other Runners' records whatever their epoch.Tests
The brief's cases and the tests that cover them.
an_independent_gateway_after_a_detach_holds_the_process_it_inherits(busy for exec and apply_patch, released once terminated, a new exec under epoch 2),a_gateway_that_started_work_claims_its_runner_again_and_reconciles_it; gateway binaryan_independent_runner_keeps_a_killed_gateways_process_to_its_original_timeout,a_claim_is_refused_while_a_stopped_gateway_holds_the_runner_and_granted_once_it_ends(nowWORKSPACE_BUSY); runnerthe_next_gateway_inherits_the_processes_and_starts_work_once_it_reconciledan_end_while_no_gateway_is_connected_is_recorded_by_the_next_gateway,an_end_a_gateway_missed_is_recorded_when_the_next_gateway_reconciles; runnera_lost_connection_keeps_the_processes_and_the_next_claim_accounts_for_exits, clientan_independent_claim_accounts_for_the_exits_before_itan_independent_resume_left_resuming_is_consumed_with_the_process_it_started,an_independent_gateway_consumes_an_interrupted_resume_with_its_process,an_independent_gateway_gives_back_an_approval_whose_execution_never_reached_the_runner; storean_interrupted_resume_is_settled_oncean_independent_gateway_waits_for_the_resource_authority_before_it_reconciles(DevGuard: lookups fail, the workspace stays refused while the process stays readable, then the attempt is found launched and the workspace is held for it); runneran_independent_runner_looks_up_only_the_attempts_it_admitted(a test authority's answers: launched, released unlaunched, not found, unknown, unreachable, and no registration),an_independent_runner_looks_up_attempts_for_its_controller_onlyan_independent_gateway_blocks_a_workspace_whose_records_and_runner_disagree(a record that saysrunningfor a process this Runner never received keeps its workspace refused; a running process that no record accounts for keeps its own refused until it is terminated; a third workspace takes work at once)an_independent_gateway_blocks_a_workspace_another_runner_left_work_in; storea_gateway_reconciles_what_earlier_claims_left_unsettled(other Runners' records at any epoch)a_gateway_that_started_work_claims_its_runner_again_and_reconciles_it(one process exits during the disconnect and one keeps running: refused, then admitted in one workspace and busy until the exit in the other); storea_process_found_again_holds_its_workspacean_independent_gateway_blocks_a_workspace_another_runner_left_work_in(the approval staysgranted),a_gateway_that_started_work_claims_its_runner_again_and_reconciles_itan_answer_written_before_the_runner_closed_outlives_a_later_failed_write; gateway binarya_gateway_refuses_independent_settings_or_a_runner_that_do_not_fitanda_sandboxed_command_cannot_reach_the_runner_socket, which failed about one run in seven before the fix and passed 40 runs of 40 after itan_independent_runner_keeps_an_ended_process_until_it_is_acknowledged,an_independent_runner_evicts_an_acknowledged_process_as_before,an_independent_runner_drops_the_oldest_released_process_beyond_its_bound,an_independent_runner_remembers_each_process_it_was_asked_to_start,an_independent_runner_recalls_whether_it_created_each_process; worker binaryan_independent_runner_outlives_its_gateways_until_one_stops_it(the barrier and the stop's report)Verified here (Linux container running as root, toolchain 1.99.0, on
7cabcba):cargo fmt --checkfor the root workspace and the worker: clean.cargo clippy --all-targets -- -D warningsin the six CI configurations (root; gateway, Runner and domain with DevGuard; worker with and without DevGuard): clean.cargo test --workspacewith the worker, patch and Linux sandbox helpers: every target passes, among them the Runner lib (152), the gateway lib (52), the gateway binary's independent Runner tests (8) and the store (71).CODESPACE_REQUIRE_DEVGUARD_BINS=1): 163 passed; the 5 ignored are child processes that their parent tests run.launch::tests::nothing_is_committed_when_the_owner_cannot_launchfails only as root, because it needs a group other than root's; this PR does not touchlaunch.rs.scripts/check_docs.py: 20 paired documents verified.Not verified here: the hosted macOS job, and the docs site build, which needs Node 24.21.0; CI runs both.
Security scenarios
not_receivedorrefusedof adispatchingrecord, with DevGuard also no attempt or one that ended unlaunched).claim_resume; a consumed approval, or one whose execution may have started, is never granted again; settling an approval takes the in-flight guard and leaves one that an execution under the current Claim resumes to that resume.RECOVERY_PENDINGand the log. Inherited processes stay readable and terminable whatever the reconciliation's state, DevGuard down included.recall,reconciled,acknowledgeandlookupare answered to the controlling connection only;reconciledandacknowledgecarry its epoch. The D6 exposure, any process of the same user claiming the Runner while no gateway controls it, is unchanged and stays documented.recallanswers with IDs and statuses,lookupwith attempt IDs and states; DevGuard's credential stays with the Runner.Out of scope