coordination: mount the TUI runtime shared, keep serve exclusive (#7042)

`new_shared_runtime_subagent_manager` set `coordination_exclusive = true` and
held the per-workspace coordination flock for the life of the process. The
embedded TUI background runtime and the standalone host both went through that
one constructor, so a second `codewhale` in the same folder could not take the
lock shared and every append in it was refused:

    another Codewhale process owns delegated coordination for <workspace>:
    Resource temporarily unavailable (os error 11)

`docs/architecture/delegated-coordination.md` reserves the exclusive holder for
`codewhale serve` and the other standalone Runtime hosts, where the whole
process is the writer.

Split the constructor: `new_shared_runtime_subagent_manager` keeps the shared
posture the TUI runtime mount is documented to take, and the new
`new_exclusive_runtime_subagent_manager` carries the exclusive one for
`runtime_api_sub_agent_manager`, i.e. the HTTP/serve host. After this,
`coordination_exclusive = true` is reachable only from that host call site.

The architecture doc claimed the standalone host "refuses to start" without the
lock. It does not: `ensure_coordination_process_lock` failing sets
`coordination_snapshot_read_only`, logs, and the ledger is picked up on the next
start. The doc now says what the code does instead of the reverse.

Regression source: 23e9119caf.

Verification (frozen tree, `CODEWHALE_CACHE_ROOT=/Volumes/VIXinSSD/cw-build-cache`):
  ./scripts/dev-cargo.sh test -p codewhale-tui --lib -- \
    runtime_mount_coordination_lock_is_shared_with_parallel_sessions \
    headless_worker_registration_enforces_live_claims_and_projects_context \
    one_actor_keeps_original_claim_bytes_and_admits_disjoint_owner_roots \
    mounted_history
  -> 17 passed; 0 failed; 15369 filtered out; 1m17s compile + 2.27s run

The new `runtime_mount_coordination_lock_is_shared_with_parallel_sessions`
fails on the previous constructor (the second shared holder's
`ensure_coordination_process_lock` returns EWOULDBLOCK) and passes now.
`headless_worker_registration_enforces_live_claims_and_projects_context` keeps
its `independent` refusal by moving to the exclusive constructor, which is the
posture its assertion describes.

Not run: hosted CI, full suite, release gates.
This commit is contained in:
CodeWhale Bot committed 2026-10-23 04:15:26 -07:00
1 parent 6249919458
commit 842d2cecf4
4 files changed
+47 -9

No files matched your search

+2 -2
View File
@@ -88,7 +88,7 @@ use crate::task_manager::{
};
use crate::tools::subagent::{
AgentWorkerRecord, AgentWorkerStatus, SharedSubAgentManager, SubAgentStatus,
new_shared_runtime_subagent_manager,
new_exclusive_runtime_subagent_manager,
};
#[cfg(test)]
pub(super) use codewhale_models::{ContentBlock, Message};
@@ -1386,7 +1386,7 @@ fn runtime_api_sub_agent_manager(
workspace: &FsPath,
workers: usize,
) -> Result<SharedSubAgentManager> {
new_shared_runtime_subagent_manager(workspace.to_path_buf(), workers)
new_exclusive_runtime_subagent_manager(workspace.to_path_buf(), workers)
}
#[derive(Debug, Serialize)]
+17 -2
View File
@@ -11230,6 +11230,21 @@ pub fn new_shared_subagent_manager_with_timeout(
pub(crate) fn new_shared_runtime_subagent_manager(
workspace: PathBuf,
workers: usize,
) -> Result<SharedSubAgentManager> {
new_runtime_subagent_manager(workspace, workers, false)
}
pub(crate) fn new_exclusive_runtime_subagent_manager(
workspace: PathBuf,
workers: usize,
) -> Result<SharedSubAgentManager> {
new_runtime_subagent_manager(workspace, workers, true)
}
fn new_runtime_subagent_manager(
workspace: PathBuf,
workers: usize,
exclusive: bool,
) -> Result<SharedSubAgentManager> {
type RuntimeManagerRegistry =
parking_lot::Mutex<HashMap<PathBuf, (usize, std::sync::Weak<RwLock<SubAgentManager>>)>>;
@@ -11254,7 +11269,7 @@ pub(crate) fn new_shared_runtime_subagent_manager(
.with_admission_limit(workers)
.with_launch_concurrency(workers)
.with_state_path(state_path);
manager.coordination_exclusive = true;
manager.coordination_exclusive = exclusive;
manager = manager.require_coordination_process_lock();
if let Err(error) = manager.ensure_coordination_process_lock() {
manager.coordination_snapshot_read_only = true;
@@ -19768,7 +19783,7 @@ mod owner_scoped_coordination_tests {
let selected = root.join("selected");
fs::create_dir_all(original.join("src")).unwrap();
fs::create_dir_all(selected.join("src")).unwrap();
let authority = new_shared_runtime_subagent_manager(original.clone(), 2).unwrap();
let authority = new_exclusive_runtime_subagent_manager(original.clone(), 2).unwrap();
let mut manager = authority.try_write().unwrap();
admit(&mut manager, &original);
admit(&mut manager, &selected);
+23 -2
View File
@@ -1194,12 +1194,33 @@ fn coordination_hot_paths_count_only_active_authoritative_owners() {
);
}
#[test]
fn runtime_mount_coordination_lock_is_shared_with_parallel_sessions() {
let tmp = tempdir().expect("tempdir");
let runtime_authority = new_shared_runtime_subagent_manager(tmp.path().to_path_buf(), 8)
.expect("admit TUI runtime writer");
let session =
SubAgentManager::new(tmp.path().to_path_buf(), 8).require_coordination_process_lock();
session
.ensure_coordination_process_lock()
.expect("a second session appends beside the runtime");
assert!(!session.coordination_snapshot_read_only);
session
.persist_state_synchronously()
.expect("session append");
let runtime = runtime_authority.try_write().expect("runtime lock");
assert!(!runtime.coordination_snapshot_read_only);
runtime
.persist_state_synchronously()
.expect("runtime append");
}
#[test]
fn headless_worker_registration_enforces_live_claims_and_projects_context() {
let tmp = tempdir().expect("tempdir");
let authority_a = new_shared_runtime_subagent_manager(tmp.path().to_path_buf(), 8)
let authority_a = new_exclusive_runtime_subagent_manager(tmp.path().to_path_buf(), 8)
.expect("admit Runtime writer");
let authority_b = new_shared_runtime_subagent_manager(tmp.path().join("."), 8)
let authority_b = new_exclusive_runtime_subagent_manager(tmp.path().join("."), 8)
.expect("reuse canonical Runtime writer");
assert!(Arc::ptr_eq(&authority_a, &authority_b));
let mut manager = authority_a.try_write().expect("first authority lock");
+5 -3
View File
@@ -117,9 +117,11 @@ whole-document atomic replace. One per-workspace advisory flock
fleet`, `codewhale resume`/`fork` mounts and the TUI's background task runtime
(`/task`, `task_create`, automations) take the flock shared, so several
sessions in one folder hold it at once. Failing to get it only logs a warning.
- **Exclusive, fatal on failure.** `codewhale serve` and the other standalone
Runtime hosts take it exclusively and refuse to start without it. Beside such an
owner, shared holders cannot append.
- **Exclusive, degrade read-only.** `codewhale serve` and the other standalone
Runtime hosts take it exclusively and refuse every durable ledger append when
another process already holds it; they log the refusal and pick the ledger back
up when the process restarts after that owner exits. Beside such an owner,
shared holders cannot append.
Every shared holder may append, and each append replaces the whole file, so the
last rename wins. The ledger is loaded once at boot; agents another process