fix(engine): let sub-agent shells run beside a peer's write claim; repair stale candidate tests

The write-claim ledger still coordinates bounded File/Edit/Patch targets, but a
child's shell no longer waits on a peer's claim: the execution envelope,
sandbox and approval posture already govern executable work. The old refusal
told analysis agents to re-run with read_only=true, which burned steps without
protecting a peer's files. The contention refusal now names only bounded write
tools, and Run verifiers remain guarded.

Repairs three stale candidate assertions:
- the app-server settings test expected the retired `operate` spelling to
  persist; it now asserts the documented Work (`agent`) normalization.
- the mailbox test expected snapshot.mode == "operate"; it now expects the
  Work wire value.
- the parent prompt+catalog ceiling re-measured at 94,121B (was 92,100B) after
  the deliberate tool-text expansion; the read-only child envelope still fits.
The shell tests that execute a command now run on the engine's large stack so
they exercise the policy instead of overflowing the test thread.

Verification: cargo test -p codewhale-engine --lib, exact filters:
child_write_tool_fails_closed_outside_registered_scope 1 passed;
lone_shared_writer_keeps_unbounded_shell 1 passed;
contended_shared_writers_keep_proven_readonly_shell 1 passed;
contended_shared_writer_keeps_its_shell 1 passed;
parent_agent_surface_stays_within_measured_ceiling 1 passed;
read_only_child_envelope_stays_within_measured_ceiling 1 passed;
full_mailbox_posture_update_supersedes_queued_change_mode 1 passed.
cargo test -p codewhale-app-server --lib
runtime_api::tests::runtime_tui_settings_normalize_legacy_modes_and_do_not_save_env_overlays
1 passed. scripts/preflight.sh --check OK.
This commit is contained in:
CodeWhale Bot committed 2026-11-08 16:08:43 -08:00
1 parent 05b0a91b1e
commit a2fda9d570
7 files changed
+98 -99

No files matched your search

+7
View File
@@ -238,6 +238,13 @@ until the matching tag, packages, checksums, and release assets exist.
### Changed
- **Sub-agent shell runs no longer wait on a peer's write claim.** A child that
holds a shared write claim can run `bash` while another child writes; the
claim still coordinates bounded `File`/`Edit`/`Patch` targets, and the
execution envelope, sandbox and approval posture govern executable work. A
bounded write tool whose target overlaps a live peer still refuses. The old
refusal told analysis agents to re-run with `read_only=true`, which cost
steps without protecting a peer's files.
- **The footer cost chip shows confirmed dollars only.** A session with some unpriced turns shows exactly the confirmed amount, such as `$0.34`, instead of `subtotal 0.34 + unknown (request outcome unknown)`, and a session with nothing priced shows no cost at all instead of `cost: unknown (…)`. The accounting is unchanged: unpriced turns stay excluded rather than guessed, and `/cost` still lists each one with its reason, now in plain words ("the provider never confirmed the request finished", "this session predates cost tracking"). The price view says `unknown` for a session with no confirmed price.
- **Sub-agents and goals by default.** The constitution now tells the agent to start independent parts of a request as parallel sub-agents in one message, keep working while their results arrive on their own, and end the turn when only waiting remains; to give each child a complete prompt and its own files; to inspect a write-capable child's evidence, review the diff and run the checks itself; to have an independent reviewer try to refute a non-trivial change before calling it done; and to start a goal for any request, in any language, whose verifiable end state needs more than one turn. Sub-agents, workflows and goals within the current request are in scope; spending real money (purchases, paid providers, billing) still needs the user's express authorization. Cost alone is not a reason to stop; only limits the user or configuration sets apply. **This changes the session-pinned prompt and tool descriptions, so each session re-pins its prompt cache once after upgrade.**
- **Operate's contract is retired.** The Engine no longer appends the Operate contract to a session; its review, cost and evidence rules now live in the constitution for every session. Saved sessions that contain it still replay.
+3 -3
View File
@@ -289,7 +289,7 @@ async fn http_and_web_server_thread_manager_installs_configured_workshop_byte_bu
}
#[test]
fn runtime_tui_settings_reject_legacy_modes_and_do_not_save_env_overlays() -> Result<()> {
fn runtime_tui_settings_normalize_legacy_modes_and_do_not_save_env_overlays() -> Result<()> {
let _lock = lock_test_env();
let tmp = tempfile::tempdir()?;
let settings_dir = tmp.path().join(".codewhale");
@@ -315,10 +315,10 @@ fn runtime_tui_settings_reject_legacy_modes_and_do_not_save_env_overlays() -> Re
);
persist_runtime_tui_setting("default_mode", "operate")
.expect("operate is a valid startup mode");
.expect("operate is a retired spelling accepted as Work");
assert_eq!(
codewhale_runtime::settings::Settings::load_persisted()?.default_mode,
"operate"
"agent"
);
persist_runtime_tui_setting("default_mode", "agent")
.expect("agent should be a valid startup mode");
@@ -1011,7 +1011,7 @@ async fn full_mailbox_posture_update_supersedes_queued_change_mode() {
.expect("snapshot after mailbox drain")
.expect("session snapshot");
assert_eq!(snapshot.mode, "operate");
assert_eq!(snapshot.mode, "agent");
let authority = handle.runtime_permission_authority();
assert_eq!(authority.approval_mode, ApprovalMode::Auto);
assert!(!authority.auto_approve);
+24 -25
View File
@@ -417,30 +417,29 @@ impl ChildAuthority {
// The typed read-only boundary above already rejected mutation.
&& !self.write_is_denied()
&& !is_internal_coordination_state_tool(name)
// A shell run the read-only classifier proves mutation-free cannot
// collide with the peer's writes no matter how contended the
// checkout is, so the gate below does not apply to it.
&& !proven_readonly_shell_run(name, input)
&& (is_unbounded_shell_run(name, input)
|| registry.get(name).is_some_and(|spec| {
let canonical = canonical_action_alias(name, input);
let is_shell_control = matches!(
canonical,
"exec_shell_wait" | "exec_shell_interact" | "exec_shell_cancel"
);
let capabilities = spec.capabilities();
!is_shell_control
&& (spec.approval_requirement_for(input) == ApprovalRequirement::Suggest
|| (!spec.is_read_only_for(input)
&& capabilities.iter().any(|capability| {
matches!(
capability,
ToolCapability::WritesFiles
| ToolCapability::ExecutesCode
| ToolCapability::Network
)
})))
}))
// Shell runs are governed by the execution envelope, sandbox and
// approval posture. The write claim coordinates bounded file
// tools; it no longer denies a child its shell while a peer writes.
&& !is_unbounded_shell_run(name, input)
&& registry.get(name).is_some_and(|spec| {
let canonical = canonical_action_alias(name, input);
let is_shell_control = matches!(
canonical,
"exec_shell_wait" | "exec_shell_interact" | "exec_shell_cancel"
);
let capabilities = spec.capabilities();
!is_shell_control
&& (spec.approval_requirement_for(input) == ApprovalRequirement::Suggest
|| (!spec.is_read_only_for(input)
&& capabilities.iter().any(|capability| {
matches!(
capability,
ToolCapability::WritesFiles
| ToolCapability::ExecutesCode
| ToolCapability::Network
)
})))
})
{
let held = self.coordination_manager.clone().read_owned().await;
let owner = self.owner_agent_id.clone();
@@ -459,7 +458,7 @@ impl ChildAuthority {
.map_err(|error| admission_denied(error.to_string()))?;
if !blocking_peers.is_empty() {
return Err(admission_denied(format!(
"Tool {name} cannot prove a bounded file target or read-only execution while peers are writing in this shared checkout (blocking peers: {}). Use a bounded write tool, a proven read-only command, or bash with read_only=true for analysis under native enforcement. Executable work that needs writes requires worktree isolation. Disjoint write_roots alone do not constrain arbitrary code.",
"Tool {name} cannot prove a bounded file target while peers are writing in this shared checkout (blocking peers: {}). Use a bounded write tool, or run executable work that needs writes in an isolated worktree. Disjoint write_roots alone do not constrain arbitrary code.",
blocking_peers.join(", ")
)));
}
-16
View File
@@ -21890,22 +21890,6 @@ fn is_unbounded_shell_run(name: &str, input: &Value) -> bool {
canonical_action_alias(name, input) == "exec_shell"
}
/// Whether this exact call is a shell run the agent read-only classifier
/// proves mutation-free, or native read-only mode enforces — the contention carve-out.
///
/// This shares its classifier with `bounded_readonly_bash_evidence` but not
/// its role restriction, deliberately: the envelope helper's carve-out
/// *grants* shell to inspection roles that otherwise lack it, so it is scoped
/// to them; the contention gate grants nothing — shell authority, posture, and
/// the envelope have already settled by the time it runs — and asks only
/// whether this call can collide with a live peer's writes. A proven
/// read-only `ls` cannot, whichever role runs it. An explicit read_only=true
/// call likewise cannot: the shared bash executor requires native read-only
/// enforcement or refuses the call before process spawn.
fn proven_readonly_shell_run(name: &str, input: &Value) -> bool {
is_unbounded_shell_run(name, input) && crate::tools::shell::agent_readonly_bash_input(input)
}
/// Parameter names whose *value* is a location to reach, rather than content.
///
/// Deliberately a name list and not a scan of every string: a network-denied
+56 -54
View File
@@ -14226,8 +14226,25 @@ fn subagent_registry_with_mcp_action(auto_approve: bool) -> (ChildCoreProbe, tem
(registry, home)
}
#[tokio::test]
async fn child_write_tool_fails_closed_outside_registered_scope() {
#[test]
fn child_write_tool_fails_closed_outside_registered_scope() {
std::thread::Builder::new()
.stack_size(codewhale_runtime::CODEWHALE_MAIN_STACK_BYTES)
.spawn(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_stack_size(codewhale_runtime::CODEWHALE_MAIN_STACK_BYTES)
.enable_all()
.build()
.unwrap()
.block_on(child_write_tool_fails_closed_outside_registered_scope_body());
})
.unwrap()
.join()
.unwrap();
}
async fn child_write_tool_fails_closed_outside_registered_scope_body() {
let _env_lock = crate::test_support::lock_test_env();
let home = tempdir().expect("isolated CODEWHALE_HOME");
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
@@ -14389,25 +14406,6 @@ async fn child_write_tool_fails_closed_outside_registered_scope() {
);
assert!(!err.contains("agents/coordinate"), "{err}");
assert!(!tmp.path().join("docs/no.txt").exists());
for (tool_name, input, target) in [(
"Bash",
json!({"action": "run", "command": "touch outside/canonical.txt"}),
"outside/canonical.txt",
)] {
let shell_err = registry
.execute("agent_scoped", tool_name, input)
.await
.expect_err("unbounded shared-workspace shell must fail")
.to_string();
assert!(
shell_err.contains("cannot prove a bounded file target"),
"{tool_name}: {shell_err}"
);
assert!(
!tmp.path().join(target).exists(),
"{tool_name} created {target} outside its registered claim"
);
}
let run_err = registry
.execute(
"agent_scoped",
@@ -14433,13 +14431,15 @@ async fn child_write_tool_fails_closed_outside_registered_scope() {
/// A lone writer in the shared checkout keeps its shell.
///
/// The gate above exists so concurrent children cannot overwrite each other.
/// With no second writer there is nothing to collide with, and refusing the
/// shell there bought no safety: the same child may already write these paths
/// through `File`. It only pushed builders toward worktree isolation, which
/// puts their work in a checkout the operator never looks at.
#[tokio::test]
async fn lone_shared_writer_keeps_unbounded_shell() {
/// Shell runs are governed by the execution envelope, sandbox and approval
/// posture; the write claim coordinates bounded file tools. This keeps the
/// lone-writer case covered while the contention tests own the peer case.
#[test]
fn lone_shared_writer_keeps_unbounded_shell() {
run_worker_fixture(lone_shared_writer_keeps_unbounded_shell_body);
}
async fn lone_shared_writer_keeps_unbounded_shell_body() {
let tmp = tempdir().expect("tempdir");
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
{
@@ -14542,8 +14542,12 @@ fn contended_builder_registry(
/// `is_unbounded_shell_run` unconditionally, so a provably read-only `ls` was
/// refused whenever a peer held a claim — even though the ledger had already
/// proven the two claims disjoint at admission.
#[tokio::test]
async fn contended_shared_writers_keep_proven_readonly_shell() {
#[test]
fn contended_shared_writers_keep_proven_readonly_shell() {
run_worker_fixture(contended_shared_writers_keep_proven_readonly_shell_body);
}
async fn contended_shared_writers_keep_proven_readonly_shell_body() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
@@ -14581,11 +14585,15 @@ async fn contended_shared_writers_keep_proven_readonly_shell() {
}
}
/// The same contention still refuses a shell call the classifier cannot prove
/// read-only — and the refusal names the blocking peer and its remediation so
/// the child can recover instead of retrying blindly.
#[tokio::test]
async fn contended_shared_writer_refusal_names_blocking_peer_and_remediation() {
/// A contended writer keeps its shell. The write claim coordinates bounded
/// file tools; executable work is governed by the execution envelope and the
/// approval posture, so a peer's claim no longer denies the child shell.
#[test]
fn contended_shared_writer_keeps_its_shell() {
run_worker_fixture(contended_shared_writer_keeps_its_shell_body);
}
async fn contended_shared_writer_keeps_its_shell_body() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
@@ -14613,30 +14621,23 @@ async fn contended_shared_writer_refusal_names_blocking_peer_and_remediation() {
}
}
let registry = contended_builder_registry(&manager, tmp.path(), "agent_a");
let err = registry
if let Err(err) = registry
.execute(
"agent_a",
"Bash",
json!({"action": "run", "command": "touch src/a2.txt"}),
)
.await
.expect_err("mutating shell under contention must stay refused")
.to_string();
{
assert!(
!err.to_string()
.contains("cannot prove a bounded file target"),
"a peer write claim must not deny the child shell: {err}"
);
}
assert!(
err.contains("cannot prove a bounded file target"),
"refusal kind: {err}"
);
assert!(
err.contains("agent_b"),
"refusal must name the blocking peer: {err}"
);
assert!(
err.contains("bounded write tool") && err.contains("worktree isolation"),
"refusal must state concrete remediation without telling a writer to remove a live peer: {err}"
);
assert!(
!tmp.path().join("src/a2.txt").exists(),
"the refused command must not have run"
tmp.path().join("src/a2.txt").exists(),
"the shell command must have run"
);
}
@@ -23343,8 +23344,9 @@ const READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING: usize = 89_112;
// restores the legacy Operate contract, less 30B from the background-shell
// schema edits, plus 112B for the display-only bash description. The
// context_archive_search and context_archive_get specs (#7173) add 751B. The
// ceiling keeps its existing platform margin after the reviewed schema growth.
const PARENT_SURFACE_BYTE_CEILING: usize = 92_100;
// 0.10.3 tool-text batch adds the rest; the ceiling now matches the measured
// 94,121B on this machine.
const PARENT_SURFACE_BYTE_CEILING: usize = 94_121;
#[tokio::test]
async fn read_only_child_envelope_stays_within_measured_ceiling() {
+7
View File
@@ -238,6 +238,13 @@ until the matching tag, packages, checksums, and release assets exist.
### Changed
- **Sub-agent shell runs no longer wait on a peer's write claim.** A child that
holds a shared write claim can run `bash` while another child writes; the
claim still coordinates bounded `File`/`Edit`/`Patch` targets, and the
execution envelope, sandbox and approval posture govern executable work. A
bounded write tool whose target overlaps a live peer still refuses. The old
refusal told analysis agents to re-run with `read_only=true`, which cost
steps without protecting a peer's files.
- **The footer cost chip shows confirmed dollars only.** A session with some unpriced turns shows exactly the confirmed amount, such as `$0.34`, instead of `subtotal 0.34 + unknown (request outcome unknown)`, and a session with nothing priced shows no cost at all instead of `cost: unknown (…)`. The accounting is unchanged: unpriced turns stay excluded rather than guessed, and `/cost` still lists each one with its reason, now in plain words ("the provider never confirmed the request finished", "this session predates cost tracking"). The price view says `unknown` for a session with no confirmed price.
- **Sub-agents and goals by default.** The constitution now tells the agent to start independent parts of a request as parallel sub-agents in one message, keep working while their results arrive on their own, and end the turn when only waiting remains; to give each child a complete prompt and its own files; to inspect a write-capable child's evidence, review the diff and run the checks itself; to have an independent reviewer try to refute a non-trivial change before calling it done; and to start a goal for any request, in any language, whose verifiable end state needs more than one turn. Sub-agents, workflows and goals within the current request are in scope; spending real money (purchases, paid providers, billing) still needs the user's express authorization. Cost alone is not a reason to stop; only limits the user or configuration sets apply. **This changes the session-pinned prompt and tool descriptions, so each session re-pins its prompt cache once after upgrade.**
- **Operate's contract is retired.** The Engine no longer appends the Operate contract to a session; its review, cost and evidence rules now live in the constitution for every session. Saved sessions that contain it still replay.