mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
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:
1 parent
05b0a91b1e
commit
a2fda9d570
7 files changed
+98
-99
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user