diff --git a/CHANGELOG.md b/CHANGELOG.md index 73578a5..200b8ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.18] - 2026-05-26 + +### Fixed + +- **Claude Code native `Agent` delegation is disabled inside managed + roles.** CoreRoom now starts Claude Code with `--disallowedTools Agent` + and the permission hook hard-denies `Agent`, so peer work must go + through `@role: ` where parent turns, lifecycle, interrupts, + cost, and evidence stay host-controlled. +- **`cr cost` normalizes Claude Code cumulative session totals.** Claude + Code reports `total_cost_usd` as a session total, not a per-turn + increment; CoreRoom now converts those samples to monotonic deltas per + role/session before aggregating. +- **Root turns no longer render as sub-agent spawns.** Public user/host + turns without a parent turn are ignored by the spawn lifecycle tracker, + fixing footer noise such as `@host spawning`. +- **Terminal text selection works by default.** The live room no longer + enables mouse capture unless `COREROOM_MOUSE_CAPTURE=1` is set, so users + can select and copy transcript text normally. + +### Changed + +- **Engine-native delegation is documented as a threat-model invariant.** + The architecture and README now state that CoreRoom-owned delegation is + `@role: `, not an engine-native subagent plane. + ## [0.9.17] - 2026-05-26 ### Added @@ -1674,7 +1700,8 @@ API stability, not feature completeness. - **No timestamps in CREP events.** `cr cost --since` honors the log file's mtime only; per-event timestamps land in v0.2. -[Unreleased]: https://github.com/spytensor/CoreRoom/compare/v0.9.17...HEAD +[Unreleased]: https://github.com/spytensor/CoreRoom/compare/v0.9.18...HEAD +[0.9.18]: https://github.com/spytensor/CoreRoom/compare/v0.9.17...v0.9.18 [0.9.17]: https://github.com/spytensor/CoreRoom/compare/v0.9.16...v0.9.17 [0.9.16]: https://github.com/spytensor/CoreRoom/compare/v0.9.15...v0.9.16 [0.9.15]: https://github.com/spytensor/CoreRoom/compare/v0.9.14...v0.9.15 diff --git a/Cargo.lock b/Cargo.lock index 314244f..02fa4ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,7 +286,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "coreroom" -version = "0.9.17" +version = "0.9.18" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index efe18b9..80d6ebe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coreroom" -version = "0.9.17" +version = "0.9.18" edition = "2021" rust-version = "1.88" authors = ["Charlie Zhu "] diff --git a/README.md b/README.md index d8190a2..2602957 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ delegation line like `@x: ` in its reply. Claude Code is gated by a CoreRoom-injected PreToolUse hook; Codex and Gemini approval support follows each engine's native protocol and is shown only when CoreRoom can supervise it. +- **Control-plane delegation.** CoreRoom disables Claude Code's native + `Agent` delegation tool inside managed roles. Peer work must route through + `@role: ` so parent turns, lifecycle, cost, interrupts, and evidence + stay visible to `@host`. ## Design docs @@ -119,7 +123,7 @@ Disable that with `COREROOM_NO_UPDATE_CHECK=1` or Don't have npm? Direct binary install. ```bash -TAG=v0.9.17 +TAG=v0.9.18 ARCH=$(uname -m); case "$ARCH" in arm64|aarch64) ARCH=aarch64 ;; *) ARCH=x86_64 ;; esac OS=$(uname -s | tr '[:upper:]' '[:lower:]') curl -fsSL "https://github.com/spytensor/CoreRoom/releases/download/${TAG}/cr-${TAG}-${OS}-${ARCH}.tar.gz" \ @@ -243,6 +247,9 @@ Useful commands: replays the full event log when you need to audit what happened. Set `COREROOM_VERBOSE_TOOLS=1` to opt the live REPL back into the full per-tool trace stream when you need it inline. +- The live room leaves terminal mouse capture off by default so transcript + text can be selected and copied normally. Set `COREROOM_MOUSE_CAPTURE=1` + if you prefer mouse-wheel routing inside the TUI. - Permission prompts appear only while a decision is needed. Successful once-only allows clear the prompt and stay out of the chat stream; session approvals and denials remain visible because they change what the role can @@ -254,9 +261,9 @@ Useful commands: | ---------- | ------------------ | ----- | ------ | | Prompt isolation | system-prompt file | MCP base instructions | requires `--system-instruction-file` | | Tool trace events | proposed + executed | exec notifications when emitted | stream-json tool_use/tool_result | -| Cost reporting | per turn | — | — | +| Cost reporting | normalized from session total | — | — | | Budget enforcement | native cap | — | — | -| Permission gating | `ask` / `auto` / `bypass` via PreToolUse hook | `ask` / `auto` / `bypass` via MCP approval bridge in live REPL | explicit `bypass` only | +| Permission gating | `ask` / `auto` / `bypass` via PreToolUse hook; native `Agent` delegation disabled | `ask` / `auto` / `bypass` via MCP approval bridge in live REPL | explicit `bypass` only | `cr cost` excludes unsupported engines from the numeric total and marks them with `—`. This is deliberate: older builds displayed `$0.00` for engines diff --git a/data/splash_content.toml b/data/splash_content.toml index 795d8e1..a402dfe 100644 --- a/data/splash_content.toml +++ b/data/splash_content.toml @@ -15,6 +15,14 @@ items = [ "/journal captures today's lessons-learned", ] +[[whats_new]] +version = "0.9.18" +items = [ + "Claude Agent delegation is disabled inside CoreRoom roles; use @role routing", + "cr cost now normalizes Claude Code cumulative session totals", + "terminal text selection works by default; mouse capture is opt-in", +] + [[whats_new]] version = "0.9.17" items = [ diff --git a/docs/architecture.md b/docs/architecture.md index 7ef8da8..4c26739 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -285,9 +285,9 @@ partial scope coverage is a blocker. ### Claude Code adapter - Spawn: `claude --print --input-format=stream-json --output-format=stream-json - --verbose --dangerously-skip-permissions --append-system-prompt-file - --settings ` when permission mode is `ask` or `auto`; - `bypass` omits the hook settings. + --verbose --dangerously-skip-permissions --disallowedTools Agent + --append-system-prompt-file --settings ` when + permission mode is `ask` or `auto`; `bypass` omits the hook settings. - Input: stream-json messages on stdin. `content` must be array of blocks (`[{"type":"text","text":"…"}]`), not bare string. - Output: stream-json on stdout (`system`, `assistant`, `result`, @@ -300,8 +300,13 @@ partial scope coverage is a blocker. `/deny ` update the session policy file read by that hook. In Claude Code's non-interactive stream mode, `ask` is represented as a safe denial in `permission_denials`; the user can `/allow` and retry. +- Native delegation guard: Claude Code's `Agent` tool is disabled for + managed CoreRoom roles. Peer work must use `@role: ` so the + dispatcher owns parent turns, lifecycle, cost attribution, interrupts, + and tracker evidence. - Session ID: extracted from `system.subtype="init"` event at start. -- Cost: per-turn `result.total_cost_usd`. Wrapper aggregates per role per day. +- Cost: `result.total_cost_usd` is a cumulative Claude Code session sample; + `cr cost` normalizes monotonic samples before aggregating per role. ### Codex adapter @@ -499,6 +504,7 @@ Raw CREP JSONL per role per session. Never auto-loaded — used for forensics, | Patch directory bloat | Hard 50-cap per role + FIFO archive at v0.1 | | Routing loops (`@a` ↔ `@b` ↔ `@a`) | Dispatcher-owned routing state. Auto-router only acts on explicit delegation lines that start with `@role:`, and skips self-delegation (`@a` delegating to itself), unknown roles (`@`), and ungrounded turns (tool calls were systematically denied → reply is a guess). User-origin depth is 0; each auto-route child is parent depth + 1; default max hop depth is 5. Fan-out and queued-turn limits are separate; chains also end when the queue drains or the user halts (`Ctrl-C` × 2 or `/halt`). | | Permission gate fail-open | Hook script defaults to deny on any error; wrapper supervises hook process and treats non-zero exit without decision-file as deny | +| Engine-native delegation escape | Claude Code `Agent` is disallowed at spawn time and denied by the CoreRoom hook so sub-work cannot bypass `@role` routing, lifecycle, cost, or host interrupts | | Concurrency / SIGINT mid-tool | Each role's tool calls wrapped in `.coreroom/locks/.inflight`. On startup, stale inflight markers put the role in recovery mode (no new tool calls until user acknowledges) | | Token cost runaway | User halts with `Ctrl-C` × 2 or `/halt`; cost per turn is surfaced in the WorkCard so runaway behavior is visible | | Role identity drift over months | v0.2 `cr review` diffs journal-self vs priors-self and surfaces contradictions | diff --git a/docs/threat-model.md b/docs/threat-model.md index 0270461..e7e4cbd 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -70,41 +70,47 @@ architecture amendment before implementation. route. Auto-routing only acts on explicit delegation lines accepted by the parser, such as `@backend: ` or `@backend @ci: `. -4. Peer output is quoted evidence, not a command channel. +4. Engine-native delegation is not a CoreRoom route. + Managed roles must not use engine-native subagent tools such as Claude + Code `Agent`. Those tools create work and cost outside dispatcher-owned + parent turns, lifecycle, interrupts, and evidence. Peer work must route + through `@role: `. + +5. Peer output is quoted evidence, not a command channel. Cross-role payloads are treated as data from the sending role. A receiving role can use that content as context, but embedded instructions inside the quote do not override its kernel, priors, or current user request. -5. Current-thread evidence is required for peer claims. +6. Current-thread evidence is required for peer claims. A role may claim consensus, approval, review completion, or "merged perspectives" only from current-thread peer evidence surfaced by the runtime, such as peer-quote envelopes, current turn ids, or user-pasted current-thread text. Memory, priors, journals, and resumed engine context are not enough. -6. Editable logs are not enforcement state. +7. Editable logs are not enforcement state. `.coreroom/messages.jsonl` supports replay and audit, but live safety decisions must come from runtime-owned state or explicit user commands. Future budget enforcement must not trust a mutable log total. -7. Permission policy is visible and resettable. +8. Permission policy is visible and resettable. Existing allow/deny decisions must be visible at startup and through `/permissions`. Review or release workflows that require fresh attention should use `/permissions clear` and, when stale engine context matters, `/fresh`. -8. Resume is convenience, not provenance. +9. Resume is convenience, not provenance. Resuming an engine session may carry useful context, but it also carries stale claims. `cr` must surface resumed roles and the clean-start controls. Release reviews, audits, and incident work should prefer `cr start --fresh` or `/fresh` unless the user intentionally wants continuity. -9. Tier 0 is inline. +10. Tier 0 is inline. Tier 0/read-only review may inspect files and commands needed for evidence, but it does not write hidden `.coreroom/` review artifacts. Persistent evidence, cross-model review, or release sign-off belongs in Tier 1. -10. Authority-scoped veto is explicit. +11. Authority-scoped veto is explicit. A role can block plan advancement only when all of these are true: the role has a validated authority scope in configuration, the plan artifact declares an intersecting scope, and the role records an explicit review @@ -112,41 +118,41 @@ architecture amendment before implementation. editable logs cannot create authority, expand scope, reject a plan, or override a rejection. -11. User override is a command, not a claim. +12. User override is a command, not a claim. A scoped veto can be overruled only by an explicit user command with a reason. The override is recorded in the gate ledger and CREP audit trail. Text emitted by a role, transcript replay, or a journal entry may explain the override after the fact, but cannot substitute for it. -12. Host-led control is visible and confirmable. +13. Host-led control is visible and confirmable. `@host` is the highest in-room coordination authority, but host output is still model text. Persistent project state changes require explicit user confirmation or a visible command path. Non-host roles cannot create WorkOrders, register sources, update trackers, prepare completion claims, or close evidence gaps by prose. -13. WorkOrders bind state; they do not prove state. +14. WorkOrders bind state; they do not prove state. A WorkOrder can link a GitHub Issue, gate thread, branch, PR, tracker row, and evidence expectations, but it is still a local project file. GitHub Issue creation or binding requires confirmation. Binding an existing issue must not silently mutate the issue body, labels, milestone, or comments. Completion still depends on external evidence and tracker closure. -14. Source Registry is pinned context, not prompt memory. +15. Source Registry is pinned context, not prompt memory. Project sources must carry pins, trust levels, owners, visible roles, purpose, and refresh policy before they can be used for WorkOrder context. Registering or re-pinning a source requires confirmation. Remote and external sources must never silently refresh. Adding a source does not mount it into role knowledge or make it part of a ContextPack. -15. ContextPacks are scoped selections. +16. ContextPacks are scoped selections. A ContextPack can select path/range or snapshot references from registered sources for specific target roles. It must not imply that all project sources are loaded into every role. Stale pins and unpinned selected sources must be surfaced before delegation; they are not hidden evidence of freshness. -16. Evidence Packets are structured claims. +17. Evidence Packets are structured claims. Evidence Packets can support host PR summaries, but completion still depends on required fields being present and tracker state being updated. Model prose alone cannot satisfy changed-file, command, test, review, risk, diff --git a/npm/package.json b/npm/package.json index 65b6a35..e202f7a 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@spytensor/coreroom", - "version": "0.9.17", + "version": "0.9.18", "description": "CoreRoom is the Engineering Control Room for AI Agents: host-led, GitHub-gated AI-assisted software engineering control.", "keywords": [ "cli", diff --git a/src/adapter/cc.rs b/src/adapter/cc.rs index b3815d1..9909b3e 100644 --- a/src/adapter/cc.rs +++ b/src/adapter/cc.rs @@ -11,6 +11,7 @@ //! ```text //! claude --print --input-format=stream-json --output-format=stream-json \ //! --include-hook-events --verbose --dangerously-skip-permissions \ +//! --disallowedTools Agent \ //! --append-system-prompt-file= [--model=] //! ``` //! @@ -52,6 +53,7 @@ use crate::turn::{TurnId, LEGACY_TURN_ID}; /// event outbound queue. Sized for typical interactive usage; can be /// revisited if back-pressure becomes a real problem. const CHANNEL_CAPACITY: usize = 64; +const NATIVE_DELEGATION_TOOL: &str = "Agent"; /// Adapter that drives the Claude Code CLI. #[derive(Debug, Clone)] @@ -105,16 +107,9 @@ impl EngineAdapter for CcAdapter { let mut tempfiles = Vec::new(); let mut cmd = Command::new(&self.claude_path); - cmd.arg("--print") - .arg("--input-format=stream-json") - .arg("--output-format=stream-json") - .arg("--include-hook-events") - .arg("--verbose") - .arg("--dangerously-skip-permissions") - .arg(format!( - "--append-system-prompt-file={}", - config.priors_path.display() - )); + for arg in base_claude_args(&config.priors_path) { + cmd.arg(arg); + } // Per amendment A-006: if the REPL handed us a session id // saved by a previous `cr start`, ask cc to resume that // conversation instead of opening a fresh one. cc tracks @@ -235,6 +230,20 @@ impl EngineAdapter for CcAdapter { } } +fn base_claude_args(priors_path: &Path) -> Vec { + vec![ + "--print".to_owned(), + "--input-format=stream-json".to_owned(), + "--output-format=stream-json".to_owned(), + "--include-hook-events".to_owned(), + "--verbose".to_owned(), + "--dangerously-skip-permissions".to_owned(), + "--disallowedTools".to_owned(), + NATIVE_DELEGATION_TOOL.to_owned(), + format!("--append-system-prompt-file={}", priors_path.display()), + ] +} + #[derive(Debug, Clone)] struct ActiveTurn { turn_id: TurnId, diff --git a/src/adapter/cc/tests.rs b/src/adapter/cc/tests.rs index a4481ef..f7f6b1e 100644 --- a/src/adapter/cc/tests.rs +++ b/src/adapter/cc/tests.rs @@ -3,6 +3,7 @@ use crate::turn::TurnId; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; +use std::path::Path; #[test] fn fingerprint_is_stable_for_same_input() { @@ -17,6 +18,24 @@ fn fingerprint_changes_with_content() { assert_ne!(fingerprint("a"), fingerprint("b")); } +#[test] +fn base_claude_args_disallow_native_agent_delegation() { + let args = base_claude_args(Path::new("/tmp/priors.md")); + let disallow_idx = args + .iter() + .position(|arg| arg == "--disallowedTools") + .expect("claude args should include a native delegation guard"); + assert_eq!( + args.get(disallow_idx + 1).map(String::as_str), + Some("Agent") + ); + assert!( + args.iter() + .any(|arg| arg == "--append-system-prompt-file=/tmp/priors.md"), + "priors must still be appended: {args:?}" + ); +} + #[test] fn parse_mentions_picks_up_simple_names() { let text = "Will check with @security and @frontend."; diff --git a/src/console_room_runtime.rs b/src/console_room_runtime.rs index 5df8933..dc61bfb 100644 --- a/src/console_room_runtime.rs +++ b/src/console_room_runtime.rs @@ -2597,16 +2597,30 @@ fn lines_to_plain_string(lines: &[Line<'_>]) -> String { .join("\n") } -fn write_enter_commands(mut writer: W) -> io::Result<()> { - // `EnableMouseCapture` is what turns the live room into a true - // K9S / tmux / vim style sandbox: the terminal stops scrolling its - // own main-buffer scrollback in response to the wheel and forwards - // mouse events to us instead. Without this, alt-screen still lets - // the user surface prior shell history with the scroll wheel on - // iTerm2 / Terminal.app, which breaks the "this is its own app" - // impression. We don't act on the mouse events for now — the - // event loop ignores them — but the capture is enough to keep the - // viewport pinned to what the TUI rendered. +fn write_enter_commands(writer: W) -> io::Result<()> { + write_enter_commands_with_mouse_capture(writer, mouse_capture_opt_in()) +} + +fn mouse_capture_opt_in() -> bool { + std::env::var("COREROOM_MOUSE_CAPTURE") + .ok() + .is_some_and(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +fn write_enter_commands_with_mouse_capture( + mut writer: W, + enable_mouse_capture: bool, +) -> io::Result<()> { + // Mouse capture is intentionally opt-in. It lets the live room own + // wheel events, but it also prevents normal terminal text + // selection. Defaulting to copyable transcript text is the less + // surprising behavior; users who prefer mouse-wheel routing can + // run with `COREROOM_MOUSE_CAPTURE=1`. // // Cursor visibility is intentionally *not* set here. ratatui's // `Terminal::draw` shows or hides the cursor every frame based on @@ -2614,12 +2628,11 @@ fn write_enter_commands(mut writer: W) -> io::Result<()> { // standalone `Hide` here would race with the composer's per-frame // `set_cursor_position` call and leave the Ask input without a // visible caret. - execute!( - writer, - EnterAlternateScreen, - EnableBracketedPaste, - EnableMouseCapture, - ) + execute!(writer, EnterAlternateScreen, EnableBracketedPaste)?; + if enable_mouse_capture { + execute!(writer, EnableMouseCapture)?; + } + Ok(()) } fn write_leave_commands(mut writer: W) -> io::Result<()> { @@ -2950,16 +2963,28 @@ mod tests { // per-frame `set_cursor_position` call and leaves Ask without a // visible caret. let mut buf: Vec = Vec::new(); - super::write_enter_commands(&mut buf).expect("write enter commands"); + super::write_enter_commands_with_mouse_capture(&mut buf, false) + .expect("write enter commands"); let text = String::from_utf8(buf).expect("enter commands are valid utf8"); assert!( !text.contains("\x1b[?25l"), "enter commands must not hide the cursor: {text:?}" ); - // Mouse capture must remain enabled (the v0.9.12 sandbox contract). + assert!( + !text.contains("\x1b[?1000h") && !text.contains("\x1b[?1003h"), + "mouse capture should default off so terminal selection works: {text:?}" + ); + } + + #[test] + fn write_enter_commands_can_opt_into_mouse_capture() { + let mut buf: Vec = Vec::new(); + super::write_enter_commands_with_mouse_capture(&mut buf, true) + .expect("write enter commands"); + let text = String::from_utf8(buf).expect("enter commands are valid utf8"); assert!( text.contains("\x1b[?1000h") || text.contains("\x1b[?1003h"), - "enter commands should still enable mouse capture: {text:?}" + "opt-in enter commands should enable mouse capture: {text:?}" ); } @@ -3927,7 +3952,7 @@ mod tests { priors_hash: String::new(), turn_id: turn_id.to_owned(), thread_id: format!("thread-{turn_id}"), - parent_turn_id: None, + parent_turn_id: Some("root".to_owned()), queue_position: 0, }), host_role: state.host_role.clone(), @@ -3956,7 +3981,7 @@ mod tests { priors_hash: String::new(), turn_id: turn_id.to_owned(), thread_id: format!("thread-{turn_id}"), - parent_turn_id: None, + parent_turn_id: Some("root".to_owned()), queue_position: 0, }), host_role: state.host_role.clone(), @@ -4131,7 +4156,7 @@ mod tests { priors_hash: String::new(), turn_id: crate::turn::TurnId::from(turn.to_owned()), thread_id: crate::turn::TurnId::from(format!("thread-{turn}")), - parent_turn_id: None, + parent_turn_id: Some(crate::turn::TurnId::from("root".to_owned())), queue_position: 0, } } diff --git a/src/cost.rs b/src/cost.rs index 7abeb94..1ed2521 100644 --- a/src/cost.rs +++ b/src/cost.rs @@ -1,11 +1,13 @@ //! `cr cost` — per-role spend summary derived from //! `.coreroom/messages.jsonl`. //! -//! v0.1 sums `RoleSpoke.cost_usd` by role across the entire log -//! (or, with `--since`, from the given date forward). Cache reads are -//! also surfaced because they're a useful proxy for "how warm was -//! this session" — large `cache_read` totals usually mean low cost -//! per turn. +//! `RoleSpoke.cost_usd` is summed by role across the entire log (or, +//! with `--since`, from the given date forward). Claude Code reports +//! its `total_cost_usd` as a cumulative session sample, so cc samples +//! are normalized to monotonic deltas per role/session before they are +//! added. Cache reads are also surfaced because they're a useful proxy +//! for "how warm was this session" — large `cache_read` totals usually +//! mean low cost per turn. use std::collections::BTreeMap; use std::path::Path; @@ -22,7 +24,7 @@ use crate::crep::CrepEvent; pub struct RoleStats { /// Number of `RoleSpoke` events from this role. pub turns: u64, - /// Sum of `cost_usd` across all turns. + /// Sum of normalized cost across all turns. pub cost_usd: f64, /// Whether this role's engine reports real cost. Unsupported /// engines render as `—` rather than a fake `$0.00`. @@ -61,11 +63,24 @@ pub async fn aggregate( let replay = MessageBus::replay(&log_path).await?; let mut by_role: BTreeMap = BTreeMap::new(); let mut engine_by_role: BTreeMap = BTreeMap::new(); + let mut session_by_role: BTreeMap = BTreeMap::new(); + let mut cc_cost_by_session: BTreeMap<(String, String), f64> = BTreeMap::new(); for event in replay.events { match event { - CrepEvent::RoleStarted { role, engine, .. } => { + CrepEvent::RoleStarted { + role, + engine, + session_id, + .. + } => { + session_by_role.insert(role.clone(), session_id); engine_by_role.insert(role, engine); } + CrepEvent::RoleSessionUpdated { + role, session_id, .. + } => { + session_by_role.insert(role, session_id); + } CrepEvent::RoleSpoke { role, cost_usd, @@ -73,11 +88,21 @@ pub async fn aggregate( .. } => { let engine = engine_by_role.get(&role).map(String::as_str); + let cost_increment = if engine == Some("cc") { + cc_cost_delta( + &mut cc_cost_by_session, + &role, + session_by_role.get(&role).map(String::as_str), + cost_usd, + ) + } else { + cost_usd + }; let entry = by_role.entry(role).or_default(); entry.turns += 1; if engine == Some("cc") || cost_usd > 0.0 { entry.cost_supported = true; - entry.cost_usd += cost_usd; + entry.cost_usd += cost_increment; } entry.cache_read = entry.cache_read.saturating_add(cache_read); } @@ -87,6 +112,25 @@ pub async fn aggregate( Ok(by_role) } +fn cc_cost_delta( + last_totals: &mut BTreeMap<(String, String), f64>, + role: &str, + session_id: Option<&str>, + total_cost_usd: f64, +) -> f64 { + if total_cost_usd <= 0.0 { + return 0.0; + } + let session_id = session_id + .filter(|id| !id.is_empty()) + .unwrap_or(""); + let key = (role.to_owned(), session_id.to_owned()); + match last_totals.insert(key, total_cost_usd) { + Some(previous) if total_cost_usd >= previous => total_cost_usd - previous, + Some(_) | None => total_cost_usd, + } +} + /// Top-level entry point for `cr cost`. Loads the log, aggregates, /// prints a table to stdout. pub async fn run(project_root: &Path, since: Option) -> Result<()> { @@ -186,7 +230,7 @@ mod tests { &[ CrepEvent::RoleStarted { role: "backend".into(), - engine: "cc".into(), + engine: "other".into(), model: "opus".into(), session_id: "b".into(), priors_hash: "h".into(), @@ -195,7 +239,7 @@ mod tests { spoke("backend", 0.10, 2000), CrepEvent::RoleStarted { role: "frontend".into(), - engine: "cc".into(), + engine: "other".into(), model: "opus".into(), session_id: "f".into(), priors_hash: "h".into(), @@ -220,6 +264,38 @@ mod tests { assert_eq!(frontend.turns, 1); } + #[tokio::test] + async fn aggregate_normalizes_cc_total_cost_samples_per_session() { + let tmp = TempDir::new().unwrap(); + write_log( + &tmp, + &[ + CrepEvent::RoleStarted { + role: "host".into(), + engine: "cc".into(), + model: "opus".into(), + session_id: "s1".into(), + priors_hash: "h".into(), + }, + spoke("host", 10.0, 100), + spoke("host", 12.5, 200), + spoke("host", 12.5, 300), + CrepEvent::RoleSessionUpdated { + role: "host".into(), + priors_hash: "h".into(), + session_id: "s2".into(), + }, + spoke("host", 1.0, 400), + ], + ); + let stats = aggregate(tmp.path(), None).await.unwrap(); + let host = stats.get("host").unwrap(); + assert_eq!(host.turns, 4); + assert!(host.cost_supported); + assert!((host.cost_usd - 13.5).abs() < 1e-9); + assert_eq!(host.cache_read, 1_000); + } + #[tokio::test] async fn aggregate_marks_non_cc_zero_cost_as_unsupported() { let tmp = TempDir::new().unwrap(); diff --git a/src/crep.rs b/src/crep.rs index 077f774..9063c36 100644 --- a/src/crep.rs +++ b/src/crep.rs @@ -130,7 +130,9 @@ pub enum CrepEvent { /// Parsed `@` references from `text`, in order of first /// appearance, deduplicated. mentions: Vec, - /// Cost of this turn in USD (engine-reported). + /// Cost sample in USD (engine-reported). Some engines report a + /// per-turn delta; Claude Code reports a cumulative session + /// total that `cr cost` normalizes before aggregation. cost_usd: f64, /// Tokens served from prompt cache for this turn (engine-reported). cache_read: u64, diff --git a/src/lib.rs b/src/lib.rs index 2eeacdd..2b10e49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ //! primary consumer. See `docs/architecture.md` for the v0.1 constitution and //! `docs/v0.2-trust-and-interrupt.md` for the v0.2 amendment. -#![doc(html_root_url = "https://docs.rs/coreroom/0.9.17")] +#![doc(html_root_url = "https://docs.rs/coreroom/0.9.18")] #![warn(missing_debug_implementations)] #![warn(rust_2018_idioms)] diff --git a/src/permissions/mod.rs b/src/permissions/mod.rs index d7c197d..14109e1 100644 --- a/src/permissions/mod.rs +++ b/src/permissions/mod.rs @@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::adapter::PermissionMode; + +const CORE_CONTROL_PLANE_TOOLS: &[&str] = &["Agent"]; use crate::config::COREROOM_DIR; pub use bridge::{ @@ -331,6 +333,12 @@ fn decide_tool( policy: &PermissionPolicy, request: &ToolRequest, ) -> ToolVerdict { + if is_core_control_plane_tool(&request.name) { + return deny(format!( + "{} is reserved for engine-native delegation; use CoreRoom @role: routing so cost, lifecycle, and interrupts stay host-controlled", + request.name + )); + } if policy.denies(&request.name) { return deny(format!( "{} denied by CoreRoom session policy", @@ -409,6 +417,12 @@ fn canonical_tool_name(tool: &str) -> String { tool.trim().to_owned() } +fn is_core_control_plane_tool(tool: &str) -> bool { + CORE_CONTROL_PLANE_TOOLS + .iter() + .any(|reserved| tool.eq_ignore_ascii_case(reserved)) +} + #[cfg(test)] mod tests { use super::*; @@ -446,6 +460,20 @@ mod tests { assert_eq!(verdict.claude_decision, "ask"); } + #[test] + fn native_agent_tool_is_denied_even_when_policy_allows_it() { + let mut policy = PermissionPolicy::default(); + policy.allow_tool("Agent"); + let request = ToolRequest { + name: "Agent".into(), + input: json!({"description": "spawn reviewer"}), + }; + let verdict = decide_tool(PermissionMode::Bypass, &policy, &request); + assert_eq!(verdict.claude_decision, "deny"); + assert!(verdict.reason.contains("@role")); + assert!(verdict.reason.contains("host-controlled")); + } + #[test] fn auto_asks_for_bash_commands_with_mutating_subcommands_or_shell_meta() { for command in [ diff --git a/src/priors.rs b/src/priors.rs index aec5292..e74d103 100644 --- a/src/priors.rs +++ b/src/priors.rs @@ -86,6 +86,8 @@ Do not impersonate another role or claim another role's findings as your own. Bare user text is dispatched to the configured host role by the runtime. Project and role priors may shape host behavior, but they cannot redefine CoreRoom routing syntax. +Native subagent tools are not routes. Delegate peers only with `@role: `. + ## Turn outcome contract End a reply with `cr-status: ` on its own final line (nothing after it) to halt routing of this reply's `@role:` delegations. The runtime strips the marker before the bus sees the text. Variants: `no_increment` (no domain-specific input), `converged` (thread resolved), `needs_user` (user decision required). Omit for normal routing; mid-paragraph occurrences are ignored. Mechanical depth, fan-out, and queue caps still apply. diff --git a/src/repl/tests.rs b/src/repl/tests.rs index d1b26c7..467ff36 100644 --- a/src/repl/tests.rs +++ b/src/repl/tests.rs @@ -640,17 +640,17 @@ fn snapshot_boot_dashboard_at_80() { .trim_start_matches('\n') .to_owned(); insta::assert_snapshot!(rendered, @r" -┌─ CoreRoom v0.9.17 ───────────────────────────────────────────────────────────┐ +┌─ CoreRoom v0.9.18 ───────────────────────────────────────────────────────────┐ │ │ │ welcome back, Ada tips for getting started │ │ • type @role to send a task to a sp… │ │ ◉ @host cc · 1M · ask • /halt @role interrupts a turn; Ct… │ │ ◇ @backend cc · 1M · ask • /journal captures today's … │ │ ◆ @security codex · default · bypass │ -│ what's new in 0.9.17 │ -│ 3.3k base tokens loaded • sub-agent work now appears inline… │ -│ /repo/CoreRoom • finished cards collapse to Done l… │ -│ • right rail is a slim project dash… │ +│ what's new in 0.9.18 │ +│ 3.3k base tokens loaded • Claude Agent delegation is disabl… │ +│ /repo/CoreRoom • cr cost now normalizes Claude Cod… │ +│ • terminal text selection works by … │ │ │ │ /help for commands │ │ │ diff --git a/src/spawn_lifecycle.rs b/src/spawn_lifecycle.rs index b3c3614..da56e70 100644 --- a/src/spawn_lifecycle.rs +++ b/src/spawn_lifecycle.rs @@ -242,21 +242,28 @@ pub struct SpawnLifecycleTracker { /// Turn-id → spawn-id index. Lets tool-call and `RoleSpoke` /// events find their spawn instance in O(log N) without scanning. by_turn: BTreeMap, - /// Host role name used to attribute root spawns (no `parent_turn_id`). - /// Root spawns are still recorded so the footer can narrate "host - /// is working" without a special case in the renderer. + /// Turn-id → role index for all dispatched turns, including public + /// root turns that are intentionally not tracked as sub-agent + /// spawns. Child spawns use this to attribute `spawned_by` without + /// forcing the root host turn itself into the working-card model. + role_by_turn: BTreeMap, + /// Host role name used as a fallback attribution when a child + /// dispatch references a parent turn that is not present in the + /// in-memory replay window. host_role: String, } impl SpawnLifecycleTracker { /// Build a new tracker. `host_role` is used as the default - /// `spawned_by` attribution for root-level spawns. + /// `spawned_by` attribution for child spawns whose parent turn is + /// outside the in-memory replay window. #[must_use] pub fn new(host_role: impl Into) -> Self { Self { next_id: 0, instances: BTreeMap::new(), by_turn: BTreeMap::new(), + role_by_turn: BTreeMap::new(), host_role: host_role.into(), } } @@ -330,7 +337,7 @@ impl SpawnLifecycleTracker { turn_id, parent_turn_id, .. - } => Some(self.on_turn_dispatched(role, turn_id, parent_turn_id.as_ref())), + } => self.on_turn_dispatched(role, turn_id, parent_turn_id.as_ref()), CrepEvent::ToolCallProposed { tool_name, tool_use_id, @@ -378,15 +385,25 @@ impl SpawnLifecycleTracker { role: &str, turn_id: &TurnId, parent_turn_id: Option<&TurnId>, - ) -> SpawnId { - // Concurrent spawns by the same role: each `TurnDispatched` - // mints a new spawn id even if a prior spawn for the same role - // is still `Working`. Distinguishing comes from `turn_id`. + ) -> Option { + self.role_by_turn.insert(turn_id.clone(), role.to_owned()); + let Some(parent_turn_id) = parent_turn_id else { + // Public root turns are not sub-agent spawns. Tracking + // them here made normal @host replies render as + // "@host spawning" footer noise and working cards. + return None; + }; + + // Concurrent spawns by the same role: each child + // `TurnDispatched` mints a new spawn id even if a prior spawn + // for the same role is still `Working`. Distinguishing comes + // from `turn_id`. let spawn_id = self.next_spawn_id(); - let spawned_by = parent_turn_id - .and_then(|parent| self.by_turn.get(parent).copied()) - .and_then(|parent_id| self.instances.get(&parent_id)) - .map_or_else(|| self.host_role.clone(), |parent| parent.role.clone()); + let spawned_by = self + .role_by_turn + .get(parent_turn_id) + .cloned() + .unwrap_or_else(|| self.host_role.clone()); let now = Instant::now(); let instance = SpawnInstance { spawn_id, @@ -400,13 +417,13 @@ impl SpawnLifecycleTracker { final_report_message_id: None, outcome: Outcome::default(), turn_id: turn_id.clone(), - parent_turn_id: parent_turn_id.cloned(), + parent_turn_id: Some(parent_turn_id.clone()), title: String::new(), chat_position: 0, }; self.instances.insert(spawn_id, instance); self.by_turn.insert(turn_id.clone(), spawn_id); - spawn_id + Some(spawn_id) } fn on_tool_proposed( @@ -624,6 +641,10 @@ mod tests { } } + fn subagent_dispatched(role: &str, turn: &str) -> CrepEvent { + turn_dispatched(role, turn, Some("root")) + } + fn tool_proposed(turn: &str, tool: &str, tool_use_id: &str) -> CrepEvent { CrepEvent::ToolCallProposed { role: String::new(), @@ -680,11 +701,23 @@ mod tests { } #[test] - fn dispatch_creates_spawning_instance_attributed_to_host_by_default() { + fn root_dispatch_is_not_tracked_as_a_subagent_spawn() { + let mut tracker = SpawnLifecycleTracker::new("host"); + assert!( + tracker + .apply_event(&turn_dispatched("host", "root", None)) + .is_none(), + "public root host turns must not become working-card spawns" + ); + assert_eq!(tracker.instances().count(), 0); + } + + #[test] + fn child_dispatch_creates_spawning_instance_attributed_to_host_by_default() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) - .expect("dispatch yields spawn id"); + .apply_event(&subagent_dispatched("backend", "t1")) + .expect("child dispatch yields spawn id"); let instance = tracker.get(id).expect("instance exists"); assert_eq!(instance.role, "backend"); assert_eq!(instance.spawned_by, "host"); @@ -698,7 +731,7 @@ mod tests { fn first_tool_call_transitions_spawning_to_working() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); tracker.apply_event(&tool_proposed("t1", "Bash", "u1")); let instance = tracker.get(id).unwrap(); @@ -712,7 +745,7 @@ mod tests { fn streaming_output_alone_also_promotes_to_working() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); tracker.apply_event(&CrepEvent::RoleOutputDelta { role: "backend".to_owned(), @@ -729,7 +762,7 @@ mod tests { fn tool_executed_marks_record_done_and_increments_step_count() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); tracker.apply_event(&tool_proposed("t1", "Bash", "u1")); tracker.apply_event(&tool_executed("t1", "u1", true)); @@ -748,7 +781,7 @@ mod tests { // RoleSpoke / TurnInterrupted reaches Done. let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); tracker.apply_event(&tool_proposed("t1", "Bash", "u1")); tracker.apply_event(&tool_executed("t1", "u1", false)); @@ -765,7 +798,7 @@ mod tests { fn role_spoke_transitions_working_to_done_then_reported_clean() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); tracker.apply_event(&tool_proposed("t1", "Bash", "u1")); tracker.apply_event(&tool_executed("t1", "u1", true)); @@ -790,7 +823,7 @@ mod tests { fn turn_interrupted_resolves_to_done_with_interrupted_outcome() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); tracker.apply_event(&tool_proposed("t1", "Bash", "u1")); tracker.apply_event(&turn_interrupted("t1")); @@ -803,7 +836,7 @@ mod tests { fn interrupt_during_spawning_still_reaches_done_with_interrupted_outcome() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); tracker.apply_event(&turn_interrupted("t1")); let instance = tracker.get(id).unwrap(); @@ -817,10 +850,10 @@ mod tests { // different turn ids must mint two distinct spawn instances. let mut tracker = SpawnLifecycleTracker::new("host"); let id_a = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); let id_b = tracker - .apply_event(&turn_dispatched("backend", "t2", None)) + .apply_event(&subagent_dispatched("backend", "t2")) .unwrap(); assert_ne!(id_a, id_b); @@ -846,9 +879,9 @@ mod tests { // @security via `parent_turn_id`. The security spawn should be // attributed to backend, not host. let mut tracker = SpawnLifecycleTracker::new("host"); - let _backend = tracker + assert!(tracker .apply_event(&turn_dispatched("backend", "t1", None)) - .unwrap(); + .is_none()); let security = tracker .apply_event(&turn_dispatched("security", "t2", Some("t1"))) .unwrap(); @@ -859,7 +892,7 @@ mod tests { fn permission_denied_records_failed_tool_without_failing_lifecycle() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); tracker.apply_event(&CrepEvent::PermissionDenied { role: "backend".to_owned(), @@ -885,13 +918,13 @@ mod tests { fn working_instances_ordered_by_started_at_lists_older_first() { let mut tracker = SpawnLifecycleTracker::new("host"); let first = tracker - .apply_event(&turn_dispatched("security", "t1", None)) + .apply_event(&subagent_dispatched("security", "t1")) .unwrap(); // Force a different started_at by burning a tick. `Instant` // is monotonic on every supported platform. std::thread::sleep(std::time::Duration::from_millis(2)); let second = tracker - .apply_event(&turn_dispatched("backend", "t2", None)) + .apply_event(&subagent_dispatched("backend", "t2")) .unwrap(); tracker.apply_event(&tool_proposed("t1", "Bash", "u1")); tracker.apply_event(&tool_proposed("t2", "Read", "u2")); @@ -912,10 +945,10 @@ mod tests { // disjoint by state. let mut tracker = SpawnLifecycleTracker::new("host"); let _spawning = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); let working = tracker - .apply_event(&turn_dispatched("security", "t2", None)) + .apply_event(&subagent_dispatched("security", "t2")) .unwrap(); // Promote the second spawn to Working with a tool call; leave // the first in Spawning. @@ -942,7 +975,7 @@ mod tests { // Events that have no per-spawn meaning return `None` and // leave the tracker untouched. let mut tracker = SpawnLifecycleTracker::new("host"); - tracker.apply_event(&turn_dispatched("backend", "t1", None)); + tracker.apply_event(&subagent_dispatched("backend", "t1")); let before = tracker.get(SpawnId(0)).cloned().unwrap(); let result = tracker.apply_event(&CrepEvent::RoleStarted { role: "backend".to_owned(), @@ -967,7 +1000,7 @@ mod tests { // listens for it and last-write-wins onto `SpawnInstance::title`. let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("security", "t1", None)) + .apply_event(&subagent_dispatched("security", "t1")) .unwrap(); // Default title is empty before any `WorkTitle` lands. assert!(tracker.get(id).unwrap().title.is_empty()); @@ -1010,7 +1043,7 @@ mod tests { fn set_chat_position_stamps_renderer_hint_on_instance() { let mut tracker = SpawnLifecycleTracker::new("host"); let id = tracker - .apply_event(&turn_dispatched("backend", "t1", None)) + .apply_event(&subagent_dispatched("backend", "t1")) .unwrap(); // Default chat_position is 0 — meaningless until the renderer // stamps it. diff --git a/src/working_card.rs b/src/working_card.rs index 6345352..25ced24 100644 --- a/src/working_card.rs +++ b/src/working_card.rs @@ -481,7 +481,7 @@ mod tests { priors_hash: String::new(), turn_id: TurnId::from("t1".to_owned()), thread_id: TurnId::from("thread-t1".to_owned()), - parent_turn_id: None, + parent_turn_id: Some(TurnId::from("root".to_owned())), queue_position: 0, }) .expect("dispatch yields a spawn id"); diff --git a/tests/spawn_lifecycle_test.rs b/tests/spawn_lifecycle_test.rs index 3bb00a3..1c45850 100644 --- a/tests/spawn_lifecycle_test.rs +++ b/tests/spawn_lifecycle_test.rs @@ -50,6 +50,10 @@ fn turn_dispatched(role: &str, turn: &str, parent: Option<&str>) -> CrepEvent { } } +fn subagent_dispatched(role: &str, turn: &str) -> CrepEvent { + turn_dispatched(role, turn, Some("root")) +} + fn tool_proposed(turn: &str, tool: &str, tool_use_id: &str) -> CrepEvent { CrepEvent::ToolCallProposed { role: String::new(), @@ -127,7 +131,7 @@ fn room_runtime_apply_event_drives_lifecycle_through_clean_path() { // surface: TurnDispatched → ToolCallProposed → ToolCallExecuted // → RoleSpoke (Done) → RoleSpoke (Reported). let mut state = make_state(); - state.apply_event(crep_event(turn_dispatched("backend", "t1", None))); + state.apply_event(crep_event(subagent_dispatched("backend", "t1"))); let tracker = state.spawn_lifecycle(); let spawn = tracker.instances().next().expect("instance exists"); assert_eq!(spawn.state, SpawnState::Spawning); @@ -161,7 +165,7 @@ fn room_runtime_apply_event_drives_lifecycle_through_interrupt_path() { // Outcome::Interrupted. There is no `Failed` lifecycle state // (per ADR §3 in `docs/v0.10-chat-stream-vs-dashboard.md`). let mut state = make_state(); - state.apply_event(crep_event(turn_dispatched("security", "t1", None))); + state.apply_event(crep_event(subagent_dispatched("security", "t1"))); state.apply_event(crep_event(tool_proposed("t1", "Read", "u1"))); state.apply_event(crep_event(turn_interrupted("t1"))); @@ -180,8 +184,8 @@ fn room_runtime_apply_event_routes_concurrent_spawns_by_turn_id() { // same role result in two independent SpawnInstances, each with // its own tool-call stream. let mut state = make_state(); - state.apply_event(crep_event(turn_dispatched("worker", "t1", None))); - state.apply_event(crep_event(turn_dispatched("worker", "t2", None))); + state.apply_event(crep_event(subagent_dispatched("worker", "t1"))); + state.apply_event(crep_event(subagent_dispatched("worker", "t2"))); state.apply_event(crep_event(tool_proposed("t1", "Bash", "u1"))); state.apply_event(crep_event(tool_proposed("t2", "Read", "u2"))); @@ -210,8 +214,8 @@ fn room_runtime_working_spawn_instances_excludes_spawning() { // `Spawning` does not. The `working_spawn_instances` shortcut // on `RoomRuntimeState` must honor that filter. let mut state = make_state(); - state.apply_event(crep_event(turn_dispatched("backend", "t1", None))); - state.apply_event(crep_event(turn_dispatched("security", "t2", None))); + state.apply_event(crep_event(subagent_dispatched("backend", "t1"))); + state.apply_event(crep_event(subagent_dispatched("security", "t2"))); // Promote only the second to Working with a tool call. state.apply_event(crep_event(tool_proposed("t2", "Read", "u1"))); @@ -229,7 +233,7 @@ fn room_runtime_lifecycle_tolerates_unrelated_room_events() { use std::time::Instant; let mut state = make_state(); - state.apply_event(crep_event(turn_dispatched("backend", "t1", None))); + state.apply_event(crep_event(subagent_dispatched("backend", "t1"))); state.apply_event(RoomEvent::Spinner(SpinnerSnapshot { role: "backend".to_owned(), frame: 0, @@ -249,12 +253,13 @@ fn room_runtime_lifecycle_tolerates_unrelated_room_events() { #[test] fn parent_attribution_threads_through_dispatch_chain() { - // Verify the spawned_by attribution across a real dispatch - // chain: @host (root) → @backend (root) → @security (child of - // backend). Tests that the `parent_turn_id` lookup resolves the - // parent's role correctly when it itself is a tracked spawn. + // Verify the spawned_by attribution across a real dispatch chain: + // @host (public root) → @backend (child) → @security (child of + // backend). Public roots are remembered for attribution but are + // not themselves tracked as spawn instances. let mut state = make_state(); - state.apply_event(crep_event(turn_dispatched("backend", "t1", None))); + state.apply_event(crep_event(turn_dispatched("host", "root", None))); + state.apply_event(crep_event(turn_dispatched("backend", "t1", Some("root")))); state.apply_event(crep_event(turn_dispatched("security", "t2", Some("t1")))); let instances: Vec<&SpawnInstance> = state.spawn_lifecycle().instances().collect(); @@ -267,7 +272,6 @@ fn parent_attribution_threads_through_dispatch_chain() { .iter() .find(|spawn| spawn.role == "backend") .unwrap(); - // Root spawn is attributed to the host role. assert_eq!(backend.spawned_by, "host"); }