diff --git a/Cargo.lock b/Cargo.lock index d861b680a..676608dcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -394,7 +394,7 @@ dependencies = [ [[package]] name = "ccteam-cli" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "async-trait", @@ -427,7 +427,7 @@ dependencies = [ [[package]] name = "ccteam-core" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "async-trait", @@ -455,7 +455,7 @@ dependencies = [ [[package]] name = "ccteam-cost" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "serde", @@ -468,7 +468,7 @@ dependencies = [ [[package]] name = "ccteam-flow" -version = "0.12.0" +version = "0.13.0" dependencies = [ "async-trait", "ccteam-harness", @@ -485,7 +485,7 @@ dependencies = [ [[package]] name = "ccteam-harness" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "async-trait", @@ -523,7 +523,7 @@ dependencies = [ [[package]] name = "ccteam-hooks" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "ccteam-core", @@ -538,7 +538,7 @@ dependencies = [ [[package]] name = "ccteam-im" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "async-trait", @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "ccteam-web" -version = "0.12.0" +version = "0.13.0" dependencies = [ "anyhow", "askama", diff --git a/Cargo.toml b/Cargo.toml index aaaec8e8b..51bfb1b98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.12.0" +version = "0.13.0" edition = "2021" rust-version = "1.85" license = "MIT" diff --git a/README.md b/README.md index 657120cef..394b4d0e8 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ The installer runs the daemon; `ccteam status` reprints your link (`http:// - six formation playbooks (commander & crews, driver & advisor, cross review, bake-off, research triangulation, cost pyramid) that prefill the launcher with a vendor lineup -- a Chat tab per session (plus a byte-faithful terminal where applicable), including a clock on the composer to queue delayed user turns above the input +- a Chat tab per session (plus a byte-faithful terminal where applicable), including a clock on the composer to queue delayed user turns above the input; a turn's folded activity row opens into its steps, and each step into its full thinking text or tool input and output +- a context sidebar per session: what its context window is made of — system prompt, tools, each memory file by path, skills, MCP tools, agents, the conversation split by tool and attachment, last-request cache use — taken from the harness's own accounting, refreshed when a turn ends (Claude sessions) - a Team page: the live delegation topology — vendor, the model and reasoning effort each session is actually running, cost, every row a real link so a parent and its delegate open side by side — plus a division-of-labor charter (the per-project `routing.md` agents read via `status`) edited in place, and a Runs tab listing every ccteam Flow run with the sessions it hired - a DSH page that opens DeepSeek Harness Web inside ccteam: the daemon authenticates the request, starts or attaches the right local DSH web instance, and gives each logged-in user a separate DSH home - a cost pill with daily budget caps @@ -232,7 +233,7 @@ Every spawn surface takes `model` and `effort` for every vendor and forwards bot ccteam adds a team to your repo without taking it over: - **Roleless by default** — the brain reads *your* `CLAUDE.md` / `AGENTS.md` through the vendor's own mechanism; ccteam never rewrites project knowledge. -- **Small footprint** — exactly `.ccteam/` (state), `.claude/agents/` (personas you install), and ccteam's own section of `.claude/settings.local.json` — never your `settings.json`. +- **Small footprint** — exactly `.ccteam/` (state), `.claude/agents/` (personas you install), and in `.claude/settings.local.json` only the keys you ask for (a hub plugin you enable, an `/effort` you pin) — never your `settings.json`, and no hooks (unless you pick the frozen terminal protocol, which installs its own). - **Durable sessions** — ids (`s1`, `s2`, …) survive daemon restarts and cold-resume from disk; state is plain files in your repo. A session you are not talking to releases its harness process after an idle window (default one hour, matching the prompt-cache TTL) and comes straight back on your next message, on the same id and the same conversation — so a fleet of thirty sessions costs thirty transcripts, not thirty resident processes. One session, one process: a restart never kills an agent mid-turn and never starts a second one beside it — the daemon lets the process finish, queues what you send it meanwhile, recovers the answer it gave from the vendor's own record, and resumes the session by id. ## Extras @@ -267,7 +268,7 @@ curl -sSL https://raw.githubusercontent.com/firstintent/ccteam/main/install.sh | rm -rf ~/.ccteam # state, secrets, hub cache — keep it if you may return ``` -Per project, delete `.ccteam/` and ccteam's section of `.claude/settings.local.json`. +Per project, delete `.ccteam/` — or, before uninstalling, run `ccteam project rm --purge `, which also strips any hook entries or plugin pin an older ccteam left in `.claude/settings.local.json`. ## Support diff --git a/crates/ccteam-cli/src/commands.rs b/crates/ccteam-cli/src/commands.rs index 70ae81737..ce1791a91 100644 --- a/crates/ccteam-cli/src/commands.rs +++ b/crates/ccteam-cli/src/commands.rs @@ -211,6 +211,12 @@ pub fn run_init(paths: &CcteamPaths, opts: InitOptions) -> Result { target.join(".claude").join("agents").display(), project_report.agents_action, )); + if let Some(note) = &project_report.hooks_note { + out.push_str(&format!( + " claude settings {} ({note})\n", + target.join(".claude").join("settings.local.json").display(), + )); + } out.push_str(&format!( " config.yaml {} (upserted)\n", ccteam_core::ccteam_config_path(&paths.root).display(), @@ -315,6 +321,10 @@ struct ProjectInstallReport { state_action: &'static str, workflow_action: &'static str, agents_action: &'static str, + /// What happened to old ccteam hook entries in + /// `.claude/settings.local.json` (docs-local issue #212); `None` when + /// there was nothing to report. + hooks_note: Option, } impl ProjectInstallReport { @@ -426,6 +436,19 @@ fn install_project_at( }; } + // docs-local issue #212 — re-running `ccteam init` is how an existing + // project sheds the Claude Code hooks earlier ccteam versions wrote (a + // fresh install already ran the same scrub inside + // `bootstrap_project_at_dir`, so here it only reports). Best-effort: a + // user file that is not valid JSON is left alone and noted, never fatal. + let hooks_note = match ccteam_core::remove_ccteam_hooks( + &target.join(".claude").join("settings.local.json"), + false, + ) { + Ok(report) => scrub_note(&report), + Err(err) => Some(format!("left untouched: {err:#}")), + }; + Ok(ProjectInstallReport { slug: slug.to_string(), team: final_team, @@ -434,9 +457,24 @@ fn install_project_at( state_action, workflow_action, agents_action, + hooks_note, }) } +/// One receipt note for an init-time [`ccteam_core::remove_ccteam_hooks`] run. +fn scrub_note(report: &ccteam_core::CcteamHookScrubReport) -> Option { + use ccteam_core::CcteamHookScrubAction as A; + match &report.action { + A::Removed { entries } | A::RemovedNowEmpty { entries } => Some(format!( + "stripped {entries} retired ccteam entr{}", + if *entries == 1 { "y" } else { "ies" } + )), + A::Unreadable { error } => Some(format!("left untouched: cannot read it — {error}")), + A::Unparseable { error } => Some(format!("left untouched: not valid JSON — {error}")), + A::NotFound | A::NoChangeNeeded | A::WouldRemove { .. } => None, + } +} + /// Normalize a raw `--owner` value (`ccteam init --owner`) into the /// `ProjectState.owner` convention. A value already containing `:` is taken /// verbatim (`user:u123`, `telegram:456`); a bare value is scoped to the @@ -1785,6 +1823,7 @@ pub fn run_web(opts: WebOptions) -> Result<()> { // Standalone `ccteam internal web` shares the home but is not the // daemon; publishing would point `daemon status` at this process. publish_endpoint: false, + bound: None, }; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -3664,16 +3703,16 @@ fn purge_imd_registry_for_slug( /// Helper for `run_remove --purge` — deletes exactly ccteam's own /// footprint inside `/` (or, under `--dry-run`, just records /// the planned step). A project's on-disk ccteam footprint is `.ccteam/` -/// (state.json + workflow.yaml) plus ccteam's hook section inside +/// (state.json + workflow.yaml) plus any ccteam hook entries an earlier +/// version (or a terminal-protocol session) left inside /// `.claude/settings.local.json`. (ccteam seeds NO role, so there is /// no ccteam-owned persona to purge.) /// /// **DELETE** (ccteam-managed only): /// - `/.ccteam/` — state.json + workflow.yaml live here (W2). -/// - ccteam's chat-progress + AskUserQuestion hook entries inside -/// `/.claude/settings.local.json` (surgically, via -/// [`ccteam_core::remove_chat_hooks`]). If stripping them leaves the -/// file an empty object, the now-vestigial file is deleted. +/// - ccteam's own hook entries inside `/.claude/settings.local.json` +/// (surgically, via [`ccteam_core::remove_ccteam_hooks`]). If stripping +/// them leaves the file an empty object, the now-vestigial file is deleted. /// /// **KEEP (never touched):** /// - ALL of `/.claude/agents/*.md` — every role file is a user @@ -3715,10 +3754,24 @@ fn purge_project_managed_paths( // removed surgically so any operator-authored keys/hooks survive. // If the strip empties the file, delete the vestigial object. let settings_local = project_dir.join(".claude").join("settings.local.json"); - let scrub = ccteam_core::remove_chat_hooks(&settings_local, dry_run)?; - use ccteam_core::ChatHookScrubAction as A; + let scrub = ccteam_core::remove_ccteam_hooks(&settings_local, dry_run)?; + use ccteam_core::CcteamHookScrubAction as A; match scrub.action { A::NotFound | A::NoChangeNeeded => {} + // Purge is explicit cleanup: refuse rather than silently skip a file + // it could not inspect. + A::Unreadable { error } => { + anyhow::bail!( + "cannot purge ccteam entries from {}: cannot read it ({error})", + settings_local.display() + ); + } + A::Unparseable { error } => { + anyhow::bail!( + "cannot purge ccteam entries from {}: not valid JSON ({error}); fix or delete it", + settings_local.display() + ); + } A::WouldRemove { entries } => { report.steps.push(format!( "would strip {entries} ccteam hook entr{} from .claude/settings.local.json ({})", @@ -3734,9 +3787,8 @@ fn purge_project_managed_paths( )); } A::RemovedNowEmpty { entries } => { - // The file existed only to carry ccteam's hooks — delete it. - std::fs::remove_file(&settings_local) - .with_context(|| format!("rm {}", settings_local.display()))?; + // The file existed only to carry ccteam's hooks — the scrub + // deleted it. report.steps.push(format!( "stripped {entries} ccteam hook entr{} and removed now-empty \ .claude/settings.local.json ({})", @@ -4638,13 +4690,10 @@ mod tests { !body.contains("mode: agent-team"), "default workflow.yaml must NOT declare agent-team mode", ); - // No F94 hooks in the default (managed) settings layer. v0.8.6 - // W2b — ccteam writes to settings.local.json, not settings.json. - let settings_body = - std::fs::read_to_string(target.join(".claude").join("settings.local.json")).unwrap(); + // docs-local issue #212 — init writes no Claude settings at all. assert!( - !settings_body.contains("TeammateIdle"), - "default settings.local.json must NOT include team_* hooks", + !target.join(".claude").join("settings.local.json").exists(), + "init must NOT create .claude/settings.local.json", ); } @@ -4693,6 +4742,98 @@ mod tests { ); } + /// docs-local issue #212 — re-running `ccteam init` on a project an + /// earlier version initialized strips the Claude Code hooks it wrote into + /// `.claude/settings.local.json`, keeping every other key; a file left + /// holding nothing is removed. + #[test] + fn run_init_refresh_strips_old_ccteam_hooks() { + ensure_isolation(); + let tmp = TempDir::new().unwrap(); + let paths = fresh_paths(&tmp); + let target = tmp.path().join("old-hooks"); + let opts = || InitOptions { + install_in: Some(target.clone()), + ..InitOptions::default() + }; + run_init(&paths, opts()).unwrap(); + let local = target.join(".claude").join("settings.local.json"); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + let hook_sh = paths.hooks_script(); + let hook_sh = hook_sh.display(); + std::fs::write( + &local, + format!( + r#"{{"env":{{"CCTEAM_HOME":"/x"}},"hooks":{{"Stop":[{{"hooks":[{{"type":"command","command":"{hook_sh} progress-append Stop","async":true}}]}}]}}}}"# + ), + ) + .unwrap(); + + run_init(&paths, opts()).unwrap(); + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&local).unwrap()).unwrap(); + assert_eq!(v, serde_json::json!({"env": {"CCTEAM_HOME": "/x"}})); + + // Only ccteam hooks left → the file goes away. + std::fs::write( + &local, + format!( + r#"{{"hooks":{{"Stop":[{{"hooks":[{{"type":"command","command":"{hook_sh} progress-append Stop"}}]}}]}}}}"# + ), + ) + .unwrap(); + run_init(&paths, opts()).unwrap(); + assert!(!local.exists(), "hooks-only settings.local.json removed"); + } + + /// docs-local issue #212 — an empty or invalid settings.local.json never + /// fails `ccteam init` (fresh or re-run); the file is left as it was and + /// the project is fully initialized. + #[test] + fn run_init_tolerates_unparseable_settings_local() { + ensure_isolation(); + for (name, body) in [("empty", ""), ("comma", "{\"model\": \"opus\",}")] { + let tmp = TempDir::new().unwrap(); + let paths = fresh_paths(&tmp); + let target = tmp.path().join(name); + let local = target.join(".claude").join("settings.local.json"); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + std::fs::write(&local, body).unwrap(); + let opts = || InitOptions { + install_in: Some(target.clone()), + ..InitOptions::default() + }; + run_init(&paths, opts()).unwrap_or_else(|e| panic!("{name}: fresh init: {e:#}")); + assert!(target.join(".ccteam").join("workflow.yaml").exists()); + run_init(&paths, opts()).unwrap_or_else(|e| panic!("{name}: re-init: {e:#}")); + assert_eq!(std::fs::read_to_string(&local).unwrap(), body, "{name}"); + } + } + + /// The init receipt names a read failure as such, not as invalid JSON. + #[test] + fn scrub_note_distinguishes_unreadable_from_unparseable() { + use ccteam_core::{CcteamHookScrubAction as A, CcteamHookScrubReport}; + let note = |action| { + scrub_note(&CcteamHookScrubReport { + target: "x".into(), + action, + }) + .unwrap() + }; + let read = note(A::Unreadable { + error: "Is a directory (os error 21)".into(), + }); + assert!( + read.contains("cannot read") && !read.contains("JSON"), + "{read}" + ); + let parse = note(A::Unparseable { + error: "EOF while parsing".into(), + }); + assert!(parse.contains("not valid JSON"), "{parse}"); + } + /// `--force` re-runs overwrite user files. workflow.yaml lives in /// `.ccteam/`, not the root. #[test] diff --git a/crates/ccteam-cli/src/daemon_cli.rs b/crates/ccteam-cli/src/daemon_cli.rs index 949abd4a1..a7f722d79 100644 --- a/crates/ccteam-cli/src/daemon_cli.rs +++ b/crates/ccteam-cli/src/daemon_cli.rs @@ -12,7 +12,7 @@ //! - without `--json` → human prose on stdout. //! - deterministic failures exit 1 after emitting the JSON/human error. -use std::io::{BufRead, Read, Seek, SeekFrom}; +use std::io::{Read, Seek, SeekFrom}; use std::path::PathBuf; use std::time::Duration; @@ -777,34 +777,58 @@ pub fn run_daemon_logs(lines: usize, follow: bool, json: bool) -> Result<()> { let mut offset = file.metadata().map(|m| m.len()).unwrap_or(0); loop { std::thread::sleep(Duration::from_millis(250)); - let len = match std::fs::metadata(&path) { - Ok(m) => m.len(), + let meta = match std::fs::metadata(&path) { + Ok(m) => m, Err(_) => continue, }; - if len < offset { - // Truncated/rotated externally — restart from the top. + let len = meta.len(); + if !same_file(&file, &meta) { + // A daemon start rotated the log (`daemon.log` → `daemon.log.1`) + // and the new daemon writes a fresh file at the path. Drain what + // the old daemon wrote since the last poll, then follow the path, + // not the inode we opened — or every line after the restart is + // missed (and the old file is replayed from its top). + print_appended(&mut file, offset)?; + file = + std::fs::File::open(&path).with_context(|| format!("open {}", path.display()))?; + offset = 0; + } else if len < offset { + // Truncated in place — restart from the top. offset = 0; } if len > offset { - file.seek(SeekFrom::Start(offset))?; - let mut buf = Vec::new(); - let reader = std::io::BufReader::new(&mut file); - for line in reader.lines() { - match line { - Ok(l) => buf.push(l), - Err(_) => break, - } - } - for l in &buf { - println!("{l}"); - } - offset = len; + offset = print_appended(&mut file, offset)?; } } } -/// Last `n` lines of a file, reading only a bounded tail window (the -/// daemon log is unrotated and can grow large). +/// Print everything in `file` past `offset`; returns the new end offset. +fn print_appended(file: &mut std::fs::File, offset: u64) -> Result { + file.seek(SeekFrom::Start(offset))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + for line in String::from_utf8_lossy(&bytes).lines() { + println!("{line}"); + } + Ok(offset + bytes.len() as u64) +} + +/// Does the open `file` still name the file whose metadata is `at_path`? +#[cfg(unix)] +fn same_file(file: &std::fs::File, at_path: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + file.metadata() + .map(|open| open.dev() == at_path.dev() && open.ino() == at_path.ino()) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn same_file(_file: &std::fs::File, _at_path: &std::fs::Metadata) -> bool { + true +} + +/// Last `n` lines of a file, reading only a bounded tail window (a log grows +/// up to the rotation bound plus one daemon lifetime). fn tail_lines(path: &std::path::Path, n: usize) -> Result> { const WINDOW: u64 = 1024 * 1024; let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?; diff --git a/crates/ccteam-cli/src/main.rs b/crates/ccteam-cli/src/main.rs index ca8a27ee3..d7d5965e5 100644 --- a/crates/ccteam-cli/src/main.rs +++ b/crates/ccteam-cli/src/main.rs @@ -1808,16 +1808,24 @@ fn run_start(web: StartWebOpts, imd: StartImdOpts) -> Result<()> { ))) }; + // `ccteam start` reports "started" once `mcp.sock` answers, and every + // MCP client then dials the TCP `/mcp` that `run/mcp-url` advertises. + // Both listeners come up in separate tasks, so the socket waits for + // the web listener to be bound and published (docs-local issue #214: + // a client right after `start` got "connection refused"). + let web_bound = (!web.disabled).then(|| std::sync::Arc::new(tokio::sync::Notify::new())); let web_handle = if web.disabled { None } else { - let opts = match parse_web_opts(&web) { + let mut opts = match parse_web_opts(&web) { Ok(o) => o, Err(err) => { signal_task.abort(); return Err(err); } }; + opts.bound = web_bound.clone(); + let web_exited = NotifyOnDrop(web_bound.clone()); let mut rx = shutdown_rx.clone(); let web_bridge = web_chat_bridge .as_ref() @@ -1848,6 +1856,16 @@ fn run_start(web: StartWebOpts, imd: StartImdOpts) -> Result<()> { let web_host_hub = host_hub.clone(); let web_dsh_runtime = std::sync::Arc::clone(&dsh_runtime); Some(tokio::spawn(async move { + // Whatever ends this task — return, error, panic or abort — a + // web that never bound must not hold mcp.sock closed forever: + // the daemon stays up without it, as before. + let _web_exited = web_exited; + // Hidden test hook: stretch the pre-bind window so the + // "started ⇒ /mcp accepts" contract is tested deterministically + // rather than only on a host whose `/proc` sweep is slow. + if let Some(delay) = env_ms(WEB_PREBIND_DELAY_ENV) { + tokio::time::sleep(delay).await; + } ccteam_web::serve_with_state_factory_and_shutdown( opts, move |paths, auth| { @@ -1919,6 +1937,9 @@ fn run_start(web: StartWebOpts, imd: StartImdOpts) -> Result<()> { // session tools then report "gateway not running". let mcp_gateway = shared_gateway.clone(); let mcp_handle = tokio::spawn(async move { + if let Some(bound) = web_bound { + bound.notified().await; + } serve_mcp_socket(paths, mcp_sink, mcp_pending, mcp_gateway, async move { let _ = rx.changed().await; }) @@ -1965,9 +1986,12 @@ fn run_start(web: StartWebOpts, imd: StartImdOpts) -> Result<()> { _ = rx.changed() => break, maybe = sink.recv() => { let Some(event) = maybe else { break }; - ccteam_harness::execution::typed_events::enrich_session_from_hook(&event); let dispatch_paths = dispatch_paths.clone(); + // All per-event work runs in the blocking task, so a + // panic in it costs this event (the `Err(je)` arm), not + // the only hook-sink loop. let res = tokio::task::spawn_blocking(move || { + ccteam_harness::execution::typed_events::enrich_session_from_hook(&event); let stdin: serde_json::Value = serde_json::from_str(&event.payload_json) .unwrap_or(serde_json::Value::Null); @@ -1985,9 +2009,9 @@ fn run_start(web: StartWebOpts, imd: StartImdOpts) -> Result<()> { error = %err, "W6 hook-sink: dispatch failed; event dropped" ), - Err(je) => tracing::warn!( + Err(je) => tracing::error!( ?je, - "W6 hook-sink: dispatch task panicked" + "W6 hook-sink: dispatch task panicked; event dropped" ), } } @@ -2320,6 +2344,29 @@ fn read_hostname() -> Option { None } +/// Hidden test hook (integration tests only): delay before the daemon's web +/// listener binds, in milliseconds. +const WEB_PREBIND_DELAY_ENV: &str = "CCTEAM_TEST_WEB_PREBIND_DELAY_MS"; + +fn env_ms(key: &str) -> Option { + std::env::var(key) + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(Duration::from_millis) +} + +/// Fires its gate when dropped, so every exit path of the task that owns it +/// (unwinding included) releases whoever waits on the gate. +struct NotifyOnDrop(Option>); + +impl Drop for NotifyOnDrop { + fn drop(&mut self) { + if let Some(gate) = &self.0 { + gate.notify_one(); + } + } +} + fn parse_web_opts(web: &StartWebOpts) -> Result { let bind: std::net::SocketAddr = web .bind @@ -2336,6 +2383,8 @@ fn parse_web_opts(web: &StartWebOpts) -> Result { // This IS the daemon body (`internal daemon-run`), so it owns the // endpoint publication; `ccteam internal web` deliberately does not. publish_endpoint: true, + // Set by the caller, which owns the `mcp.sock` it gates. + bound: None, }) } diff --git a/crates/ccteam-cli/src/web_chat_bridge.rs b/crates/ccteam-cli/src/web_chat_bridge.rs index eead56262..e1e151d62 100644 --- a/crates/ccteam-cli/src/web_chat_bridge.rs +++ b/crates/ccteam-cli/src/web_chat_bridge.rs @@ -679,8 +679,14 @@ mod tests { fn write_queued_web_outbound(ccteam_home: &Path, content: &str) { let path = ccteam_home.join("state").join("im").join("outbound.jsonl"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + // A current timestamp: replay gives up on a row queued past its age + // bound (issue #213). + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; let row = serde_json::json!({ - "ts_ms": 1_u64, + "ts_ms": now_ms, "id": "web-replay-1", "inbound_id": "web-replay", "channel": "web", diff --git a/crates/ccteam-cli/tests/daemon_identity_test.rs b/crates/ccteam-cli/tests/daemon_identity_test.rs index 4f7da2f28..4552be647 100644 --- a/crates/ccteam-cli/tests/daemon_identity_test.rs +++ b/crates/ccteam-cli/tests/daemon_identity_test.rs @@ -37,15 +37,18 @@ impl Sandbox { } } - fn run(&self, args: &[&str]) -> Output { - Command::new(ccteam_bin()) - .args(args) + fn command(&self, args: &[&str]) -> Command { + let mut cmd = Command::new(ccteam_bin()); + cmd.args(args) .env("HOME", &self.home) .env("CCTEAM_HOME", &self.ccteam_home) .env("CCTEAM_PROJECTS_ROOT", self.home.join("projects")) - .env("RUST_LOG", "warn") - .output() - .expect("run ccteam") + .env("RUST_LOG", "warn"); + cmd + } + + fn run(&self, args: &[&str]) -> Output { + self.command(args).output().expect("run ccteam") } fn json(&self, args: &[&str]) -> serde_json::Value { @@ -62,6 +65,15 @@ impl Sandbox { } } +impl Drop for Sandbox { + fn drop(&mut self) { + // A failing assertion must not leak a daemon into the next test. + // Every test here starts one, and stopping a stopped (or never + // started) daemon is a harmless `notRunning`. + let _ = self.run(&["daemon", "stop", "--json"]); + } +} + /// `GET http:///health`, parsed. Hand-rolled over std so the test /// needs no HTTP client: one loopback request, one JSON body. fn http_get_json(addr: &str) -> serde_json::Value { @@ -312,3 +324,50 @@ fn start_is_the_launcher_and_returns_without_leaving_a_foreground_process() { let after = sb.json(&["daemon", "status", "--json"]); assert_eq!(after["status"], "down", "{after}"); } + +#[test] +#[cfg(unix)] +fn started_means_the_advertised_mcp_endpoint_already_accepts() { + // issue #214 — readiness is probed on `mcp.sock`, but MCP clients dial + // the TCP `/mcp` that `run/mcp-url` advertises. The web listener binds + // after pre-bind work (a `/proc` sweep), so the daemon once reported + // "started" while that port still refused. The hook stretches the + // pre-bind window so the race is lost every time unless the daemon + // holds its readiness socket until the web listener is bound. + let sb = Sandbox::new(); + // A real port, not `:0`: the connect below must not first ask the + // daemon where it listens (that answer would itself wait for the bind). + let port = std::net::TcpListener::bind("127.0.0.1:0") + .and_then(|l| l.local_addr()) + .expect("reserve a port") + .port(); + let bind = format!("127.0.0.1:{port}"); + let out = sb + .command(&[ + "start", + "--json", + "--web-bind", + &bind, + "--dsh-web-bind", + "off", + "--no-imd", + "--no-clipboard", + ]) + .env("CCTEAM_TEST_WEB_PREBIND_DELAY_MS", "1500") + .output() + .expect("run ccteam start"); + let started: serde_json::Value = + serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()).unwrap_or_else(|err| { + panic!( + "start printed no JSON ({err}): {}", + String::from_utf8_lossy(&out.stderr) + ) + }); + assert_eq!(started["status"], "started", "{started}"); + + std::net::TcpStream::connect(("127.0.0.1", port)) + .expect("the advertised /mcp accepts the moment start reports started"); + let recorded = std::fs::read_to_string(sb.ccteam_home.join("run/mcp-url")) + .expect("daemon publishes run/mcp-url"); + assert_eq!(recorded.trim(), format!("http://{bind}/mcp"), "{recorded}"); +} diff --git a/crates/ccteam-cli/tests/flow_run_test.rs b/crates/ccteam-cli/tests/flow_run_test.rs index ff9f326d3..eb395c207 100644 --- a/crates/ccteam-cli/tests/flow_run_test.rs +++ b/crates/ccteam-cli/tests/flow_run_test.rs @@ -263,6 +263,11 @@ impl Sandbox { started["status"] == "started" || started["status"] == "alreadyRunning", "daemon did not start: {started}", ); + // issue #214 regression: "started" means the advertised `/mcp` is + // already accepting — no retry, no sleep. The daemon once answered + // its readiness socket before the web listener was bound. + std::net::TcpStream::connect(("127.0.0.1", self.port)) + .expect("the web /mcp listener accepts as soon as start returns"); // issue #7 regression: the daemon must re-record run/mcp-url from // the address it BOUND, not the requested bind — MCP clients // (including `ccteam flow run`) resolve the endpoint from this file. diff --git a/crates/ccteam-core/src/daemon.rs b/crates/ccteam-core/src/daemon.rs index d272db16b..741cc463c 100644 --- a/crates/ccteam-core/src/daemon.rs +++ b/crates/ccteam-core/src/daemon.rs @@ -96,6 +96,28 @@ pub const STOP_KILL_WAIT: Duration = Duration::from_secs(5); /// How much of the daemon log tail to surface on a failed start. pub const DAEMON_LOG_TAIL_BYTES: u64 = 4096; +/// A daemon log past this size is moved to `daemon.log.1` (replacing the +/// previous one) before the next daemon starts appending. The detached +/// daemon's stdout/stderr ARE the log file, so it cannot be cut while that +/// daemon runs; each start is the safe point, and two generations bound the +/// disk it can take to ~2x this plus one daemon lifetime (issue #213: 20 MB +/// and growing with no rotation at all). +pub const DAEMON_LOG_ROTATE_BYTES: u64 = 16 * 1024 * 1024; + +/// Move an oversized daemon log aside (see [`DAEMON_LOG_ROTATE_BYTES`]). +/// Best-effort: a failed rename just keeps appending to the old file. +fn rotate_daemon_log(log_path: &Path, max_bytes: u64) { + let Ok(meta) = std::fs::metadata(log_path) else { + return; + }; + if meta.len() <= max_bytes { + return; + } + let mut rotated = log_path.as_os_str().to_owned(); + rotated.push(".1"); + let _ = std::fs::rename(log_path, PathBuf::from(rotated)); +} + /// Resolve the pid-record path for a given ccteam root. pub fn pidfile_path(paths: &CcteamPaths) -> PathBuf { paths.root.join("state").join(PIDFILE_NAME) @@ -762,6 +784,7 @@ pub fn start_managed(paths: &CcteamPaths, spec: &DaemonStartSpec) -> Result std::result::Result<( mod tests { use super::*; + /// Issue #213: an oversized log is moved to `.1` (replacing the old one) + /// before a start appends to it; a small one is left alone. + #[test] + fn an_oversized_daemon_log_is_rotated_before_a_start() { + let tmp = tempfile::TempDir::new().unwrap(); + let log = tmp.path().join(DAEMON_LOG_NAME); + let rotated = tmp.path().join("daemon.log.1"); + std::fs::write(&rotated, "older").unwrap(); + std::fs::write(&log, "small").unwrap(); + rotate_daemon_log(&log, 10); + assert_eq!(std::fs::read_to_string(&log).unwrap(), "small"); + std::fs::write(&log, "a log past its bound").unwrap(); + rotate_daemon_log(&log, 10); + assert!(!log.exists()); + assert_eq!( + std::fs::read_to_string(&rotated).unwrap(), + "a log past its bound" + ); + rotate_daemon_log(&log, 10); // nothing to rotate: no-op + } + fn paths(tmp: &tempfile::TempDir) -> CcteamPaths { CcteamPaths { root: tmp.path().join("home"), diff --git a/crates/ccteam-core/src/hooks_dispatcher/mod.rs b/crates/ccteam-core/src/hooks_dispatcher/mod.rs index 21a638670..5a7af0d31 100644 --- a/crates/ccteam-core/src/hooks_dispatcher/mod.rs +++ b/crates/ccteam-core/src/hooks_dispatcher/mod.rs @@ -42,7 +42,7 @@ pub const HOOK_VIA_DAEMON_ENV: &str = "CCTEAM_HOOK_VIA_DAEMON"; /// /// Read at two points that MUST agree: the install-time branch in /// `ensure_chat_hooks_installed` (which hook command string to write -/// into `.claude/settings.json`) and the sink-bind decision in +/// into `.claude/settings.local.json`) and the sink-bind decision in /// `ccteam start` (whether to bind the `hook.sock` listener). Sharing /// this helper guarantees they never drift. /// diff --git a/crates/ccteam-core/src/lib.rs b/crates/ccteam-core/src/lib.rs index 65b80268e..4db21e65d 100644 --- a/crates/ccteam-core/src/lib.rs +++ b/crates/ccteam-core/src/lib.rs @@ -322,12 +322,10 @@ pub use team_resolver::{ }; pub use templates::{ apply_probe_defaults_to_workflow_ctx, current_ccteam_bin, default_workflow_ctx, - merge_named_mcp_server, probe_project, render_project_settings, render_workflow_agents_block, - render_workflow_template, resolve_spawnable_exe, validate_mcp_server_name, - write_global_helper_templates, write_project_settings, write_project_settings_in, - EnabledPluginsSetting, Language, ProjectKind, ProjectProbe, SettingsEnv, WorkflowAgentEntry, - WorkflowPreset, WorkflowTemplateCtx, WorkflowTemplateRenderError, CCTEAM_MCP_SERVER_KEY, - HELPER_TEMPLATES, PROJECT_SETTINGS_JSON, + merge_named_mcp_server, probe_project, render_workflow_agents_block, render_workflow_template, + resolve_spawnable_exe, validate_mcp_server_name, write_global_helper_templates, Language, + ProjectKind, ProjectProbe, WorkflowAgentEntry, WorkflowPreset, WorkflowTemplateCtx, + WorkflowTemplateRenderError, CCTEAM_MCP_SERVER_KEY, HELPER_TEMPLATES, }; pub use tmux::{ capture_pane_tail, capture_pane_tail_from_session, capture_pane_with_ansi, @@ -338,10 +336,9 @@ pub use tmux::{ pub use tool_surface::{ disable_tool_surface_bootstrap_for_tests, ensure_skills_placeholders, migrate_legacy_skill_dirs, migrate_recommended_agent_symlinks, missing_tools, - remove_chat_hooks, remove_cost_accumulate_hooks, rewrite_legacy_hook_commands, user_claude_dir, - ChatHookScrubAction, ChatHookScrubReport, CostAccumulateScrubAction, CostAccumulateScrubReport, - HookCmdRewriteAction, HookCmdRewriteReport, LegacySkillAction, LegacySkillReport, - MigrationReport, MissingTool, ToolSurfaceSnapshot, ToolsRequired, BUILTIN_SUBAGENTS, + remove_ccteam_hooks, user_claude_dir, CcteamHookScrubAction, CcteamHookScrubReport, + LegacySkillAction, LegacySkillReport, MigrationReport, MissingTool, ToolSurfaceSnapshot, + ToolsRequired, BUILTIN_SUBAGENTS, }; pub use transcript_scanner::{resolve_jsonl_path, session_cost_from_jsonl}; pub use vendor::AgentVendor; diff --git a/crates/ccteam-core/src/marketplace_plugin.rs b/crates/ccteam-core/src/marketplace_plugin.rs index 1dc0fd8ed..4d2034d25 100644 --- a/crates/ccteam-core/src/marketplace_plugin.rs +++ b/crates/ccteam-core/src/marketplace_plugin.rs @@ -18,9 +18,8 @@ //! equal the marketplace's declared `name`. //! - `enabledPlugins` — `"@": true` turns a plugin on. //! -//! ccteam writes both into the project's **`.claude/settings.local.json`** — -//! the layer it already owns (same file as the managed hooks + -//! `ensure_telegram_plugin_disabled`), so the user's committed +//! ccteam writes both into the project's **`.claude/settings.local.json`** +//! (gitignored local layer) — only when the user enables a plugin — so the user's committed //! `.claude/settings.json` is never touched. On the next launch in that //! project Claude Code sees the marketplace + the enabled plugin and does //! ALL of the fetch / native-dep / install work itself, sandboxed by the diff --git a/crates/ccteam-core/src/paths.rs b/crates/ccteam-core/src/paths.rs index 56bcd2b1b..a3fbbd249 100644 --- a/crates/ccteam-core/src/paths.rs +++ b/crates/ccteam-core/src/paths.rs @@ -192,15 +192,15 @@ impl CcteamPaths { } /// `~/.ccteam/hooks/` — V0.6.1 F139 directory holding the - /// `hook.sh` dispatcher (the single shell wrapper every Claude Code - /// hook command in a ccteam project invokes). Materialized by - /// `ccteam init` and `ccteam doctor --install-hooks`. + /// `hook.sh` dispatcher (the single shell wrapper the terminal-protocol + /// chat hooks invoke). Materialized by `ensure_ccteam_home`. pub fn hooks_dir(&self) -> PathBuf { self.root.join("hooks") } /// `~/.ccteam/hooks/hook.sh` — V0.6.1 F139 dispatcher script the - /// project-level `.claude/settings.json` hook commands point at. The + /// terminal-protocol chat hooks (`claude_tui::ensure_chat_hooks_installed`) + /// point at; project creation installs no hooks (docs-local issue #212). The /// script POSTs the Claude Code hook stdin to the long-running /// daemon's `/internal/hook/:kind[/:action]` route (fast path) and /// falls back to `ccteam internal hook ...` when the daemon is @@ -376,20 +376,13 @@ pub fn canonical_home_dirs() -> &'static [&'static str] { /// `paths.root` (`std::fs::create_dir_all` — a no-op when present). /// 2. [`install_hooks`](crate::hooks_dispatcher::install_hooks) /// materializes `~/.ccteam/hooks/hook.sh`, the dispatcher the -/// project-level `.claude/settings.local.json` SessionStart hook -/// commands point at. Re-running returns +/// terminal-protocol chat hooks point at. Re-running returns /// [`InstallHooksAction::Unchanged`](crate::hooks_dispatcher::InstallHooksAction::Unchanged). /// 3. Create `~/.ccteam/routing.md` with neutral starter text when absent. /// Existing user content is never overwritten. /// /// This is the single home-ensure every create/start path calls so the -/// home is never half-built. Historically only `ccteam init`, the -/// `chat_register` MCP tool, and `ccteam doctor --install-hooks` ran -/// `install_hooks`; the web `POST /projects` + IM/web chat create path -/// (`projects::bootstrap_project_at_dir`) wrote a project settings file -/// that *references* `hook.sh` without ever materializing it, yielding a -/// "hook.sh: not found" at the first SessionStart. Calling this from -/// every create/start path closes that gap. +/// home is never half-built. pub fn ensure_ccteam_home(paths: &CcteamPaths) -> Result<()> { for sub in canonical_home_dirs() { let dir = paths.root.join(sub); diff --git a/crates/ccteam-core/src/projects.rs b/crates/ccteam-core/src/projects.rs index 70856d15d..5f8e4f43c 100644 --- a/crates/ccteam-core/src/projects.rs +++ b/crates/ccteam-core/src/projects.rs @@ -9,7 +9,6 @@ use serde_json::{Map, Value}; use crate::paths::CcteamPaths; use crate::state::ProjectState; -use crate::templates::{write_project_settings_in, EnabledPluginsSetting}; /// Slugify a free-text project request: keep `[a-z0-9]`, collapse other /// runs to `-`, trim, lower-case, and cap at 40 chars. When the cap @@ -331,10 +330,7 @@ pub fn scaffold_workflow_yaml(target: &Path, force: bool) -> Result<()> { pub fn bootstrap_project_at_dir( // `paths` is the global `~/.ccteam/` root. It feeds the - // home-ensure below (`ensure_ccteam_home`) so the `hook.sh` - // dispatcher the project settings reference actually exists. (It no - // longer feeds the dead helper-template stamp removed in v0.8.6 - // review-fix #3.) + // home-ensure below (`ensure_ccteam_home`). paths: &CcteamPaths, target_dir: &Path, slug: &str, @@ -344,17 +340,11 @@ pub fn bootstrap_project_at_dir( _request: &str, team: &str, ) -> Result { - // Materialize the global `~/.ccteam/` home (canonical dirs + - // `hooks/hook.sh` dispatcher) BEFORE writing the project settings: - // `write_project_settings` lays down a `.claude/settings.local.json` - // whose SessionStart hook command points at - // `/hooks/hook.sh`. Historically only `ccteam init` / - // `chat_register` / `doctor --install-hooks` ran `install_hooks`, so - // the web `POST /projects` + IM/web chat create path that flows - // through here referenced a hook.sh that was never written → - // "hook.sh: not found" at first launch. Idempotent (cheap no-op when - // the home is already complete). - crate::ensure_ccteam_home(paths).context("ensure ~/.ccteam/ home before project settings")?; + // Materialize the global `~/.ccteam/` home (canonical dirs + the + // `hooks/hook.sh` dispatcher) so every create path — `ccteam init`, web + // `POST /projects`, IM/web chat create — leaves the same complete home. + // Idempotent (cheap no-op when the home is already complete). + crate::ensure_ccteam_home(paths).context("ensure ~/.ccteam/ home")?; let project_dir = target_dir.to_path_buf(); let ccteam_dir = project_dir.join(".ccteam"); @@ -364,20 +354,39 @@ pub fn bootstrap_project_at_dir( let state = ProjectState::initial_for_team(slug.to_string(), team.to_string()); state.save(&CcteamPaths::project_state_in(&project_dir))?; - // V0.4.0 F60: the phase-template-driven `enabledPlugins` resolver - // (`compute_enabled_plugins` + `load_phase_templates_for_bootstrap`) - // was deleted with the rest of the phase machinery. F66 will - // reintroduce the plugin enablement set computed from - // `workflow.yaml::agents..executor`. For now we lay down - // managed settings (`.claude/settings.local.json`) with an empty - // plugin set so the project bootstraps cleanly; user-authored agent - // prompts still resolve their plugin surface via the global - // `~/.claude/agents/` layer. - let enabled_plugins = EnabledPluginsSetting::default(); - - // The root is already in hand — render the hook path from IT, never from - // `$CCTEAM_HOME` (which may point at a different home entirely). - write_project_settings_in(paths, &project_dir, &enabled_plugins)?; + // docs-local issue #212 — ccteam writes NO Claude settings for a project. + // The `.claude/settings.local.json` hooks block it used to lay down only + // served the retired tmux/rmux terminal protocol (the default stream-json + // session is hookless), and creating that file for ccteam alone is + // exactly what the owner ruled out. A directory that already carries + // ccteam's old hooks (a re-bootstrap over an earlier project) gets them + // stripped instead; every other key and any user hook survive. + // Best-effort: a user file that is unreadable or not valid JSON (empty, + // trailing comma) is left untouched and never fails project creation. + let settings_local = project_dir.join(".claude").join("settings.local.json"); + match crate::tool_surface::remove_ccteam_hooks(&settings_local, false) { + Ok(report) => { + use crate::tool_surface::CcteamHookScrubAction as A; + match report.action { + A::Unreadable { error } => tracing::warn!( + path = %settings_local.display(), + %error, + "left .claude/settings.local.json untouched: cannot read it", + ), + A::Unparseable { error } => tracing::warn!( + path = %settings_local.display(), + %error, + "left .claude/settings.local.json untouched: not valid JSON", + ), + _ => {} + } + } + Err(err) => tracing::warn!( + path = %settings_local.display(), + error = %err, + "could not strip old ccteam hooks from .claude/settings.local.json", + ), + } // v0.8.6 (review-fix #3): no longer stamp `~/.ccteam/templates/`. // `HELPER_TEMPLATES` has been empty since V0.5.0 F101, so the writer // only ever created an empty `templates/` dir that is *not* in @@ -1182,65 +1191,119 @@ mod tests { ); } + /// docs-local issue #212 — creating a project writes no Claude settings: + /// no `.claude/settings.local.json` (the hooks it carried only served the + /// retired tmux/rmux protocol), so a fresh project has no `.claude/` at all. #[test] - fn bootstrap_project_settings_routes_through_hook_sh() { - // V0.6.1 F139: fresh managed settings render route hook commands - // through `~/.ccteam/hooks/hook.sh` (the daemon-aware wrapper) - // instead of cold-spawning `ccteam internal hook ...`. - // - // v0.8.6: managed settings land in `.claude/settings.local.json` - // (NOT the user-committed `settings.json`) so ccteam never - // dirties the project's checked-in settings. + fn bootstrap_project_writes_no_claude_settings() { + ensure_isolation(); let tmp = tempfile::TempDir::new().unwrap(); let paths = CcteamPaths { root: tmp.path().join("home"), projects_root: tmp.path().join("projects"), }; - // CLI-ENVTEST-1: no `CCTEAM_HOME` pin. `bootstrap_project` was already - // handed `paths`, and now renders the hook path from it — so this - // asserts the real contract (the hook belongs to the root the caller - // passed) instead of to whatever the environment happened to say. - let expected_hook = paths.hooks_script(); bootstrap_project(&paths, "demo", "demo request", "dev").unwrap(); - let settings = paths - .project_dir("demo") - .join(".claude/settings.local.json"); - let body = std::fs::read_to_string(&settings).unwrap(); - let v: Value = serde_json::from_str(&body).unwrap(); - let cmd = v["hooks"]["SessionStart"][0]["hooks"][0]["command"] - .as_str() - .unwrap(); + let claude_dir = paths.project_dir("demo").join(".claude"); assert!( - cmd.starts_with('/'), - "settings.local.json hook command must be an absolute path, got: {cmd}", + !claude_dir.join("settings.local.json").exists(), + "bootstrap must not create .claude/settings.local.json" ); assert!( - cmd.contains(expected_hook.to_str().unwrap()), - "settings.local.json hook should invoke {}, got: {cmd}", - expected_hook.display(), + !claude_dir.exists(), + "bootstrap must not create .claude/ for ccteam's sake" ); - assert!( - cmd.ends_with(" load-context"), - "settings.local.json SessionStart[0] should pass `load-context`, got: {cmd}", + } + + /// docs-local issue #212 — re-bootstrapping a directory that still carries + /// the hooks an earlier ccteam wrote strips exactly those: user keys, a + /// user hook in the same event section and `settings.json` survive. + #[test] + fn bootstrap_project_at_dir_strips_old_ccteam_hooks() { + ensure_isolation(); + let tmp = tempfile::TempDir::new().unwrap(); + let paths = CcteamPaths { + root: tmp.path().join("home"), + projects_root: tmp.path().join("projects"), + }; + let project_dir = tmp.path().join("workspace").join("old-repo"); + let claude_dir = project_dir.join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + let hook_sh = paths.hooks_script(); + let hook_sh = hook_sh.display(); + let local = claude_dir.join("settings.local.json"); + std::fs::write( + &local, + format!( + r#"{{ + "model": "opus", + "hooks": {{ + "SessionStart": [{{"hooks": [ + {{"type": "command", "command": "{hook_sh} load-context", "timeout": 5}}, + {{"type": "command", "command": "{hook_sh} progress-append session_start", "async": true}} + ]}}], + "Stop": [ + {{"hooks": [{{"type": "command", "command": "{hook_sh} progress-append Stop", "async": true}}]}}, + {{"hooks": [{{"type": "command", "command": "/usr/bin/notify-send done"}}]}} + ] + }} + }}"# + ), + ) + .unwrap(); + let committed = claude_dir.join("settings.json"); + let committed_body = format!( + r#"{{"hooks":{{"Stop":[{{"hooks":[{{"type":"command","command":"{hook_sh} progress-append Stop"}}]}}]}}}}"# ); + std::fs::write(&committed, &committed_body).unwrap(); + + bootstrap_project_at_dir(&paths, &project_dir, "demo", "(re-create)", "dev").unwrap(); + + let v: Value = serde_json::from_str(&std::fs::read_to_string(&local).unwrap()).unwrap(); + assert_eq!(v["model"], "opus", "user key kept"); + let hooks = v["hooks"].as_object().unwrap(); assert!( - !cmd.contains("__CCTEAM_HOOK_SH__"), - "placeholder should be substituted, got: {cmd}", + !hooks.contains_key("SessionStart"), + "ccteam-only event pruned" ); - assert!( - !cmd.contains("__CCTEAM_BIN__"), - "retired F89 placeholder should not return, got: {cmd}", + assert_eq!( + v["hooks"]["Stop"], + serde_json::json!([{"hooks": [{"type": "command", "command": "/usr/bin/notify-send done"}]}]), + "user hook kept, ccteam hook gone" ); + assert_eq!( + std::fs::read_to_string(&committed).unwrap(), + committed_body, + "the user's .claude/settings.json is never touched" + ); + } + + /// docs-local issue #212 — an empty or invalid user settings.local.json + /// never fails project creation and is left exactly as it was. + #[test] + fn bootstrap_project_at_dir_tolerates_unparseable_settings_local() { + ensure_isolation(); + for (name, body) in [("empty", ""), ("comma", "{\"model\": \"opus\",}")] { + let tmp = tempfile::TempDir::new().unwrap(); + let paths = CcteamPaths { + root: tmp.path().join("home"), + projects_root: tmp.path().join("projects"), + }; + let project_dir = tmp.path().join("workspace").join(name); + let local = project_dir.join(".claude").join("settings.local.json"); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + std::fs::write(&local, body).unwrap(); + + bootstrap_project_at_dir(&paths, &project_dir, "demo", "(x)", "dev") + .unwrap_or_else(|e| panic!("{name}: bootstrap must not fail: {e:#}")); + assert!(CcteamPaths::project_state_in(&project_dir).exists()); + assert_eq!(std::fs::read_to_string(&local).unwrap(), body, "{name}"); + } } - /// Root-cause regression: a project created via the web / IM / - /// daemon path (which all flow through `bootstrap_project_at_dir`) - /// writes a `.claude/settings.local.json` SessionStart hook that - /// points at `/hooks/hook.sh` — but that dispatcher was - /// never materialized on this path, yielding "hook.sh: not found" at - /// first launch. `bootstrap_project_at_dir` now calls - /// `ensure_ccteam_home` up front, so hook.sh must land + be exec. + /// Every create path (web / IM / daemon all flow through + /// `bootstrap_project_at_dir`) leaves a complete ccteam home: + /// `ensure_ccteam_home` runs up front, so hook.sh lands + is exec. #[test] fn bootstrap_project_at_dir_materializes_hook_sh() { ensure_isolation(); @@ -1257,7 +1320,7 @@ mod tests { let hook_sh = paths.hooks_script(); assert!( hook_sh.exists(), - "bootstrap_project_at_dir must materialize {} (the dispatcher the project settings reference)", + "bootstrap_project_at_dir must materialize {}", hook_sh.display(), ); let body = std::fs::read_to_string(&hook_sh).unwrap(); diff --git a/crates/ccteam-core/src/templates/mod.rs b/crates/ccteam-core/src/templates/mod.rs index ebf6c226e..55eb064ea 100644 --- a/crates/ccteam-core/src/templates/mod.rs +++ b/crates/ccteam-core/src/templates/mod.rs @@ -6,12 +6,11 @@ //! the phase machinery; F66 reintroduces a much smaller bootstrap path //! against the new `workflow.yaml` schema. //! +//! There is no per-project Claude settings template: project bootstrap +//! writes nothing into `.claude/settings.local.json` (docs-local issue #212 — +//! the hooks it carried only served the retired tmux/rmux protocol). +//! //! What survives F60: -//! - the per-project settings template + render helper — produced-project -//! hook plumbing has not changed shape, so we still ship the same -//! scaffold; as of v0.8.6 W2b the render is written to the **local** -//! layer `.claude/settings.local.json` so it never dirties the user's -//! committed `.claude/settings.json`; //! - the helper-template writer (`write_global_helper_templates`) — //! helper markdown is referenced by user-authored agent prompts via //! `@~/.ccteam/templates/` and is not coupled to phases. @@ -19,7 +18,6 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; -use serde_json::Value; // Per-project `.mcp.json` merge helper for third-party servers (ccteam's own // entry is global-only + HTTP — see `crate::mcp_register`). @@ -59,28 +57,6 @@ pub use workflow_templates::{ pub mod project_probe; pub use project_probe::{probe as probe_project, Language, ProjectKind, ProjectProbe}; -/// Per-project ccteam settings template. -/// -/// v0.8.6 W2b: rendered output is written to the **local** settings layer -/// `/.claude/settings.local.json` (see [`write_project_settings`]) -/// so it never dirties the user's committed `.claude/settings.json`. The -/// `include_str!` source below is still the in-crate `settings.json` -/// scaffold file (template payload, not an output path). -/// -/// V0.6.1 F139: hook commands route through a single shell wrapper -/// (`__CCTEAM_HOOK_SH__`, materialized at `~/.ccteam/hooks/hook.sh` by -/// `ccteam init` / `ccteam doctor --install-hooks`). The wrapper POSTs -/// the hook stdin to the long-running ccteam daemon's -/// `/internal/hook/:kind[/:action]` route so per-hook latency drops from -/// ~200 ms (cold Rust binary spawn) to ~10 ms (curl round-trip). The -/// wrapper falls back to `ccteam internal hook ...` when the daemon is -/// down so the behaviour matches the pre-F139 path. Pre-F139 templates -/// substituted `__CCTEAM_BIN__` with the absolute `ccteam` binary path; -/// the `rewrite_legacy_hook_commands` rewriter (`tool_surface.rs`) + -/// `ccteam doctor --migrate-hook-commands` rewrite older renders into -/// the new shape. -pub const PROJECT_SETTINGS_JSON: &str = include_str!("settings.json"); - /// M2.4: helper templates that user-authored agent / workflow markdown /// can `@`-reference. Shipped inside the binary so a fresh install (or /// `ccteam doctor`) can stamp them into `~/.ccteam/templates/` without @@ -150,251 +126,6 @@ pub fn resolve_spawnable_exe(raw: &Path) -> Result { )) } -/// Extra `env` keys to inject into the rendered settings.json. Used to -/// freeze `CCTEAM_HOME` / `CCTEAM_PROJECTS_ROOT` per-project so hook -/// subprocesses see the same paths the orchestrator uses, regardless of -/// the tmux server's potentially stale environment block. -#[derive(Debug, Default, Clone)] -pub struct SettingsEnv { - pub ccteam_home: Option, - pub ccteam_projects_root: Option, -} - -/// Optional `enabledPlugins` map for the rendered settings.json. -/// -/// Each entry is the `@` key Claude Code's plugin -/// pipeline expects. -#[derive(Debug, Default, Clone)] -pub struct EnabledPluginsSetting { - pub plugin_ids: std::collections::BTreeSet, -} - -/// Render `PROJECT_SETTINGS_JSON` with `__CCTEAM_HOOK_SH__` replaced by -/// the given absolute path to the `ccteam` hook dispatcher -/// (`~/.ccteam/hooks/hook.sh` in a real install), `extra_env` merged -/// into the top-level `env` block, and `enabled` written under -/// `enabledPlugins`. Validates that the rewritten body is still valid -/// JSON so a path with shell-hostile characters can't silently corrupt -/// the settings file. -/// -/// V0.6.1 F139 swap: the V0.4.6 `__CCTEAM_BIN__` placeholder (substituted -/// with the absolute ccteam binary path) is retired. Hook commands now -/// invoke a thin shell wrapper that routes through the long-running -/// daemon's HTTP server (`POST /internal/hook/...`) with a CLI fallback, -/// shaving ~190 ms per hook firing. -pub fn render_project_settings( - hook_sh: &Path, - extra_env: &SettingsEnv, - enabled: &EnabledPluginsSetting, -) -> Result { - render_settings_template(PROJECT_SETTINGS_JSON, hook_sh, extra_env, enabled) -} - -/// Shared implementation for [`render_project_settings`]. -fn render_settings_template( - template: &str, - hook_sh: &Path, - extra_env: &SettingsEnv, - enabled: &EnabledPluginsSetting, -) -> Result { - let hook = hook_sh - .to_str() - .ok_or_else(|| anyhow!("ccteam hook.sh path not valid UTF-8: {}", hook_sh.display()))?; - if hook.contains('"') || hook.contains('\\') { - return Err(anyhow!( - "ccteam hook.sh path contains characters that can't be embedded in settings.json: {hook}" - )); - } - let body = template.replace("__CCTEAM_HOOK_SH__", hook); - let mut v: Value = serde_json::from_str(&body) - .with_context(|| format!("rendered settings.json is not valid JSON (hook_sh={hook})"))?; - if let Some(env) = v.get_mut("env").and_then(|e| e.as_object_mut()) { - if let Some(home) = &extra_env.ccteam_home { - env.insert("CCTEAM_HOME".into(), Value::String(home.clone())); - } - if let Some(proj) = &extra_env.ccteam_projects_root { - env.insert("CCTEAM_PROJECTS_ROOT".into(), Value::String(proj.clone())); - } - } - if !enabled.plugin_ids.is_empty() { - let mut map = serde_json::Map::new(); - for id in &enabled.plugin_ids { - map.insert(id.clone(), Value::Bool(true)); - } - v.as_object_mut() - .ok_or_else(|| anyhow!("settings.json root is not an object"))? - .insert("enabledPlugins".into(), Value::Object(map)); - } - serde_json::to_string_pretty(&v).context("serialize rendered settings.json") -} - -/// Write `/.claude/settings.local.json` with hook commands -/// pointing at the running ccteam binary by absolute path, the -/// effective `CCTEAM_HOME` / `CCTEAM_PROJECTS_ROOT` baked into `env`, -/// and the spawned-session `enabledPlugins` set. Creates the parent -/// dir if missing. Idempotent — re-running after a ccteam upgrade -/// refreshes the ccteam-managed keys (paths + plugin set) in place. -/// -/// v0.8.6 W2b — this targets the **local** settings layer -/// (`settings.local.json`), not the user-committed `settings.json`, so -/// the fresh-project ccteam base config never dirties the user's repo. -/// -/// v0.8.6 review-fix — re-init / in-place init **merges** the rendered -/// ccteam base config into any pre-existing `settings.local.json` -/// instead of clobbering it. ccteam *owns* (and overwrites) the -/// top-level `hooks` block and the specific nested keys it renders into -/// `env` / `permissions` / `enabledPlugins`; it *preserves* every -/// user-authored top-level key, plus any nested `env` / `permissions` / -/// `enabledPlugins` key ccteam does not render. See -/// [`merge_ccteam_settings`]. -pub fn write_project_settings(project_dir: &Path, enabled: &EnabledPluginsSetting) -> Result<()> { - write_settings_template( - project_dir, - enabled, - ProjectSettingsKind::ArtifactDriven, - effective_hook_sh_path()?, - ) -} - -/// [`write_project_settings`] for a caller that already holds the ccteam root. -/// -/// The hook dispatcher the rendered settings point at lives under -/// `paths.root`; resolving it from `CCTEAM_HOME` instead (what -/// [`effective_hook_sh_path`] does) means a caller that passed an explicit -/// `paths` — `bootstrap_project`, `ccteam init`, the web/IM create path — -/// could stamp a hook path belonging to a DIFFERENT home than the one it just -/// materialized. Taking the root as an argument is also what lets its test -/// stop `set_var`-ing `CCTEAM_HOME` under every sibling test in the binary -/// (CLI-ENVTEST-1). -pub fn write_project_settings_in( - paths: &crate::CcteamPaths, - project_dir: &Path, - enabled: &EnabledPluginsSetting, -) -> Result<()> { - write_settings_template( - project_dir, - enabled, - ProjectSettingsKind::ArtifactDriven, - paths.hooks_script(), - ) -} - -/// Discriminator for settings template writes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProjectSettingsKind { - ArtifactDriven, -} - -fn write_settings_template( - project_dir: &Path, - enabled: &EnabledPluginsSetting, - kind: ProjectSettingsKind, - hook_sh: std::path::PathBuf, -) -> Result<()> { - let dir = project_dir.join(".claude"); - std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; - // v0.8.6 W2b — ccteam writes its fresh-project base config to the - // **local** settings layer so the user's committed - // `.claude/settings.json` is never touched. - let path = dir.join("settings.local.json"); - let extra = SettingsEnv { - ccteam_home: std::env::var("CCTEAM_HOME").ok(), - ccteam_projects_root: std::env::var("CCTEAM_PROJECTS_ROOT").ok(), - }; - let body = match kind { - ProjectSettingsKind::ArtifactDriven => render_project_settings(&hook_sh, &extra, enabled)?, - }; - // v0.8.6 review-fix — merge the rendered ccteam base config into the - // existing settings.local.json (read + merge + write), mirroring - // `ensure_chat_hooks_installed`, so an in-place / re-init never - // destroys the user's own gitignored local settings. - let rendered: Value = serde_json::from_str(&body) - .with_context(|| format!("rendered settings.local.json is not valid JSON for {kind:?}"))?; - let mut root: Value = if path.exists() { - let existing = - std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - // A user file that is missing or corrupt JSON degrades to a fresh - // object rather than aborting the init (same policy as the hook - // installer); ccteam-managed keys are then written on top. - serde_json::from_str(&existing).unwrap_or_else(|_| Value::Object(Default::default())) - } else { - Value::Object(Default::default()) - }; - merge_ccteam_settings(&mut root, &rendered)?; - let merged = - serde_json::to_string_pretty(&root).context("serialize merged settings.local.json")?; - std::fs::write(&path, merged).with_context(|| format!("write {}", path.display()))?; - Ok(()) -} - -/// Top-level keys whose *nested* members ccteam renders into the -/// settings template. For these we deep-merge — ccteam overwrites only -/// the nested keys it produces (e.g. `env.CCTEAM_HOME`, -/// `permissions.allow`, a given `enabledPlugins` id) and leaves every -/// other nested key the user authored untouched. The top-level `hooks` -/// block is *not* listed here: ccteam owns it wholesale and replaces it -/// (matching `ensure_chat_hooks_installed`, which the chat path layers -/// on top afterwards). -const CCTEAM_DEEP_MERGE_KEYS: &[&str] = &["env", "permissions", "enabledPlugins"]; - -/// Merge the freshly-`rendered` ccteam settings into `root` (the user's -/// existing — possibly empty — `settings.local.json`) in place. -/// -/// ccteam **owns** (overwrites): the top-level `hooks` block, plus the -/// individual nested keys it renders under `env` / `permissions` / -/// `enabledPlugins`. ccteam **preserves**: every user-authored top-level -/// key not present in the template, and every nested `env` / -/// `permissions` / `enabledPlugins` key ccteam does not render. -fn merge_ccteam_settings(root: &mut Value, rendered: &Value) -> Result<()> { - let dst = root - .as_object_mut() - .ok_or_else(|| anyhow!("settings.local.json root is not a JSON object"))?; - let src = rendered - .as_object() - .ok_or_else(|| anyhow!("rendered ccteam settings root is not a JSON object"))?; - for (key, src_val) in src { - if CCTEAM_DEEP_MERGE_KEYS.contains(&key.as_str()) { - // Deep-merge: copy each rendered nested key over the user's, - // creating the parent object if the user had none. - if let Some(src_obj) = src_val.as_object() { - let dst_entry = dst - .entry(key.clone()) - .or_insert_with(|| Value::Object(Default::default())); - match dst_entry.as_object_mut() { - Some(dst_obj) => { - for (nested_key, nested_val) in src_obj { - dst_obj.insert(nested_key.clone(), nested_val.clone()); - } - } - // User wrote a non-object under this key — ccteam - // takes it over rather than silently dropping its - // managed nested keys. - None => { - dst.insert(key.clone(), src_val.clone()); - } - } - continue; - } - // Rendered value is unexpectedly not an object — overwrite. - dst.insert(key.clone(), src_val.clone()); - } else { - // ccteam owns this top-level key wholesale (e.g. `hooks`). - dst.insert(key.clone(), src_val.clone()); - } - } - Ok(()) -} - -/// V0.6.1 F139 — resolve the absolute path to `~/.ccteam/hooks/hook.sh` -/// honouring `CCTEAM_HOME` (test seam + ops override). Used by -/// [`write_project_settings`] so freshly-rendered settings.local.json -/// files point at the dispatcher actually materialized on disk by -/// `ccteam init` / `ccteam doctor --install-hooks`. -fn effective_hook_sh_path() -> Result { - let paths = crate::CcteamPaths::from_env()?; - Ok(paths.hooks_script()) -} - /// M2.4 / V0.5.0 F101: ensure `/templates/` exists + write /// any embedded payloads in [`HELPER_TEMPLATES`] so user-authored /// markdown's `@~/.ccteam/templates/` reference resolves. @@ -419,7 +150,7 @@ pub fn write_global_helper_templates(global_dir: &Path, force: bool) -> Result<( #[cfg(test)] mod tests { use super::*; - use std::path::{Path, PathBuf}; + use std::path::PathBuf; #[test] fn resolve_spawnable_exe_prefers_live_canonical_path() { @@ -451,246 +182,4 @@ mod tests { let deleted = PathBuf::from(format!("{}/gone (deleted)", dir.path().display())); assert!(resolve_spawnable_exe(&deleted).is_err()); } - - #[test] - fn template_is_valid_json_with_expected_hook_keys() { - let body = render_project_settings( - Path::new("/home/u/.ccteam/hooks/hook.sh"), - &SettingsEnv::default(), - &EnabledPluginsSetting::default(), - ) - .unwrap(); - let v: serde_json::Value = - serde_json::from_str(&body).expect("rendered template must be valid JSON"); - let hooks = v["hooks"].as_object().expect("hooks object"); - for required in [ - "SessionStart", - "Stop", - "Notification", - "PreToolUse", - "PostToolUse", - "SubagentStop", - "SessionEnd", - ] { - assert!( - hooks.contains_key(required), - "settings.json template missing `{required}` hook entry", - ); - } - } - - #[test] - fn template_session_start_routes_through_hook_sh() { - // V0.6.1 F139: hook commands now route through the - // `~/.ccteam/hooks/hook.sh` wrapper (HTTP-to-daemon + CLI - // fallback) instead of cold-spawning `ccteam internal hook ...` - // every firing. - let body = render_project_settings( - Path::new("/home/u/.ccteam/hooks/hook.sh"), - &SettingsEnv::default(), - &EnabledPluginsSetting::default(), - ) - .unwrap(); - let v: serde_json::Value = serde_json::from_str(&body).unwrap(); - let entries = v["hooks"]["SessionStart"][0]["hooks"].as_array().unwrap(); - let cmds: Vec<&str> = entries - .iter() - .map(|e| e["command"].as_str().unwrap()) - .collect(); - assert_eq!( - cmds, - vec![ - "/home/u/.ccteam/hooks/hook.sh load-context", - "/home/u/.ccteam/hooks/hook.sh progress-append session_start" - ], - ); - } - - #[test] - fn raw_template_uses_hook_sh_placeholder() { - assert!( - PROJECT_SETTINGS_JSON.contains("__CCTEAM_HOOK_SH__"), - "template should reference __CCTEAM_HOOK_SH__ placeholder (V0.6.1 F139)", - ); - // V0.4.6 F89 / V0.6.1 F139: legacy bare `ccteam hook` forms - // must not survive the migration. - assert!( - !PROJECT_SETTINGS_JSON.contains("\"ccteam hook"), - "template should not embed bare `ccteam hook`", - ); - assert!( - !PROJECT_SETTINGS_JSON.contains(" ccteam hook"), - "template must not embed bare `ccteam hook` form", - ); - assert!( - !PROJECT_SETTINGS_JSON.contains("__CCTEAM_BIN__"), - "V0.4.6 `__CCTEAM_BIN__` placeholder was retired by F139", - ); - // F44 sweep guard: F39's `{{CCT_BIN}}` placeholder must not return. - assert!( - !PROJECT_SETTINGS_JSON.contains("{{CCT_BIN}}"), - "template should not embed F39-era `{{CCT_BIN}}` placeholder (F44)", - ); - } - - #[test] - fn render_project_settings_rejects_quoted_path() { - let err = render_project_settings( - Path::new("/tmp/has\"quote/hook.sh"), - &SettingsEnv::default(), - &EnabledPluginsSetting::default(), - ) - .unwrap_err(); - assert!(format!("{err:#}").contains("characters")); - } - - #[test] - fn render_project_settings_injects_ccteam_env_when_provided() { - let env = SettingsEnv { - ccteam_home: Some("/tmp/sandbox/home".into()), - ccteam_projects_root: Some("/tmp/sandbox/projects".into()), - }; - let body = render_project_settings( - Path::new("/home/u/.ccteam/hooks/hook.sh"), - &env, - &EnabledPluginsSetting::default(), - ) - .unwrap(); - let v: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert_eq!(v["env"]["CCTEAM_HOME"], "/tmp/sandbox/home"); - assert_eq!(v["env"]["CCTEAM_PROJECTS_ROOT"], "/tmp/sandbox/projects"); - // Existing env keys must survive the merge. - assert_eq!(v["env"]["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"], "1"); - } - - #[test] - fn render_project_settings_omits_env_when_default() { - let body = render_project_settings( - Path::new("/home/u/.ccteam/hooks/hook.sh"), - &SettingsEnv::default(), - &EnabledPluginsSetting::default(), - ) - .unwrap(); - let v: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert!(v["env"]["CCTEAM_HOME"].is_null()); - } - - #[test] - fn render_project_settings_omits_enabled_plugins_when_empty() { - let body = render_project_settings( - Path::new("/home/u/.ccteam/hooks/hook.sh"), - &SettingsEnv::default(), - &EnabledPluginsSetting::default(), - ) - .unwrap(); - let v: serde_json::Value = serde_json::from_str(&body).unwrap(); - assert!(v.get("enabledPlugins").is_none()); - } - - #[test] - fn merge_ccteam_settings_preserves_user_keys() { - // Pre-seed an existing settings.local.json with user-authored - // top-level keys plus user-authored nested keys under the - // deep-merge keys ccteam touches. The merge must keep all of them - // while still stamping ccteam-managed config on top. - let existing = serde_json::json!({ - "env": { "USER_X": "1", "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "user-was-here" }, - "permissions": { "ask": ["Bash(rm:*)"] }, - "model": "opus", - "statusLine": { "type": "command", "command": "my-status" } - }); - let rendered = render_project_settings( - Path::new("/home/u/.ccteam/hooks/hook.sh"), - &SettingsEnv { - ccteam_home: Some("/tmp/sandbox/home".into()), - ccteam_projects_root: None, - }, - &EnabledPluginsSetting::default(), - ) - .unwrap(); - let rendered: serde_json::Value = serde_json::from_str(&rendered).unwrap(); - - let mut root = existing; - merge_ccteam_settings(&mut root, &rendered).unwrap(); - - // User-authored top-level keys ccteam does not own SURVIVE. - assert_eq!(root["model"], "opus"); - assert_eq!(root["statusLine"]["command"], "my-status"); - // User-authored nested env key SURVIVES. - assert_eq!(root["env"]["USER_X"], "1"); - // User-authored nested permissions key SURVIVES. - assert_eq!(root["permissions"]["ask"][0], "Bash(rm:*)"); - // ccteam-managed nested keys are present (and overwrite where they - // collide on a key ccteam owns). - assert_eq!(root["env"]["CCTEAM_HOME"], "/tmp/sandbox/home"); - assert_eq!( - root["env"]["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"], "1", - "ccteam owns this env key and overwrites the user's stale value", - ); - // ccteam widens permissions via the valid `defaultMode` (newer Claude - // Code rejects a bare `"*"` in an `allow` rule). ccteam-spawned sessions - // pass `--dangerously-skip-permissions` / `--permission-mode default` on - // the CLI (which override settings), so this only governs manual runs. - assert_eq!(root["permissions"]["defaultMode"], "bypassPermissions"); - // ccteam owns the hooks block wholesale. - assert!(root["hooks"]["SessionStart"].is_array()); - } - - #[test] - fn write_project_settings_merges_into_existing_local_file() { - // Full end-to-end: an in-place re-init over a project that already - // has a user-authored settings.local.json must NOT destroy it. - let tmp = tempfile::tempdir().unwrap(); - let project = tmp.path(); - let claude_dir = project.join(".claude"); - std::fs::create_dir_all(&claude_dir).unwrap(); - let settings_path = claude_dir.join("settings.local.json"); - std::fs::write( - &settings_path, - serde_json::to_string_pretty(&serde_json::json!({ - "env": { "USER_X": "1" }, - "permissions": { "ask": ["Bash(rm:*)"] }, - "model": "opus" - })) - .unwrap(), - ) - .unwrap(); - - write_project_settings(project, &EnabledPluginsSetting::default()) - .expect("in-place write must succeed"); - - let body = std::fs::read_to_string(&settings_path).unwrap(); - let v: serde_json::Value = serde_json::from_str(&body).unwrap(); - // User keys SURVIVE the re-init (top-level + nested). - assert_eq!(v["env"]["USER_X"], "1"); - assert_eq!(v["model"], "opus"); - assert_eq!(v["permissions"]["ask"][0], "Bash(rm:*)"); - // ccteam-managed keys are present. - assert_eq!(v["env"]["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"], "1"); - assert!(v["hooks"]["SessionStart"].is_array()); - } - - #[test] - fn render_project_settings_writes_enabled_plugins_when_present() { - let mut enabled = EnabledPluginsSetting::default(); - enabled - .plugin_ids - .insert("pr-review-toolkit@claude-plugins-official".into()); - enabled - .plugin_ids - .insert("feature-dev@claude-plugins-official".into()); - let body = render_project_settings( - Path::new("/home/u/.ccteam/hooks/hook.sh"), - &SettingsEnv::default(), - &enabled, - ) - .unwrap(); - let v: serde_json::Value = serde_json::from_str(&body).unwrap(); - let map = v["enabledPlugins"] - .as_object() - .expect("enabledPlugins object"); - assert_eq!(map.len(), 2); - assert_eq!(map["pr-review-toolkit@claude-plugins-official"], true); - assert_eq!(map["feature-dev@claude-plugins-official"], true); - } } diff --git a/crates/ccteam-core/src/templates/settings.json b/crates/ccteam-core/src/templates/settings.json deleted file mode 100644 index 04facaed2..000000000 --- a/crates/ccteam-core/src/templates/settings.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "permissions": { - "defaultMode": "bypassPermissions", - "deny": [] - }, - "env": { - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" - }, - "hooks": { - "SessionStart": [ - { - "hooks": [ - {"type": "command", "command": "__CCTEAM_HOOK_SH__ load-context", "timeout": 5}, - {"type": "command", "command": "__CCTEAM_HOOK_SH__ progress-append session_start", "async": true} - ] - } - ], - "Stop": [ - { - "hooks": [ - {"type": "command", "command": "__CCTEAM_HOOK_SH__ progress-append Stop", "async": true} - ] - } - ], - "Notification": [ - { - "matcher": "idle_prompt|permission_prompt", - "hooks": [ - {"type": "command", "command": "__CCTEAM_HOOK_SH__ progress-append notification", "async": true} - ] - } - ], - "PreToolUse": [ - { - "hooks": [ - {"type": "command", "command": "__CCTEAM_HOOK_SH__ progress-append PreToolUse", "async": true} - ] - }, - { - "matcher": "AskUserQuestion", - "hooks": [ - {"type": "command", "command": "__CCTEAM_HOOK_SH__ intercept-ask", "timeout": 5} - ] - } - ], - "PostToolUse": [ - { - "hooks": [ - {"type": "command", "command": "__CCTEAM_HOOK_SH__ progress-append PostToolUse", "async": true} - ] - } - ], - "SubagentStop": [ - { - "hooks": [ - {"type": "command", "command": "__CCTEAM_HOOK_SH__ progress-append SubagentStop", "async": true} - ] - } - ], - "SessionEnd": [ - { - "hooks": [ - {"type": "command", "command": "__CCTEAM_HOOK_SH__ progress-append SessionEnd", "async": true} - ] - } - ] - } -} diff --git a/crates/ccteam-core/src/tool_surface.rs b/crates/ccteam-core/src/tool_surface.rs index 0312adacc..a419b56be 100644 --- a/crates/ccteam-core/src/tool_surface.rs +++ b/crates/ccteam-core/src/tool_surface.rs @@ -493,316 +493,113 @@ pub fn migrate_legacy_skill_dirs( Ok(out) } -/// V0.2.2 F44: outcome of a single project's settings.local.json hook -/// command rewrite (legacy `… cct hook …` → ` hook …`). +/// Outcome of stripping ccteam's own Claude Code hooks from a project's +/// `.claude/settings.local.json`. See [`remove_ccteam_hooks`]. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum HookCmdRewriteAction { - /// File absent — nothing to do. - NotFound, - /// File present, no hook command referenced an F39-era `cct` - /// binary path. Idempotent re-run after a successful migration - /// hits this branch. - NoChangeNeeded, - /// `dry_run=true` — would have rewritten N entries. - WouldRewrite { entries: usize }, - /// Rewrote N hook commands and atomically wrote the file. - Rewrote { entries: usize }, -} - -/// V0.2.2 F44: report for one `~/projects//.claude/settings.local.json` -/// (v0.8.6 W2b — ccteam manages the local settings layer). -#[derive(Debug, Clone)] -pub struct HookCmdRewriteReport { - pub target: PathBuf, - pub action: HookCmdRewriteAction, -} - -/// V0.2.2 F44 + V0.4.6 F89 + V0.6.1 F139: rewrite legacy hook command -/// forms in a project's `.claude/settings.local.json` (v0.8.6 W2b — the -/// ccteam-managed local settings layer; the caller supplies the path) so -/// they invoke the current `~/.ccteam/hooks/hook.sh` wrapper. Idempotent -/// — re-runs after success hit `NoChangeNeeded`. -/// -/// Migration paths handled (oldest → newest): -/// - F39: `…/cct hook [args]` / bare `cct hook [args]` -/// - F89: `…/ccteam hook [args]` / bare `ccteam hook [args]` -/// - F89: `…/ccteam internal hook [args]` / bare `ccteam internal hook [args]` -/// -/// All of them collapse to ` [args]` — hook -/// subcommand + event-type args (`progress-append session_start`, -/// `chat-progress user-prompt`) are preserved verbatim so the dispatch -/// table inside `hook.sh` / the daemon route picks them up unchanged. -pub fn rewrite_legacy_hook_commands( - settings_path: &Path, - new_hook_sh: &Path, - dry_run: bool, -) -> Result { - if !settings_path.exists() { - return Ok(HookCmdRewriteReport { - target: settings_path.to_path_buf(), - action: HookCmdRewriteAction::NotFound, - }); - } - - let new_hook_str = new_hook_sh - .to_str() - .ok_or_else(|| anyhow!("hook.sh path not valid UTF-8: {}", new_hook_sh.display()))?; - if new_hook_str.contains('"') || new_hook_str.contains('\\') { - return Err(anyhow!( - "hook.sh path contains characters that can't be embedded in settings.json: {new_hook_str}" - )); - } - - let body = std::fs::read_to_string(settings_path) - .with_context(|| format!("read {}", settings_path.display()))?; - let mut v: serde_json::Value = serde_json::from_str(&body) - .with_context(|| format!("parse {}", settings_path.display()))?; - - let mut rewritten = 0usize; - let Some(hooks) = v.get_mut("hooks").and_then(|h| h.as_object_mut()) else { - return Ok(HookCmdRewriteReport { - target: settings_path.to_path_buf(), - action: HookCmdRewriteAction::NoChangeNeeded, - }); - }; - for (_event, list) in hooks.iter_mut() { - let Some(arr) = list.as_array_mut() else { - continue; - }; - for entry in arr.iter_mut() { - let Some(inner_hooks) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) else { - continue; - }; - for hook in inner_hooks.iter_mut() { - let Some(cmd_val) = hook.get_mut("command") else { - continue; - }; - let Some(cmd) = cmd_val.as_str() else { - continue; - }; - if let Some(new_cmd) = rewrite_one_hook_command(cmd, new_hook_str) { - *cmd_val = serde_json::Value::String(new_cmd); - rewritten += 1; - } - } - } - } - - if rewritten == 0 { - return Ok(HookCmdRewriteReport { - target: settings_path.to_path_buf(), - action: HookCmdRewriteAction::NoChangeNeeded, - }); - } - if dry_run { - return Ok(HookCmdRewriteReport { - target: settings_path.to_path_buf(), - action: HookCmdRewriteAction::WouldRewrite { entries: rewritten }, - }); - } - - let body = serde_json::to_string_pretty(&v).context("serialize updated settings.json")?; - // Atomic write: write to `.tmp` then rename so a crash mid- - // write can't leave a half-rewritten settings.json on disk. - let tmp = settings_path.with_extension("json.ccteam-migrate.tmp"); - std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?; - std::fs::rename(&tmp, settings_path) - .with_context(|| format!("rename {} -> {}", tmp.display(), settings_path.display()))?; - Ok(HookCmdRewriteReport { - target: settings_path.to_path_buf(), - action: HookCmdRewriteAction::Rewrote { entries: rewritten }, - }) -} - -/// V0.4.6 F91: outcome of a single project's `cost-accumulate` hook -/// scrub. See [`remove_cost_accumulate_hooks`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CostAccumulateScrubAction { +pub enum CcteamHookScrubAction { /// `settings.local.json` absent — nothing to do. NotFound, - /// No `cost-accumulate` hook entries found. Idempotent re-run after - /// success hits this branch. - NoChangeNeeded, - /// `dry_run = true` — would have removed N entries. - WouldRemove { entries: usize }, - /// Removed N hook entries and atomically wrote the file. - Removed { entries: usize }, -} - -/// V0.4.6 F91: per-project report for [`remove_cost_accumulate_hooks`]. -#[derive(Debug, Clone)] -pub struct CostAccumulateScrubReport { - pub target: PathBuf, - pub action: CostAccumulateScrubAction, -} - -/// V0.4.6 F91: strip the legacy `ccteam hook cost-accumulate` hook entry -/// from a project's `.claude/settings.local.json` (v0.8.6 W2b — the -/// ccteam-managed local settings layer; the caller supplies the path). -/// F91 retired the PostToolUse cost-accumulator (Claude itself reports -/// cost on each `agent_done` event into `progress.jsonl`); existing -/// projects need their settings cleaned so Claude Code doesn't keep -/// firing the now-missing subcommand. -/// -/// Detection rule: any `command` string ending with `hook cost-accumulate` -/// (binary path prefix can be anything — `/usr/local/bin/ccteam hook -/// cost-accumulate`, `ccteam hook cost-accumulate`, or even the legacy -/// `cct hook cost-accumulate`). Inner hook entries matching the rule -/// are removed; entries whose only-remaining sibling matrix collapses -/// to empty are pruned too so the file shape stays clean. -/// -/// Idempotent — re-runs after a successful scrub hit `NoChangeNeeded`. -pub fn remove_cost_accumulate_hooks( - settings_path: &Path, - dry_run: bool, -) -> Result { - if !settings_path.exists() { - return Ok(CostAccumulateScrubReport { - target: settings_path.to_path_buf(), - action: CostAccumulateScrubAction::NotFound, - }); - } - - let body = std::fs::read_to_string(settings_path) - .with_context(|| format!("read {}", settings_path.display()))?; - let mut v: serde_json::Value = serde_json::from_str(&body) - .with_context(|| format!("parse {}", settings_path.display()))?; - - let mut removed = 0usize; - let Some(hooks) = v.get_mut("hooks").and_then(|h| h.as_object_mut()) else { - return Ok(CostAccumulateScrubReport { - target: settings_path.to_path_buf(), - action: CostAccumulateScrubAction::NoChangeNeeded, - }); - }; - for (_event, list) in hooks.iter_mut() { - let Some(arr) = list.as_array_mut() else { - continue; - }; - for entry in arr.iter_mut() { - let Some(inner_hooks) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) else { - continue; - }; - // Keep only the hook commands that aren't cost-accumulate. - let before = inner_hooks.len(); - inner_hooks.retain(|hook| !hook_command_is_cost_accumulate(hook)); - removed += before - inner_hooks.len(); - } - // Drop now-empty inner hook lists so the settings.json shape - // stays clean (e.g. PostToolUse → [{}] with empty inner is - // semantically a no-op but ugly to leave behind). - arr.retain(|entry| { - entry - .get("hooks") - .and_then(|h| h.as_array()) - .map(|a| !a.is_empty()) - .unwrap_or(false) - }); - } - // Drop now-empty top-level event arrays. - hooks.retain(|_event, list| list.as_array().map(|a| !a.is_empty()).unwrap_or(false)); - - if removed == 0 { - return Ok(CostAccumulateScrubReport { - target: settings_path.to_path_buf(), - action: CostAccumulateScrubAction::NoChangeNeeded, - }); - } - if dry_run { - return Ok(CostAccumulateScrubReport { - target: settings_path.to_path_buf(), - action: CostAccumulateScrubAction::WouldRemove { entries: removed }, - }); - } - - let body = serde_json::to_string_pretty(&v).context("serialize updated settings.json")?; - let tmp = settings_path.with_extension("json.ccteam-f91-scrub.tmp"); - std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?; - std::fs::rename(&tmp, settings_path) - .with_context(|| format!("rename {} -> {}", tmp.display(), settings_path.display()))?; - Ok(CostAccumulateScrubReport { - target: settings_path.to_path_buf(), - action: CostAccumulateScrubAction::Removed { entries: removed }, - }) -} - -/// Predicate: does this `hook` JSON entry invoke `ccteam hook -/// cost-accumulate` (or the F39-era `cct` variant)? Matches both -/// absolute-path and bare invocations, with or without a trailing -/// space + args. -fn hook_command_is_cost_accumulate(hook: &serde_json::Value) -> bool { - let Some(cmd) = hook.get("command").and_then(|v| v.as_str()) else { - return false; - }; - let trimmed = cmd.trim(); - trimmed.ends_with(" hook cost-accumulate") || trimmed.contains(" hook cost-accumulate ") -} - -/// v0.8.6 W3: outcome of stripping ccteam's chat hooks from a project's -/// `.claude/settings.local.json`. See [`remove_chat_hooks`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ChatHookScrubAction { - /// `settings.local.json` absent — nothing to do. - NotFound, - /// No ccteam chat-hook entries found. Idempotent re-run after a - /// successful scrub hits this branch. + /// No ccteam hook entries found. Idempotent re-run after a successful + /// scrub hits this branch. NoChangeNeeded, + /// The path exists but could not be read (a directory, no permission …). + /// Left exactly as it is; the caller decides whether that is fatal + /// (`project rm --purge`) or a note (project create / `ccteam init`, + /// which must never fail on a user file). + Unreadable { error: String }, + /// The file was read but is not valid JSON (empty, a trailing comma …). + /// Left exactly as it is; same caller policy as `Unreadable`. + Unparseable { error: String }, /// `dry_run = true` — would have removed N hook command entries. WouldRemove { entries: usize }, /// Removed N hook command entries; the file still holds other keys /// (or other hooks) so it was atomically rewritten in place. Removed { entries: usize }, - /// Removed N hook command entries and the resulting JSON collapsed - /// to an empty object (`{}` / only an empty `hooks` map). The caller - /// is told so it can delete the now-vestigial file outright. + /// Removed N hook command entries and the resulting JSON collapsed to an + /// empty object, so the file — which existed only to carry ccteam's + /// hooks — was deleted. RemovedNowEmpty { entries: usize }, } -/// v0.8.6 W3: per-project report for [`remove_chat_hooks`]. +/// Per-project report for [`remove_ccteam_hooks`]. #[derive(Debug, Clone)] -pub struct ChatHookScrubReport { +pub struct CcteamHookScrubReport { pub target: PathBuf, - pub action: ChatHookScrubAction, + pub action: CcteamHookScrubAction, } -/// v0.8.6 W3: surgically strip the chat-mode hooks ccteam installs via -/// `ccteam_harness::ensure_chat_hooks_installed` from a project's -/// `.claude/settings.local.json` (the caller supplies the path). This is -/// the inverse of that writer and the `project rm --purge` complement of -/// [`remove_cost_accumulate_hooks`]. +/// Surgically strip ccteam's own Claude Code hooks from a project's +/// `.claude/settings.local.json` (the caller supplies the path). /// -/// **Scope (red line).** Removes ONLY entries whose `command` invokes a -/// ccteam chat hook ([`hook_command_is_chat_hook`]): -/// - ` chat-progress ` (the 9 SessionStart/UserPromptSubmit/ -/// Stop/SubagentStop/PostToolUse/PreToolUse/SessionEnd/PreCompact/PostCompact -/// events) -/// - `ccteam mux hook-emit --kind chat-progress …` (the `CCTEAM_HOOK_VIA_DAEMON` -/// variant) -/// - ` intercept-ask` (the `AskUserQuestion` PreToolUse matcher) -/// - ` permission-request` (the HITL `PermissionRequest` hook; -/// R-M4 — a hitl-spawned session installs it, so purge must clear it) +/// docs-local issue #212: project creation no longer writes hooks — they only +/// ever served the retired tmux/rmux terminal protocol (the default +/// stream-json session is hookless: its events come from stdout, HITL from +/// `can_use_tool`). Projects created before that still carry the old +/// `hooks` block, so `ccteam init` (fresh or re-run), the web/IM create path +/// (`bootstrap_project_at_dir`) and `project rm --purge` all run this to +/// clean them. /// -/// Every other key (`permissions`, `env`, `enabledPlugins`, …) and any -/// operator-authored hook survives untouched. Empty inner hook lists + -/// top-level event arrays are pruned so the file shape stays clean, and -/// if the whole object collapses the action is `RemovedNowEmpty` so the -/// caller can delete the file (it was created solely by ccteam). +/// **Scope (red line).** Removes ONLY entries whose `command` is a ccteam +/// hook ([`hook_command_is_ccteam_hook`]), plus the Telegram plugin pin an +/// earlier ccteam wrote here (`enabledPlugins` entry exactly +/// [`TELEGRAM_PLUGIN_PIN`]` = false`; any other value is the user's and +/// stays — the pin now rides claude's `--settings` flag layer); every other key (`permissions`, +/// `env`, `enabledPlugins`, `effortLevel`, …) and any operator-authored hook +/// survives untouched. Empty inner hook lists + top-level event arrays are +/// pruned so the file shape stays clean, and if the whole object collapses +/// the file is deleted (`RemovedNowEmpty`). /// /// Idempotent — a re-run after a successful scrub hits `NoChangeNeeded`. -pub fn remove_chat_hooks(settings_path: &Path, dry_run: bool) -> Result { +pub fn remove_ccteam_hooks(settings_path: &Path, dry_run: bool) -> Result { if !settings_path.exists() { - return Ok(ChatHookScrubReport { + return Ok(CcteamHookScrubReport { target: settings_path.to_path_buf(), - action: ChatHookScrubAction::NotFound, + action: CcteamHookScrubAction::NotFound, }); } - let body = std::fs::read_to_string(settings_path) - .with_context(|| format!("read {}", settings_path.display()))?; - let mut v: serde_json::Value = serde_json::from_str(&body) - .with_context(|| format!("parse {}", settings_path.display()))?; + let untouched = |action| { + Ok(CcteamHookScrubReport { + target: settings_path.to_path_buf(), + action, + }) + }; + let body = match std::fs::read_to_string(settings_path) { + Ok(body) => body, + Err(e) => { + return untouched(CcteamHookScrubAction::Unreadable { + error: e.to_string(), + }) + } + }; + let mut v: serde_json::Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(e) => { + return untouched(CcteamHookScrubAction::Unparseable { + error: e.to_string(), + }) + } + }; let mut removed = 0usize; + if let Some(obj) = v.as_object_mut() { + if let Some(plugins) = obj + .get_mut("enabledPlugins") + .and_then(|p| p.as_object_mut()) + { + // A `false` here cannot be told apart from one the user typed by + // hand; stripping it re-enables Telegram for the user's own + // non-ccteam claude runs in this dir — accepted, since ccteam + // wrote this exact pin into every project it spawned in. + if plugins.get(TELEGRAM_PLUGIN_PIN) == Some(&serde_json::Value::Bool(false)) { + plugins.remove(TELEGRAM_PLUGIN_PIN); + removed += 1; + if plugins.is_empty() { + obj.remove("enabledPlugins"); + } + } + } + } if let Some(hooks) = v.get_mut("hooks").and_then(|h| h.as_object_mut()) { for (_event, list) in hooks.iter_mut() { let Some(arr) = list.as_array_mut() else { @@ -814,7 +611,7 @@ pub fn remove_chat_hooks(settings_path: &Path, dry_run: bool) -> Result Result Result {}", tmp.display(), settings_path.display()))?; - Ok(ChatHookScrubReport { + Ok(CcteamHookScrubReport { target: settings_path.to_path_buf(), - action: if now_empty { - ChatHookScrubAction::RemovedNowEmpty { entries: removed } - } else { - ChatHookScrubAction::Removed { entries: removed } - }, + action: CcteamHookScrubAction::Removed { entries: removed }, }) } -/// Predicate: does this `hook` JSON entry invoke one of ccteam's -/// chat-mode hooks (the inverse target of `ensure_chat_hooks_installed`)? -/// Matches the `chat-progress ` + `intercept-ask` + -/// `permission-request` wrapper forms and the `mux hook-emit --kind -/// chat-progress` daemon-bus variant, independent of the leading -/// `hook.sh` / binary path. -/// -/// v0.8.7 review-fix (R-M4): the HITL `PermissionRequest` hook -/// (`{hook_sh} permission-request`, installed by `claude_tui` for a hitl -/// session) is a ccteam-managed hook too, so `project rm --purge` (which -/// scrubs these entries — purge = `init` inverse, red line) must also -/// recognize it; otherwise a hitl-spawned project leaves a residual -/// `PermissionRequest` hook in `settings.local.json` after purge. -fn hook_command_is_chat_hook(hook: &serde_json::Value) -> bool { +/// The plugin id ccteam pins off for its Claude sessions (mirror of +/// `ccteam_harness::execution::claude_tui::TELEGRAM_PLUGIN_ID`; core sits below +/// harness and cannot name it). +const TELEGRAM_PLUGIN_PIN: &str = "telegram@claude-plugins-official"; + +/// Hook kinds ccteam's `hooks/hook.sh` dispatcher has ever been invoked with +/// from a Claude Code settings file (`ccteam_hooks::dispatch`). A `hook.sh` +/// command only counts as ccteam's when its first argument is one of these, +/// so an operator's own `hook.sh` survives the scrub. +const CCTEAM_HOOK_KINDS: &[&str] = &[ + "progress-append", + "load-context", + "intercept-ask", + "permission-request", + "chat-progress", +]; + +/// Predicate: does this `hook` JSON entry invoke one of ccteam's own hooks? +/// Every shape ccteam has ever written, independent of the install path: +/// - `<…>/hook.sh [args]` — the dispatcher (project bootstrap +/// template + terminal-protocol chat hooks, incl. the HITL +/// `permission-request` and the `AskUserQuestion` `intercept-ask` entries) +/// - `<…>/ccteam [internal] hook …` / `<…>/cct hook …` — the +/// pre-dispatcher binary forms (incl. the retired `cost-accumulate` / +/// `parse-phase-end` kinds) +/// - `ccteam mux hook-emit …` — the `CCTEAM_HOOK_VIA_DAEMON` variant +fn hook_command_is_ccteam_hook(hook: &serde_json::Value) -> bool { let Some(cmd) = hook.get("command").and_then(|v| v.as_str()) else { return false; }; - let trimmed = cmd.trim(); - trimmed.ends_with(" chat-progress") - || trimmed.contains(" chat-progress ") - || trimmed.contains("hook-emit --kind chat-progress") - || trimmed.ends_with(" intercept-ask") - || trimmed.contains(" intercept-ask ") - || trimmed.ends_with(" permission-request") - || trimmed.contains(" permission-request ") -} - -/// V0.2.2 F44 + V0.4.6 F89 + V0.6.1 F139: rewrite one hook `command` -/// string into the ` [args]` shape current installs ship. -/// -/// Returns `None` when the command already points at the supplied -/// `new_hook_sh` (idempotency) or does not look like a ccteam-managed -/// hook (operator-authored hooks survive untouched). Otherwise returns -/// the rewritten string with the leading binary / wrapper-path tokens -/// swapped to `new_hook_sh` and the `internal hook` infix dropped. -/// -/// Migration paths handled (oldest → newest): -/// - F44: `…/cct hook [args]` / bare `cct hook [args]` -/// - F89: `…/ccteam hook [args]` / bare `ccteam hook [args]` -/// - F89: `…/ccteam internal hook [args]` / -/// bare `ccteam internal hook [args]` -fn rewrite_one_hook_command(cmd: &str, new_hook_sh: &str) -> Option { - // Already on the F139 form? Idempotency. - if cmd.trim_start().starts_with(new_hook_sh) { - return None; - } - - // V0.4.6 → V0.6.1 — `…/ccteam internal hook [args]` form. - if let Some(idx) = cmd.find("/ccteam internal hook ") { - let after = &cmd[idx + "/ccteam internal hook ".len()..]; - return Some(format!("{new_hook_sh} {after}")); - } - if let Some(rest) = cmd.strip_prefix("ccteam internal hook ") { - return Some(format!("{new_hook_sh} {rest}")); - } - // F89 — absolute path ending with `/ccteam hook …` (V0.4.5 form). - if let Some(idx) = cmd.find("/ccteam hook ") { - let after = &cmd[idx + "/ccteam hook ".len()..]; - return Some(format!("{new_hook_sh} {after}")); - } - // F89 — bare `ccteam hook …` (V0.4.5 form, no path prefix). - if let Some(rest) = cmd.strip_prefix("ccteam hook ") { - return Some(format!("{new_hook_sh} {rest}")); - } - // F44 — absolute path ending with `/cct hook …` (F39 binary name). - if let Some(idx) = cmd.find("/cct hook ") { - let after = &cmd[idx + "/cct hook ".len()..]; - return Some(format!("{new_hook_sh} {after}")); - } - // F44 — bare `cct hook …` with no path prefix (uncommon but legal). - if let Some(rest) = cmd.strip_prefix("cct hook ") { - return Some(format!("{new_hook_sh} {rest}")); + let mut tokens = cmd.split_whitespace(); + let Some(program) = tokens.next() else { + return false; + }; + let basename = program.rsplit('/').next().unwrap_or(program); + let rest: Vec<&str> = tokens.collect(); + match basename { + "hook.sh" => rest + .first() + .is_some_and(|kind| CCTEAM_HOOK_KINDS.contains(kind)), + "ccteam" | "cct" => matches!( + rest.as_slice(), + ["hook", _, ..] | ["internal", "hook", _, ..] | ["mux", "hook-emit", ..] + ), + _ => false, } - None } #[cfg(test)] @@ -1334,219 +1108,31 @@ mod tests { } } - fn legacy_settings_json(legacy_bin: &str) -> String { - format!( - r#"{{ - "hooks": {{ - "SessionStart": [ - {{"hooks": [ - {{"type": "command", "command": "{legacy_bin} hook load-context", "timeout": 5}}, - {{"type": "command", "command": "{legacy_bin} hook progress-append session_start", "async": true}} - ]}} - ], - "Stop": [ - {{"hooks": [{{"type": "command", "command": "{legacy_bin} hook parse-phase-end"}}]}} - ] - }} -}}"# - ) - } - - const HOOK_SH: &str = "/home/u/.ccteam/hooks/hook.sh"; - - #[test] - fn rewrite_legacy_hook_commands_swaps_f39_cct_path() { - // V0.6.1 F139: F44/F89 rewriter now lands on the hook.sh form. - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join("settings.local.json"); - std::fs::write(&path, legacy_settings_json("/home/u/.cargo/bin/cct")).unwrap(); - let new_hook = PathBuf::from(HOOK_SH); - let rep = rewrite_legacy_hook_commands(&path, &new_hook, false).unwrap(); - assert_eq!(rep.action, HookCmdRewriteAction::Rewrote { entries: 3 }); - let body = std::fs::read_to_string(&path).unwrap(); - assert!( - body.contains(&format!("{HOOK_SH} load-context")), - "got: {body}", - ); - assert!(!body.contains("/cct hook"), "F39-era path survived: {body}"); - } - - #[test] - fn rewrite_legacy_hook_commands_dry_run_does_not_write() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join("settings.local.json"); - let original = legacy_settings_json("/home/u/.cargo/bin/cct"); - std::fs::write(&path, &original).unwrap(); - let new_hook = PathBuf::from(HOOK_SH); - let rep = rewrite_legacy_hook_commands(&path, &new_hook, true).unwrap(); - assert_eq!( - rep.action, - HookCmdRewriteAction::WouldRewrite { entries: 3 } - ); - let body = std::fs::read_to_string(&path).unwrap(); - assert_eq!(body, original); - } - - #[test] - fn rewrite_legacy_hook_commands_idempotent_when_already_hook_sh() { - // V0.6.1 F139: a settings.local.json already pointing at the - // hook.sh wrapper must be a no-op the second pass. - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join("settings.local.json"); - let already_migrated = format!( - r#"{{ - "hooks": {{ - "SessionStart": [ - {{"hooks": [ - {{"type": "command", "command": "{HOOK_SH} load-context"}}, - {{"type": "command", "command": "{HOOK_SH} progress-append session_start"}} - ]}} - ], - "Stop": [ - {{"hooks": [{{"type": "command", "command": "{HOOK_SH} parse-phase-end"}}]}} - ] - }} -}}"# - ); - std::fs::write(&path, already_migrated).unwrap(); - let new_hook = PathBuf::from(HOOK_SH); - let rep = rewrite_legacy_hook_commands(&path, &new_hook, false).unwrap(); - assert_eq!(rep.action, HookCmdRewriteAction::NoChangeNeeded); - } - - #[test] - fn rewrite_legacy_hook_commands_migrates_f89_internal_hook_to_hook_sh() { - // V0.6.1 F139: V0.4.6/V0.6.0 ` internal hook ...` form - // rewrites to ` ...`. Catches user upgrades from F89. - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join("settings.local.json"); - let v060_settings = r#"{ - "hooks": { - "SessionStart": [ - {"hooks": [ - {"type": "command", "command": "/home/u/.cargo/bin/ccteam internal hook load-context"}, - {"type": "command", "command": "/home/u/.cargo/bin/ccteam internal hook progress-append session_start"} - ]} - ], - "Stop": [ - {"hooks": [{"type": "command", "command": "/home/u/.cargo/bin/ccteam internal hook progress-append Stop"}]} - ] - } -}"#; - std::fs::write(&path, v060_settings).unwrap(); - let new_hook = PathBuf::from(HOOK_SH); - let rep = rewrite_legacy_hook_commands(&path, &new_hook, false).unwrap(); - assert_eq!(rep.action, HookCmdRewriteAction::Rewrote { entries: 3 }); - let body = std::fs::read_to_string(&path).unwrap(); - assert!( - body.contains(&format!("{HOOK_SH} load-context")), - "got: {body}" - ); - assert!( - !body.contains("ccteam internal hook"), - "V0.6.0 path must not survive: {body}", - ); - } - - #[test] - fn rewrite_legacy_hook_commands_migrates_v045_ccteam_hook_to_hook_sh() { - // V0.4.5 settings.local.json with ` hook …` (no `internal` - // segment) rewrites to the F139 form too. - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join("settings.local.json"); - std::fs::write(&path, legacy_settings_json("/home/u/.cargo/bin/ccteam")).unwrap(); - let new_hook = PathBuf::from(HOOK_SH); - let rep = rewrite_legacy_hook_commands(&path, &new_hook, false).unwrap(); - assert_eq!(rep.action, HookCmdRewriteAction::Rewrote { entries: 3 }); - let body = std::fs::read_to_string(&path).unwrap(); - assert!( - body.contains(&format!("{HOOK_SH} load-context")), - "got: {body}" - ); - assert!( - !body.contains("ccteam hook load-context"), - "V0.4.5 path must not survive: {body}", - ); - } - - #[test] - fn rewrite_legacy_hook_commands_handles_missing_file() { - let tmp = tempfile::TempDir::new().unwrap(); - let path = tmp.path().join("settings.local.json"); - let new_hook = PathBuf::from(HOOK_SH); - let rep = rewrite_legacy_hook_commands(&path, &new_hook, false).unwrap(); - assert_eq!(rep.action, HookCmdRewriteAction::NotFound); - } - - #[test] - fn rewrite_one_hook_command_handles_bare_cct_prefix() { - let got = rewrite_one_hook_command("cct hook progress-append PreToolUse", HOOK_SH); - assert_eq!( - got.as_deref(), - Some(format!("{HOOK_SH} progress-append PreToolUse").as_str()), - ); - } - - #[test] - fn rewrite_one_hook_command_handles_bare_ccteam_prefix() { - let got = rewrite_one_hook_command("ccteam hook progress-append PreToolUse", HOOK_SH); - assert_eq!( - got.as_deref(), - Some(format!("{HOOK_SH} progress-append PreToolUse").as_str()), - ); - } - - #[test] - fn rewrite_one_hook_command_handles_internal_hook_form() { - let got = rewrite_one_hook_command( - "/home/u/.cargo/bin/ccteam internal hook intercept-ask", - HOOK_SH, - ); - assert_eq!( - got.as_deref(), - Some(format!("{HOOK_SH} intercept-ask").as_str()), - ); - } - - #[test] - fn rewrite_one_hook_command_idempotent_for_hook_sh_form() { - let got = rewrite_one_hook_command(&format!("{HOOK_SH} load-context"), HOOK_SH); - assert!(got.is_none(), "hook.sh-prefixed command must be skipped"); - } - - #[test] - fn rewrite_one_hook_command_returns_none_for_unrelated_commands() { - assert!( - rewrite_one_hook_command("/usr/bin/jq .progress[]", HOOK_SH).is_none(), - "should not touch operator-authored hooks", - ); - } - - // ───────────────────── v0.8.6 W3 — remove_chat_hooks ───────────────────── + // ───────────────────── remove_ccteam_hooks ───────────────────── #[test] - fn remove_chat_hooks_not_found_when_file_absent() { + fn remove_ccteam_hooks_not_found_when_file_absent() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); - let report = remove_chat_hooks(&path, false).unwrap(); - assert_eq!(report.action, ChatHookScrubAction::NotFound); + let report = remove_ccteam_hooks(&path, false).unwrap(); + assert_eq!(report.action, CcteamHookScrubAction::NotFound); } #[test] - fn remove_chat_hooks_no_change_for_operator_only_file() { + fn remove_ccteam_hooks_no_change_for_operator_only_file() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); let original = r#"{"permissions":{"allow":["Bash"]},"hooks":{"PreToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"/h/my-linter.sh"}]}]}}"#; std::fs::write(&path, original).unwrap(); - let report = remove_chat_hooks(&path, false).unwrap(); - assert_eq!(report.action, ChatHookScrubAction::NoChangeNeeded); + let report = remove_ccteam_hooks(&path, false).unwrap(); + assert_eq!(report.action, CcteamHookScrubAction::NoChangeNeeded); // File untouched (still parses, still has the operator hook). let body = std::fs::read_to_string(&path).unwrap(); assert!(body.contains("my-linter.sh")); } #[test] - fn remove_chat_hooks_strips_chat_and_ask_keeps_others() { + fn remove_ccteam_hooks_strips_chat_and_ask_keeps_others() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); std::fs::write( @@ -1564,9 +1150,9 @@ mod tests { }"#, ) .unwrap(); - let report = remove_chat_hooks(&path, false).unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); // 1 SessionStart + 1 PreToolUse chat-progress + 1 intercept-ask = 3. - assert_eq!(report.action, ChatHookScrubAction::Removed { entries: 3 }); + assert_eq!(report.action, CcteamHookScrubAction::Removed { entries: 3 }); let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert!(v.get("permissions").is_some(), "operator key kept"); @@ -1581,7 +1167,7 @@ mod tests { } #[test] - fn remove_chat_hooks_reports_now_empty_when_only_ccteam_hooks() { + fn remove_ccteam_hooks_reports_now_empty_when_only_ccteam_hooks() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); std::fs::write( @@ -1589,20 +1175,17 @@ mod tests { r#"{"hooks":{"SessionStart":[{"matcher":"*","hooks":[{"type":"command","command":"/h/hook.sh chat-progress session-start"}]}],"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"/h/hook.sh chat-progress stop"}]}]}}"#, ) .unwrap(); - let report = remove_chat_hooks(&path, false).unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); assert_eq!( report.action, - ChatHookScrubAction::RemovedNowEmpty { entries: 2 } + CcteamHookScrubAction::RemovedNowEmpty { entries: 2 } ); - // remove_chat_hooks rewrites in place ({}) — the CALLER deletes - // the file on RemovedNowEmpty; here the rewritten body is empty. - let v: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(v, serde_json::json!({})); + // The file existed only to carry ccteam's hooks — it is deleted. + assert!(!path.exists(), "now-empty settings.local.json is removed"); } #[test] - fn remove_chat_hooks_matches_daemon_bus_variant() { + fn remove_ccteam_hooks_matches_daemon_bus_variant() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); std::fs::write( @@ -1610,23 +1193,23 @@ mod tests { r#"{"hooks":{"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"ccteam mux hook-emit --kind chat-progress --action stop"}]}]}}"#, ) .unwrap(); - let report = remove_chat_hooks(&path, false).unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); assert_eq!( report.action, - ChatHookScrubAction::RemovedNowEmpty { entries: 1 } + CcteamHookScrubAction::RemovedNowEmpty { entries: 1 } ); } /// v0.8.7 review-fix (R-M4) — a hitl session installs the /// `PermissionRequest` hook (`{hook_sh} permission-request`, see /// `claude_tui::ensure_chat_hooks_installed`). `project rm --purge` - /// (which runs `remove_chat_hooks`) is the `init` inverse and MUST clear + /// (which runs `remove_ccteam_hooks`) is the `init` inverse and MUST clear /// it; otherwise the project keeps a live HITL gate after deregistration. /// This pins both: the entry is counted/removed AND its now-empty /// `PermissionRequest` event section is pruned, while an operator hook /// under the same section survives. #[test] - fn remove_chat_hooks_strips_permission_request_keeps_operator() { + fn remove_ccteam_hooks_strips_permission_request_keeps_operator() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); std::fs::write( @@ -1642,9 +1225,9 @@ mod tests { }"#, ) .unwrap(); - let report = remove_chat_hooks(&path, false).unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); // 1 SessionStart chat-progress + 1 PermissionRequest ccteam hook = 2. - assert_eq!(report.action, ChatHookScrubAction::Removed { entries: 2 }); + assert_eq!(report.action, CcteamHookScrubAction::Removed { entries: 2 }); let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); let body = serde_json::to_string(&v).unwrap(); @@ -1671,7 +1254,7 @@ mod tests { /// `PermissionRequest` HITL hook, no operator content) collapses to empty /// on purge so the caller deletes it (true `init` inverse). #[test] - fn remove_chat_hooks_now_empty_includes_permission_request() { + fn remove_ccteam_hooks_now_empty_includes_permission_request() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); std::fs::write( @@ -1679,26 +1262,24 @@ mod tests { r#"{"hooks":{"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"/h/hook.sh chat-progress stop"}]}],"PermissionRequest":[{"hooks":[{"type":"command","command":"/h/hook.sh permission-request"}]}]}}"#, ) .unwrap(); - let report = remove_chat_hooks(&path, false).unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); assert_eq!( report.action, - ChatHookScrubAction::RemovedNowEmpty { entries: 2 } + CcteamHookScrubAction::RemovedNowEmpty { entries: 2 } ); - let v: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(v, serde_json::json!({})); + assert!(!path.exists(), "now-empty settings.local.json is removed"); } #[test] - fn remove_chat_hooks_dry_run_does_not_write() { + fn remove_ccteam_hooks_dry_run_does_not_write() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); let original = r#"{"hooks":{"SessionStart":[{"matcher":"*","hooks":[{"type":"command","command":"/h/hook.sh chat-progress session-start"}]}]}}"#; std::fs::write(&path, original).unwrap(); - let report = remove_chat_hooks(&path, true).unwrap(); + let report = remove_ccteam_hooks(&path, true).unwrap(); assert_eq!( report.action, - ChatHookScrubAction::WouldRemove { entries: 1 } + CcteamHookScrubAction::WouldRemove { entries: 1 } ); assert_eq!( std::fs::read_to_string(&path).unwrap(), @@ -1708,7 +1289,7 @@ mod tests { } #[test] - fn remove_chat_hooks_idempotent_after_scrub() { + fn remove_ccteam_hooks_idempotent_after_scrub() { let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("settings.local.json"); std::fs::write( @@ -1716,9 +1297,149 @@ mod tests { r#"{"permissions":{},"hooks":{"SessionStart":[{"matcher":"*","hooks":[{"type":"command","command":"/h/hook.sh chat-progress session-start"}]}]}}"#, ) .unwrap(); - let first = remove_chat_hooks(&path, false).unwrap(); - assert!(matches!(first.action, ChatHookScrubAction::Removed { .. })); - let second = remove_chat_hooks(&path, false).unwrap(); - assert_eq!(second.action, ChatHookScrubAction::NoChangeNeeded); + let first = remove_ccteam_hooks(&path, false).unwrap(); + assert!(matches!( + first.action, + CcteamHookScrubAction::Removed { .. } + )); + let second = remove_ccteam_hooks(&path, false).unwrap(); + assert_eq!(second.action, CcteamHookScrubAction::NoChangeNeeded); + } + + /// docs-local issue #212 — the exact block project bootstrap used to + /// write (`hooks/hook.sh progress-append|load-context|intercept-ask`) is + /// stripped, while the non-hook keys that file carried and a user hook in + /// the same event section survive. + #[test] + fn remove_ccteam_hooks_strips_bootstrap_template_hooks() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("settings.local.json"); + std::fs::write( + &path, + r#"{ + "permissions": {"defaultMode": "bypassPermissions", "deny": []}, + "env": {"CCTEAM_HOME": "/home/u/.ccteam"}, + "hooks": { + "SessionStart": [{"hooks": [ + {"type":"command","command":"/home/u/.ccteam/hooks/hook.sh load-context","timeout":5}, + {"type":"command","command":"/home/u/.ccteam/hooks/hook.sh progress-append session_start","async":true} + ]}], + "Stop": [{"hooks": [{"type":"command","command":"/home/u/.ccteam/hooks/hook.sh progress-append Stop","async":true}]}], + "PreToolUse": [ + {"hooks": [{"type":"command","command":"/home/u/.ccteam/hooks/hook.sh progress-append PreToolUse","async":true}]}, + {"matcher":"AskUserQuestion","hooks":[{"type":"command","command":"/home/u/.ccteam/hooks/hook.sh intercept-ask","timeout":5}]}, + {"matcher":"Bash","hooks":[{"type":"command","command":"/home/u/bin/hook.sh audit"}]} + ] + } + }"#, + ) + .unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); + assert_eq!(report.action, CcteamHookScrubAction::Removed { entries: 5 }); + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(v["permissions"]["defaultMode"], "bypassPermissions"); + assert_eq!(v["env"]["CCTEAM_HOME"], "/home/u/.ccteam"); + let hooks = v["hooks"].as_object().unwrap(); + assert_eq!( + hooks.keys().collect::>(), + vec!["PreToolUse"], + "only the user's hook section remains" + ); + assert_eq!( + v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "/home/u/bin/hook.sh audit", + "a user hook.sh with a non-ccteam argument is not ccteam's" + ); + } + + /// The pre-dispatcher binary forms (`ccteam hook …`, `ccteam internal + /// hook …`, `cct hook …`) incl. retired kinds are ccteam's too; an + /// unrelated command merely mentioning `hook` is not. + #[test] + fn hook_command_is_ccteam_hook_matches_legacy_binary_forms() { + let is = |cmd: &str| hook_command_is_ccteam_hook(&serde_json::json!({ "command": cmd })); + assert!(is("/usr/local/bin/ccteam hook cost-accumulate")); + assert!(is("ccteam hook parse-phase-end")); + assert!(is("/home/u/.cargo/bin/ccteam internal hook load-context")); + assert!(is("/opt/cct hook progress-append Stop")); + assert!(is( + "ccteam mux hook-emit --kind chat-progress --action stop" + )); + assert!(!is("ccteam status")); + assert!(!is("/usr/bin/jq .progress[]")); + assert!(!is("/h/hook.sh")); + assert!(!is("git hook run pre-commit")); + } + + /// docs-local issue #212 — an empty / invalid file is reported, never an + /// error, and left byte-for-byte as it was. + #[test] + fn remove_ccteam_hooks_reports_unparseable_and_leaves_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("settings.local.json"); + for body in ["", "{\"model\": \"opus\",}"] { + std::fs::write(&path, body).unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); + assert!( + matches!(report.action, CcteamHookScrubAction::Unparseable { .. }), + "{body:?}: {:?}", + report.action + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), body); + } + } + + /// A path that exists but cannot be read (here: a directory) is + /// `Unreadable`, not mislabelled as invalid JSON. + #[test] + fn remove_ccteam_hooks_reports_unreadable_distinct_from_unparseable() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("settings.local.json"); + std::fs::create_dir(&path).unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); + assert!( + matches!(report.action, CcteamHookScrubAction::Unreadable { .. }), + "{:?}", + report.action + ); + assert!(path.is_dir(), "left as it was"); + } + + /// The Telegram pin ccteam wrote (`false`) is scrubbed — pruning an + /// emptied `enabledPlugins` — while a user's own value survives. + #[test] + fn remove_ccteam_hooks_strips_ccteam_telegram_pin_only() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("settings.local.json"); + std::fs::write( + &path, + r#"{"model":"opus","enabledPlugins":{"telegram@claude-plugins-official":false}}"#, + ) + .unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); + assert_eq!(report.action, CcteamHookScrubAction::Removed { entries: 1 }); + let v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(v, serde_json::json!({"model": "opus"})); + + // Pin-only file collapses and is removed. + std::fs::write( + &path, + r#"{"enabledPlugins":{"telegram@claude-plugins-official":false}}"#, + ) + .unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); + assert_eq!( + report.action, + CcteamHookScrubAction::RemovedNowEmpty { entries: 1 } + ); + assert!(!path.exists()); + + // A user's own `true` (and other plugins) is not ccteam's. + let user = r#"{"enabledPlugins":{"telegram@claude-plugins-official":true,"x@y":false}}"#; + std::fs::write(&path, user).unwrap(); + let report = remove_ccteam_hooks(&path, false).unwrap(); + assert_eq!(report.action, CcteamHookScrubAction::NoChangeNeeded); + assert_eq!(std::fs::read_to_string(&path).unwrap(), user); } } diff --git a/crates/ccteam-core/tests/cost_summary_test.rs b/crates/ccteam-core/tests/cost_summary_test.rs index 77974c0d6..d436c711d 100644 --- a/crates/ccteam-core/tests/cost_summary_test.rs +++ b/crates/ccteam-core/tests/cost_summary_test.rs @@ -18,9 +18,6 @@ //! - `t04_cost_used_usd_serde_compat_old_files` — hand-rolled //! pre-F91 `state.json` with `cost_used_usd: 1.23` still loads //! (deprecated serde-default path). -//! - `t05_doctor_update_hooks_removes_cost_accumulate` — settings.json -//! containing the legacy `ccteam hook cost-accumulate` PostToolUse -//! entry is scrubbed by `remove_cost_accumulate_hooks`. //! //! V0.5.0 F92 — six new tests covering the transcript-jsonl cost path: //! @@ -44,9 +41,8 @@ use std::fs; use ccteam_core::transcript_scanner::{file_read_count, reset_cache_for_tests}; use ccteam_core::{ - compute_cost_summary, cost_summary, estimate_cost, progress, remove_cost_accumulate_hooks, - session_cost_from_jsonl, CcteamPaths, CostAccumulateScrubAction, JobLiveness, ProjectState, - Usage, Vendor, + compute_cost_summary, cost_summary, estimate_cost, progress, session_cost_from_jsonl, + CcteamPaths, JobLiveness, ProjectState, Usage, Vendor, }; use chrono::{Duration, Utc}; use serde_json::json; @@ -211,71 +207,6 @@ fn t04_cost_used_usd_serde_compat_old_files() { // progress.jsonl — confirming the read paths are independent. } -#[test] -fn t05_doctor_update_hooks_removes_cost_accumulate() { - // V0.4.6 F91: `doctor --update-hooks` walks every project's - // settings.json and strips the legacy `cost-accumulate` hook - // entry. The scrub is idempotent. - let tmp = TempDir::new().unwrap(); - let settings = tmp.path().join("settings.json"); - fs::write( - &settings, - r#"{ - "hooks": { - "PostToolUse": [ - { - "hooks": [ - {"type": "command", "command": "/usr/local/bin/ccteam hook progress-append PostToolUse", "async": true}, - {"type": "command", "command": "/usr/local/bin/ccteam hook cost-accumulate", "async": true} - ] - } - ], - "Stop": [ - { - "hooks": [ - {"type": "command", "command": "/usr/local/bin/ccteam hook parse-phase-end", "timeout": 10} - ] - } - ] - } - }"#, - ) - .unwrap(); - - let report = remove_cost_accumulate_hooks(&settings, false).unwrap(); - assert!( - matches!( - report.action, - CostAccumulateScrubAction::Removed { entries: 1 } - ), - "expected one entry removed, got {:?}", - report.action, - ); - - // After scrub the file must still parse and the PostToolUse hook - // entry must keep the surviving `progress-append` command. - let body = fs::read_to_string(&settings).unwrap(); - let v: serde_json::Value = serde_json::from_str(&body).unwrap(); - let post = &v["hooks"]["PostToolUse"][0]["hooks"]; - assert_eq!(post.as_array().unwrap().len(), 1); - assert!(post[0]["command"] - .as_str() - .unwrap() - .ends_with("hook progress-append PostToolUse")); - - // Stop hook untouched. - let stop = &v["hooks"]["Stop"][0]["hooks"]; - assert_eq!(stop.as_array().unwrap().len(), 1); - - // Idempotency: a second run reports NoChangeNeeded. - let again = remove_cost_accumulate_hooks(&settings, false).unwrap(); - assert!( - matches!(again.action, CostAccumulateScrubAction::NoChangeNeeded), - "second scrub must be no-op, got {:?}", - again.action, - ); -} - // ============================================================ // V0.5.0 F92 — transcript-jsonl cost source tests (t06..t11). // One test per acceptance bullet in `docs/versions/v0-5-0/prd.md` §F92 §验收. diff --git a/crates/ccteam-harness/src/adapter.rs b/crates/ccteam-harness/src/adapter.rs index d7172eb6c..6aafde952 100644 --- a/crates/ccteam-harness/src/adapter.rs +++ b/crates/ccteam-harness/src/adapter.rs @@ -453,6 +453,17 @@ pub struct RecoveredTurn { pub conclusion: Option, } +/// What a body that outlived its daemon is doing, read from the vendor's own +/// durable record (see [`HarnessAdapter::detached_activity`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DetachedActivity { + /// The newest turn in the record has ended: nothing is mid-turn, and the + /// process is alive only for work it started in the background. + pub turn_ended: bool, + /// How long the record has not grown. + pub quiet_for: std::time::Duration, +} + /// Cross-vendor thread handle, returned from /// [`HarnessAdapter::start_thread`] and consumed by every other trait /// method. Replaces the V0.5.x [`SessionHandle`] on the adapter surface @@ -1395,6 +1406,128 @@ pub struct ThreadStatus { pub stop_hook_blocks: Option, } +/// What a session's context window is MADE OF — the structured peer of +/// [`ContextUsage`]'s two numbers, for the web context panel (issue #21). +/// +/// Every number is the harness's OWN accounting (Claude: the +/// `get_context_usage` control_request that also backs its `/context`), never +/// a ccteam estimate: the point is to let a user see where the window goes +/// without asking the model to guess. Vendor-neutral so another harness with a +/// breakdown channel fills the same shape; one without answers `None`. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ContextBreakdown { + /// The harness that reported it (`"claude"`). + pub vendor: String, + #[serde(default)] + pub model: Option, + /// When the harness was asked. A released session shows its last + /// turn-end snapshot, and the panel has to say how old that is. + pub captured_at: DateTime, + pub used_tokens: u64, + pub window_tokens: u64, + /// Occupancy at which the harness compacts on its own; `None` when it + /// doesn't say (or auto-compaction is off). + #[serde(default)] + pub auto_compact_threshold: Option, + /// In the harness's own order. `used` categories sum to `used_tokens`. + #[serde(default)] + pub categories: Vec, + /// The conversation itself, split by what produced it — the view that + /// answers "which tool is eating my context". `None` before the first + /// message or when the harness doesn't split it. + #[serde(default)] + pub messages: Option, + /// Real API accounting of the most recent request (cache read vs write vs + /// uncached input), as opposed to the estimated categories above. + #[serde(default)] + pub last_request: Option, +} + +/// One slice of the window. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ContextCategory { + /// Stable machine id (`memory_files`, `messages`, …). + pub id: String, + /// The harness's display name. + pub name: String, + pub tokens: u64, + pub kind: ContextCategoryKind, + /// The entries that make up this slice (memory file paths, skills, MCP + /// tools, agents, …); empty when the harness doesn't itemize it. + #[serde(default)] + pub items: Vec, +} + +/// Whether a category actually occupies the window. `Deferred` exists so a +/// tool schema the model has NOT loaded is never read as context spent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextCategoryKind { + #[default] + Used, + Deferred, + /// Held back for auto-compaction. + Buffer, + Free, +} + +/// One itemized entry of a [`ContextCategory`]. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ContextItem { + pub name: String, + /// Where it came from (`Project` / `User` memory, a skill's settings + /// scope, an MCP tool's server). + #[serde(default)] + pub source: Option, + /// A secondary locator, typically the file path behind the entry. + #[serde(default)] + pub detail: Option, + pub tokens: u64, + /// For deferrable entries: whether it is loaded into the window now. + #[serde(default)] + pub loaded: Option, +} + +/// The conversation part of the window, split by origin. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ContextMessages { + pub tool_call_tokens: u64, + pub tool_result_tokens: u64, + /// Harness-injected context riding on messages (reminders, hook output, + /// file snapshots, …). + pub attachment_tokens: u64, + pub assistant_tokens: u64, + pub user_tokens: u64, + /// What the harness counted but could not attribute to the above. + #[serde(default)] + pub other_tokens: u64, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ContextToolUsage { + pub name: String, + pub call_tokens: u64, + pub result_tokens: u64, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ContextAttachmentUsage { + pub name: String, + pub tokens: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ContextRequestUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_read_tokens: u64, +} + /// Account-level usage / rate-limits (Claude `get_usage` control_request; Codex /// equivalent), vendor-agnostic. Surfaced in the IM `/status` operator /// dashboard. This is the ACCOUNT's state (5-hour + weekly windows + extra @@ -1593,6 +1726,10 @@ pub enum ThreadItemDetails { CommandExecution { cmd: String, status: String, + /// The command's output, on the item that completes it — see + /// [`ThreadItemDetails::ToolCall::output`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + output: Option, }, FileChange { path: PathBuf, @@ -1600,7 +1737,14 @@ pub enum ThreadItemDetails { }, ToolCall { name: String, + /// The call's input. A completing item repeats it when the channel + /// still has it, `Null` otherwise — readers key a step by item id. args: serde_json::Value, + /// What the tool returned, as text — set only on the item that + /// completes the call, `None` where the vendor reports no result. + /// Observability only (the web step inspector); never an answer. + #[serde(default, skip_serializing_if = "Option::is_none")] + output: Option, }, WebSearch { query: String, @@ -1608,6 +1752,71 @@ pub enum ThreadItemDetails { Error(String), } +/// Flatten a tool result into the text a step inspector shows: a bare +/// string as is; an Anthropic / ACP content-block array as its text blocks, +/// one per line, with every non-text block (an image, a diff) named in +/// brackets rather than dropped; a result object by its `text` / `output` +/// field when it has one, as compact JSON otherwise. +pub fn tool_result_text(content: &serde_json::Value) -> String { + use serde_json::Value; + fn block_text(block: &Value) -> String { + match block { + Value::String(s) => s.clone(), + Value::Object(map) => { + if let Some(text) = map.get("text").and_then(Value::as_str) { + return text.to_string(); + } + // ACP wraps a block one level deeper: `{type:"content", content:{…}}`. + if let Some(inner) = map.get("content") { + return tool_result_text(inner); + } + // A result object (an ACP `rawOutput` such as `{output, metadata}`). + if let Some(output) = map.get("output").and_then(Value::as_str) { + return output.to_string(); + } + match map.get("type").and_then(Value::as_str) { + Some(kind) => format!("[{kind}]"), + None => block.to_string(), + } + } + other => other.to_string(), + } + } + match content { + Value::Null => String::new(), + Value::String(s) => s.clone(), + Value::Array(blocks) => blocks + .iter() + .map(block_text) + .filter(|t| !t.is_empty()) + .collect::>() + .join("\n"), + Value::Object(_) => block_text(content), + other => other.to_string(), + } +} + +/// The output a Codex item reports (`codex exec --json` snake_case or the +/// app-server's camelCase): a command's `aggregated_output`, or an MCP call's +/// `result.content` — its `error.message` when it failed. `None` while the +/// item is still running, or when it carries neither. +pub fn codex_item_output(item: &serde_json::Value) -> Option { + if let Some(out) = item + .get("aggregatedOutput") + .or_else(|| item.get("aggregated_output")) + .and_then(serde_json::Value::as_str) + { + return Some(out.to_string()); + } + if let Some(content) = item.get("result").and_then(|r| r.get("content")) { + return Some(tool_result_text(content)); + } + item.get("error") + .and_then(|e| e.get("message")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + /// Error payload on [`ThreadEvent::TurnFailed`] / [`ThreadEvent::Error`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThreadErrorEvent { @@ -1897,6 +2106,17 @@ pub trait HarnessAdapter: Send + Sync { None } + /// What this LIVE session's context window is made of, asked of the + /// harness right now (issue #21). OPTIONAL (default `None`): only adapters + /// with a breakdown channel implement it (Claude stream-json via + /// `get_context_usage`). Callers fall back to the session's persisted + /// snapshot ([`crate::execution::session_status::read_context_file`]) when + /// this answers `None`, so an adapter that persists one at turn end stays + /// readable after the session is released. + async fn context_breakdown(&self, _h: &ThreadHandle) -> Option { + None + } + /// The session's currently-running subagent / workflow tasks, as reported by /// the harness's OWN task lifecycle (never folded or counted by ccteam). /// OPTIONAL (default empty): only adapters with task introspection @@ -2007,6 +2227,18 @@ pub trait HarnessAdapter: Send + Sync { None } + /// Is a body that outlived its daemon mid-turn, and how long has it been + /// silent? Read from the vendor's durable record (Claude's transcript + /// jsonl), the same file [`Self::recover_unobserved_turn`] reads. The + /// gateway uses it to bound the wait behind such a body. + /// + /// Default `None` = "this vendor keeps no record ccteam may read"; the + /// gateway then only applies its long stall bound. + async fn detached_activity(&self, ctx: &UnobservedTurnCtx) -> Option { + let _ = ctx; + None + } + /// Push an explicit user rename to the VENDOR's own session-title surface, /// so a session renamed in ccteam reads the same way in the vendor's native /// UI (`claude --resume`'s picker, `codex`'s thread list). @@ -2837,4 +3069,47 @@ mod tests { assert!(sh.job_id.is_none()); assert_eq!(sh.pid, Some(9001)); } + + #[test] + fn tool_result_text_flattens_every_result_shape() { + use serde_json::json; + assert_eq!(tool_result_text(&json!("plain")), "plain"); + assert_eq!(tool_result_text(&serde_json::Value::Null), ""); + assert_eq!( + tool_result_text(&json!([ + {"type": "text", "text": "one"}, + {"type": "content", "content": {"type": "text", "text": "two"}}, + {"type": "diff", "path": "a.rs"} + ])), + "one\ntwo\n[diff]" + ); + assert_eq!(tool_result_text(&json!({"exit": 0})), r#"{"exit":0}"#); + assert_eq!( + tool_result_text(&json!({"output": "built", "metadata": {"exit": 0}})), + "built" + ); + } + + #[test] + fn codex_item_output_reads_commands_and_mcp_calls() { + use serde_json::json; + assert_eq!( + codex_item_output(&json!({"aggregatedOutput": "ok\n"})).as_deref(), + Some("ok\n") + ); + assert_eq!( + codex_item_output(&json!({"aggregated_output": "snake"})).as_deref(), + Some("snake") + ); + assert_eq!( + codex_item_output(&json!({"result": {"content": [{"type": "text", "text": "r"}]}})) + .as_deref(), + Some("r") + ); + assert_eq!( + codex_item_output(&json!({"error": {"message": "boom"}})).as_deref(), + Some("boom") + ); + assert_eq!(codex_item_output(&json!({"status": "inProgress"})), None); + } } diff --git a/crates/ccteam-harness/src/execution/acp/translate.rs b/crates/ccteam-harness/src/execution/acp/translate.rs index f7f72d28a..6ff9b36e3 100644 --- a/crates/ccteam-harness/src/execution/acp/translate.rs +++ b/crates/ccteam-harness/src/execution/acp/translate.rs @@ -36,6 +36,10 @@ use crate::{ /// pulse. First chunk of a streak always emits (interval elapsed). const LIVENESS_MIN_INTERVAL: Duration = Duration::from_secs(5); +/// The most reasoning one turn keeps for the step inspector (the step log +/// caps a field at the same size). +const THOUGHT_MAX_BYTES: usize = 256 * 1024; + /// Per-turn final-text buffer for either a client- or vendor-started turn. #[derive(Debug, Clone, Default)] pub struct TurnBuffer { @@ -245,6 +249,16 @@ pub struct SessionTranslateState { /// `pub` so vendor adapters can construct the state with struct update /// syntax (`..Default::default()`); not part of the public API surface. pub last_liveness_at: Option, + /// The open turn's reasoning so far — every thought chunk, in order, up to + /// [`THOUGHT_MAX_BYTES`]. Thoughts never enter the answer; this is what a + /// liveness pulse reports, so the step inspector reads the whole thought + /// rather than whichever chunk a throttled pulse happened to carry. + pub thought: String, + /// How much of `thought` (bytes) a pulse has already reported. + pub thought_reported: usize, + /// A non-thought update arrived since the last thought chunk: the next + /// chunk starts a new paragraph. + pub thought_paused: bool, } impl SessionTranslateState { @@ -337,6 +351,30 @@ impl SessionTranslateState { self.prompt_boundary_seen = false; // Fresh turn → first thought/message chunk should emit immediately. self.last_liveness_at = None; + self.reset_thought(); + } + + fn reset_thought(&mut self) { + self.thought.clear(); + self.thought_reported = 0; + self.thought_paused = false; + } + + /// Add one thought chunk to the turn's reasoning (bounded). + fn append_thought(&mut self, chunk: &str) { + if chunk.is_empty() || self.thought.len() >= THOUGHT_MAX_BYTES { + return; + } + if self.thought_paused && !self.thought.is_empty() { + self.thought.push_str("\n\n"); + } + self.thought_paused = false; + let room = THOUGHT_MAX_BYTES.saturating_sub(self.thought.len()); + let mut take = chunk.len().min(room); + while !chunk.is_char_boundary(take) { + take -= 1; + } + self.thought.push_str(&chunk[..take]); } /// Mark the turn just begun as a context probe, whose reply is read as @@ -375,6 +413,7 @@ impl SessionTranslateState { let turn_id = super::turn_runner::next_acp_turn_id(); self.vendor_started_buffer = Some(TurnBuffer::new(turn_id.clone())); self.last_liveness_at = None; + self.reset_thought(); // `Submitted`, not a vendor continuation: what opens this turn is a // line ccteam delivered (an interjection grok admitted while idle), // simply without a matching `session/prompt` RPC. ACP has no way for @@ -550,8 +589,54 @@ fn apply_session_update( .get("sessionUpdate") .or_else(|| update.get("type")) .and_then(|v| v.as_str()) - .unwrap_or(""); + .unwrap_or("") + .to_string(); + + // Reasoning that a throttled pulse never reported is complete once the + // agent moves on to anything else: report it now, so the step inspector + // holds the whole thought. + let mut out = Vec::new(); + if kind != "agent_thought_chunk" { + out.extend(flush_thought(state)); + state.thought_paused = true; + } + out.extend(translate_session_update( + state, + params, + update, + &kind, + vendor_started, + )); + out +} + +/// The turn's reasoning as one unthrottled pulse, when some of it is +/// unreported. Same item id as the throttled pulses: one step per turn. +fn flush_thought(state: &mut SessionTranslateState) -> Option { + if state.thought.len() <= state.thought_reported { + return None; + } + let turn_id = state + .vendor_started_buffer + .as_ref() + .or(state.buffer.as_ref()) + .map(|b| b.turn_id.clone())?; + state.thought_reported = state.thought.len(); + Some(ThreadEvent::ItemUpdated { + item: ThreadItem { + id: format!("{turn_id}-live-thought"), + details: ThreadItemDetails::Reasoning(state.thought.clone()), + }, + }) +} +fn translate_session_update( + state: &mut SessionTranslateState, + params: &Value, + update: Value, + kind: &str, + vendor_started: bool, +) -> Vec { match kind { "agent_message_chunk" => { let text = extract_chunk_text(&update).unwrap_or_default(); @@ -567,8 +652,16 @@ fn apply_session_update( "agent_thought_chunk" => { // Thoughts never enter final text; still pulse liveness (Grok can // think for many minutes with zero tool calls). - let text = extract_chunk_text(&update).unwrap_or_default(); - maybe_liveness_event(state, "thought", ThreadItemDetails::Reasoning(text)) + state.append_thought(&extract_chunk_text(&update).unwrap_or_default()); + let events = maybe_liveness_event( + state, + "thought", + ThreadItemDetails::Reasoning(state.thought.clone()), + ); + if !events.is_empty() { + state.thought_reported = state.thought.len(); + } + events } "user_message_chunk" => { // User echo ignored (and not a liveness signal for the agent turn). @@ -615,6 +708,16 @@ fn apply_session_update( details: ThreadItemDetails::ToolCall { name, args: update.get("rawInput").cloned().unwrap_or(Value::Null), + // The result rides the completing update: `rawOutput` + // when the agent reports one, else its content blocks. + output: (status == "completed") + .then(|| { + update + .get("rawOutput") + .or_else(|| update.get("content")) + .map(crate::tool_result_text) + }) + .flatten(), }, }; // A new tool call ends whatever the agent was saying before it: @@ -1483,8 +1586,23 @@ mod tests { }), }, ); - assert_eq!(msg.len(), 1, "message chunk after throttle must pulse"); + // The thought the throttle withheld is complete once the agent moves + // on: it is reported whole — every chunk, one step — ahead of the + // message pulse. + assert_eq!(msg.len(), 2, "flushed thought + message pulse: {msg:?}"); match &msg[0] { + ThreadEvent::ItemUpdated { item } => { + assert_eq!(item.id, thought_id(&thought[0])); + match &item.details { + ThreadItemDetails::Reasoning(t) => { + assert_eq!(t, "thinking hardmore thought") + } + other => panic!("expected Reasoning, got {other:?}"), + } + } + other => panic!("expected ItemUpdated, got {other:?}"), + } + match &msg[1] { ThreadEvent::ItemUpdated { item } => match &item.details { ThreadItemDetails::AgentMessage(t) => assert_eq!(t, "Hello"), other => panic!("expected AgentMessage, got {other:?}"), @@ -1495,6 +1613,13 @@ mod tests { assert_eq!(st.buffer.as_ref().unwrap().text, "Hello"); } + fn thought_id(event: &ThreadEvent) -> String { + match event { + ThreadEvent::ItemUpdated { item } => item.id.clone(), + other => panic!("expected ItemUpdated, got {other:?}"), + } + } + #[test] fn turn_boundary_notification_fires_finalize_barrier() { let mut st = SessionTranslateState::default(); diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/context.rs b/crates/ccteam-harness/src/execution/claude_stream_json/context.rs new file mode 100644 index 000000000..e46be4a0b --- /dev/null +++ b/crates/ccteam-harness/src/execution/claude_stream_json/context.rs @@ -0,0 +1,393 @@ +//! Claude's `get_context_usage` answer → [`ContextBreakdown`] (issue #21). +//! +//! The control_request is the data behind claude's own `/context`: categories +//! (system prompt, tools, memory files, skills, MCP, messages, …) plus the +//! entries behind them and a split of the conversation by tool / attachment. +//! It is an internal protocol, not a published API — the live CLI already +//! answers with more than the reference schema (`id`, `kind`, `path`, …) — so +//! the parse is lenient: only `totalTokens` and a window are required, a +//! missing `id` / `kind` is derived, and anything unknown is ignored. + +use chrono::{DateTime, Utc}; +use serde_json::Value; + +use crate::{ + ContextAttachmentUsage, ContextBreakdown, ContextCategory, ContextCategoryKind, ContextItem, + ContextMessages, ContextRequestUsage, ContextToolUsage, +}; + +/// `None` only when the occupancy itself is missing — everything else degrades +/// to an empty list / `None` field. +pub(super) fn parse_context_usage(resp: &Value, now: DateTime) -> Option { + let used_tokens = num(resp.get("totalTokens"))?; + let window_tokens = num(resp.get("maxTokens")).or_else(|| num(resp.get("rawMaxTokens")))?; + let mut categories: Vec = list(resp.get("categories")) + .filter_map(parse_category) + .collect(); + fill_items(&mut categories, resp); + let auto_compact_enabled = resp + .get("isAutoCompactEnabled") + .and_then(Value::as_bool) + .unwrap_or(true); + Some(ContextBreakdown { + vendor: "claude".into(), + model: text(resp.get("model")), + captured_at: now, + used_tokens, + window_tokens, + auto_compact_threshold: num(resp.get("autoCompactThreshold")) + .filter(|_| auto_compact_enabled), + categories, + messages: resp.get("messageBreakdown").and_then(parse_messages), + last_request: resp.get("apiUsage").and_then(parse_request_usage), + }) +} + +fn parse_category(v: &Value) -> Option { + let name = text(v.get("name"))?; + let id = text(v.get("id")).unwrap_or_else(|| slug(&name)); + let kind = match v.get("kind").and_then(Value::as_str) { + Some("used") => ContextCategoryKind::Used, + Some("deferred") => ContextCategoryKind::Deferred, + Some("buffer") => ContextCategoryKind::Buffer, + Some("free") => ContextCategoryKind::Free, + // Older CLIs only flag deferral; free space and the compaction buffer + // are recognisable by the ids claude has always given them. + _ if v.get("isDeferred").and_then(Value::as_bool) == Some(true) => { + ContextCategoryKind::Deferred + } + _ if id == "free_space" => ContextCategoryKind::Free, + _ if id == "autocompact_buffer" => ContextCategoryKind::Buffer, + _ => ContextCategoryKind::Used, + }; + Some(ContextCategory { + id, + name, + tokens: num(v.get("tokens")).unwrap_or(0), + kind, + items: Vec::new(), + }) +} + +/// Hang each itemized list under the category it makes up. Deferrable +/// entries (MCP tools, deferred built-in tools) split by `isLoaded`: a loaded +/// one occupies the window and belongs to the used category, the rest to its +/// `(deferred)` twin — the same split claude's own `/context` draws. +fn fill_items(categories: &mut [ContextCategory], resp: &Value) { + let item = |v: &Value, name_key: &str, source_key: Option<&str>, detail_key: Option<&str>| { + Some(ContextItem { + name: text(v.get(name_key))?, + source: source_key.and_then(|k| text(v.get(k))), + detail: detail_key.and_then(|k| text(v.get(k))), + tokens: num(v.get("tokens")).unwrap_or(0), + loaded: v.get("isLoaded").and_then(Value::as_bool), + }) + }; + let mut put = |id: &str, entry: ContextItem| { + if let Some(c) = categories.iter_mut().find(|c| c.id == id) { + c.items.push(entry); + } + }; + for v in list(resp.get("memoryFiles")) { + if let Some(e) = item(v, "path", Some("type"), None) { + put("memory_files", e); + } + } + let skills = resp.get("skills").and_then(|s| s.get("skillFrontmatter")); + for v in list(skills) { + if let Some(e) = item(v, "name", Some("source"), Some("path")) { + put("skills", e); + } + } + for v in list(resp.get("agents")) { + if let Some(e) = item(v, "agentType", Some("source"), Some("path")) { + put("custom_agents", e); + } + } + for v in list(resp.get("mcpInstructions")) { + if let Some(e) = item(v, "serverName", None, None) { + put("mcp_instructions", e); + } + } + for v in list(resp.get("systemPromptSections")) { + if let Some(e) = item(v, "name", None, None) { + put("system_prompt", e); + } + } + for v in list(resp.get("systemTools")) { + if let Some(e) = item(v, "name", None, None) { + put("system_tools", e); + } + } + let has = |categories: &[ContextCategory], id: &str| categories.iter().any(|c| c.id == id); + let mut deferrable: Vec<(&str, &str, ContextItem)> = Vec::new(); + for v in list(resp.get("mcpTools")) { + if let Some(e) = item(v, "name", Some("serverName"), None) { + deferrable.push(("mcp_tools", "mcp_tools_deferred", e)); + } + } + for v in list(resp.get("deferredBuiltinTools")) { + if let Some(e) = item(v, "name", None, None) { + deferrable.push(("system_tools", "system_tools_deferred", e)); + } + } + let placed: Vec<(String, ContextItem)> = deferrable + .into_iter() + .filter_map(|(loaded_id, deferred_id, e)| { + let wants_deferred = e.loaded == Some(false); + let (first, second) = if wants_deferred { + (deferred_id, loaded_id) + } else { + (loaded_id, deferred_id) + }; + let id = [first, second].into_iter().find(|id| has(categories, id))?; + Some((id.to_string(), e)) + }) + .collect(); + for (id, e) in placed { + if let Some(c) = categories.iter_mut().find(|c| c.id == id) { + c.items.push(e); + } + } +} + +fn parse_messages(v: &Value) -> Option { + v.as_object()?; + let n = |k: &str| num(v.get(k)).unwrap_or(0); + Some(ContextMessages { + tool_call_tokens: n("toolCallTokens"), + tool_result_tokens: n("toolResultTokens"), + attachment_tokens: n("attachmentTokens"), + assistant_tokens: n("assistantMessageTokens"), + user_tokens: n("userMessageTokens"), + other_tokens: n("redirectedContextTokens") + n("unattributedTokens"), + tools: list(v.get("toolCallsByType")) + .filter_map(|t| { + Some(ContextToolUsage { + name: text(t.get("name"))?, + call_tokens: num(t.get("callTokens")).unwrap_or(0), + result_tokens: num(t.get("resultTokens")).unwrap_or(0), + }) + }) + .collect(), + attachments: list(v.get("attachmentsByType")) + .filter_map(|a| { + Some(ContextAttachmentUsage { + name: text(a.get("name"))?, + tokens: num(a.get("tokens")).unwrap_or(0), + }) + }) + .collect(), + }) +} + +fn parse_request_usage(v: &Value) -> Option { + v.as_object()?; + let n = |k: &str| num(v.get(k)).unwrap_or(0); + Some(ContextRequestUsage { + input_tokens: n("input_tokens"), + output_tokens: n("output_tokens"), + cache_creation_tokens: n("cache_creation_input_tokens"), + cache_read_tokens: n("cache_read_input_tokens"), + }) +} + +fn list(v: Option<&Value>) -> impl Iterator { + v.and_then(Value::as_array).into_iter().flatten() +} + +fn text(v: Option<&Value>) -> Option { + v.and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Token counts arrive as JSON numbers that are integral in practice; a float +/// is rounded rather than rejected, a negative or non-number is absent. +fn num(v: Option<&Value>) -> Option { + let v = v?; + v.as_u64().or_else(|| { + v.as_f64() + .filter(|f| f.is_finite() && *f >= 0.0) + .map(|f| f.round() as u64) + }) +} + +fn slug(name: &str) -> String { + let mut out = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + } else if !out.is_empty() && !out.ends_with('_') { + out.push('_'); + } + } + out.trim_end_matches('_').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Trimmed from a real claude 2.1.304 answer on a resumed 250k session. + fn live_payload() -> Value { + json!({ + "categories": [ + {"id":"system_prompt","name":"System prompt","tokens":2247,"color":"promptBorder","kind":"used"}, + {"id":"system_tools","name":"System tools","tokens":6714,"color":"inactive","kind":"used"}, + {"id":"mcp_tools","name":"MCP tools","tokens":941,"color":"cyan","kind":"used"}, + {"id":"mcp_instructions","name":"MCP server instructions","tokens":436,"kind":"used"}, + {"id":"mcp_tools_deferred","name":"MCP tools (deferred)","tokens":649,"isDeferred":true,"kind":"deferred"}, + {"id":"system_tools_deferred","name":"System tools (deferred)","tokens":12011,"isDeferred":true,"kind":"deferred"}, + {"id":"custom_agents","name":"Custom agents","tokens":145,"kind":"used"}, + {"id":"memory_files","name":"Memory files","tokens":11592,"kind":"used"}, + {"id":"skills","name":"Skills","tokens":79,"kind":"used"}, + {"id":"messages","name":"Messages","tokens":231616,"kind":"used"}, + {"id":"autocompact_buffer","name":"Autocompact buffer","tokens":33000,"kind":"buffer"}, + {"id":"free_space","name":"Free space","tokens":713230,"kind":"free"} + ], + "totalTokens": 253770, + "maxTokens": 1000000, + "rawMaxTokens": 1000000, + "autoCompactThreshold": 967000, + "isAutoCompactEnabled": true, + "percentage": 25, + "model": "claude-sonnet-5-5", + "gridRows": [[{"color":"x","isFilled":true}]], + "memoryFiles": [ + {"path":"/home/u/.claude/CLAUDE.md","type":"User","tokens":103}, + {"path":"/repo/CLAUDE.md","type":"Project","tokens":11489} + ], + "mcpTools": [ + {"name":"mcp__ccteam__agent","serverName":"ccteam","tokens":941,"isLoaded":true}, + {"name":"mcp__ccteam__agent_read","serverName":"ccteam","tokens":649,"isLoaded":false} + ], + "mcpInstructions": [{"serverName":"ccteam","tokens":436}], + "agents": [{"agentType":"cto","source":"projectSettings","path":"/repo/.claude/agents/cto.md","tokens":145}], + "skills": {"totalSkills":1,"includedSkills":1,"tokens":79, + "skillFrontmatter":[{"name":"graphify","source":"userSettings","path":"/home/u/.claude/skills/graphify/SKILL.md","tokens":79}]}, + "messageBreakdown": { + "toolCallTokens":36149,"toolResultTokens":62805,"attachmentTokens":69528, + "assistantMessageTokens":67354,"userMessageTokens":1732, + "redirectedContextTokens":0,"unattributedTokens":5, + "toolCallsByType":[{"name":"Bash","callTokens":34420,"resultTokens":61749}], + "attachmentsByType":[{"name":"prompt_snapshot","tokens":26483}] + }, + "apiUsage": {"input_tokens":2,"output_tokens":1259,"cache_creation_input_tokens":1022,"cache_read_input_tokens":252179} + }) + } + + fn cat<'a>(b: &'a ContextBreakdown, id: &str) -> &'a ContextCategory { + b.categories.iter().find(|c| c.id == id).expect(id) + } + + #[test] + fn a_live_answer_keeps_every_category_in_order_with_its_kind() { + let b = parse_context_usage(&live_payload(), Utc::now()).unwrap(); + assert_eq!((b.used_tokens, b.window_tokens), (253_770, 1_000_000)); + assert_eq!(b.model.as_deref(), Some("claude-sonnet-5-5")); + assert_eq!(b.auto_compact_threshold, Some(967_000)); + let ids: Vec<&str> = b.categories.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids.first(), Some(&"system_prompt")); + assert_eq!(ids.last(), Some(&"free_space")); + assert_eq!( + cat(&b, "mcp_tools_deferred").kind, + ContextCategoryKind::Deferred + ); + assert_eq!( + cat(&b, "autocompact_buffer").kind, + ContextCategoryKind::Buffer + ); + assert_eq!(cat(&b, "free_space").kind, ContextCategoryKind::Free); + let used: u64 = b + .categories + .iter() + .filter(|c| c.kind == ContextCategoryKind::Used) + .map(|c| c.tokens) + .sum(); + assert_eq!( + used, b.used_tokens, + "used categories account for the whole occupancy" + ); + } + + #[test] + fn entries_hang_under_the_category_they_make_up() { + let b = parse_context_usage(&live_payload(), Utc::now()).unwrap(); + let memory = &cat(&b, "memory_files").items; + assert_eq!(memory.len(), 2); + assert_eq!(memory[1].name, "/repo/CLAUDE.md"); + assert_eq!(memory[1].source.as_deref(), Some("Project")); + let skill = &cat(&b, "skills").items[0]; + assert_eq!(skill.source.as_deref(), Some("userSettings")); + assert!(skill + .detail + .as_deref() + .unwrap() + .ends_with("graphify/SKILL.md")); + assert_eq!(cat(&b, "custom_agents").items[0].name, "cto"); + assert_eq!(cat(&b, "mcp_instructions").items[0].name, "ccteam"); + // A loaded MCP tool occupies the window; an unloaded one does not. + let loaded = &cat(&b, "mcp_tools").items; + let deferred = &cat(&b, "mcp_tools_deferred").items; + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].loaded, Some(true)); + assert_eq!(deferred.len(), 1); + assert_eq!(deferred[0].name, "mcp__ccteam__agent_read"); + assert_eq!(deferred[0].source.as_deref(), Some("ccteam")); + } + + #[test] + fn the_conversation_splits_by_tool_and_attachment() { + let b = parse_context_usage(&live_payload(), Utc::now()).unwrap(); + let m = b.messages.unwrap(); + assert_eq!(m.tool_result_tokens, 62_805); + assert_eq!(m.assistant_tokens, 67_354); + assert_eq!(m.other_tokens, 5); + assert_eq!(m.tools[0].name, "Bash"); + assert_eq!(m.tools[0].result_tokens, 61_749); + assert_eq!(m.attachments[0].name, "prompt_snapshot"); + let r = b.last_request.unwrap(); + assert_eq!( + (r.cache_read_tokens, r.cache_creation_tokens), + (252_179, 1_022) + ); + } + + #[test] + fn a_bare_or_older_answer_degrades_instead_of_failing() { + // The minimal shape older CLIs (and the test fakes) answer with. + let b = parse_context_usage(&json!({"totalTokens":2000,"maxTokens":200000}), Utc::now()) + .unwrap(); + assert_eq!((b.used_tokens, b.window_tokens), (2_000, 200_000)); + assert!(b.categories.is_empty()); + assert_eq!(b.messages, None); + assert_eq!(b.last_request, None); + // No `id` / `kind`: derived from the name and the deferral flag. + let b = parse_context_usage( + &json!({"totalTokens":10,"rawMaxTokens":100,"isAutoCompactEnabled":false, + "autoCompactThreshold":90,"apiUsage":null, + "categories":[{"name":"MCP tools (deferred)","tokens":4.0,"isDeferred":true}, + {"name":"Free space","tokens":90}], + "mcpTools":[{"name":"t","serverName":"s","tokens":4,"isLoaded":false}]}), + Utc::now(), + ) + .unwrap(); + assert_eq!(b.window_tokens, 100); + assert_eq!(b.auto_compact_threshold, None, "auto-compaction is off"); + assert_eq!(b.categories[0].id, "mcp_tools_deferred"); + assert_eq!(b.categories[0].kind, ContextCategoryKind::Deferred); + assert_eq!(b.categories[0].items[0].name, "t"); + assert_eq!(b.categories[1].kind, ContextCategoryKind::Free); + // Occupancy is the one thing that cannot be invented. + assert_eq!( + parse_context_usage(&json!({"maxTokens":1}), Utc::now()), + None + ); + assert_eq!( + parse_context_usage(&json!({"totalTokens":1}), Utc::now()), + None + ); + } +} diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs index 53099805d..1d786e488 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs @@ -28,11 +28,13 @@ //! - **No terminal scraping**: there is no terminal — naturally satisfied. pub mod bridge; +mod context; pub mod protocol; pub mod recovery; pub mod spawn_spec; pub mod translate; pub mod transport; +mod turn_gating; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; @@ -50,13 +52,13 @@ use crate::execution::claude_common; use crate::execution::progress_bridge::{ append_event, build_chat_session_reset_event_with_reason, progress_jsonl_from_env, }; -use crate::execution::session_status::{read_status_file, write_status_file}; +use crate::execution::session_status::{read_status_file, write_context_file, write_status_file}; use crate::execution::transcript_tail::anthropic_project_dir; use crate::{ - AgentSpecBrief, AgentVendor, ChoiceOption, ChoicePrompt, DetachOutcome, Directive, - DirectiveOutcome, ExecutionMode, HarnessAdapter, HarnessError, RecoveredTurn, SpawnCtx, - ThreadEvent, ThreadHandle, ThreadStatus, TurnId, TurnInput, TurnRouting, TurnSubmission, - UnobservedTurnCtx, + AgentSpecBrief, AgentVendor, ChoiceOption, ChoicePrompt, DetachOutcome, DetachedActivity, + Directive, DirectiveOutcome, ExecutionMode, HarnessAdapter, HarnessError, RecoveredTurn, + SpawnCtx, ThreadEvent, ThreadHandle, ThreadStatus, TurnId, TurnInput, TurnRouting, + TurnSubmission, UnobservedTurnCtx, }; use bridge::{ApprovalDecision, CanUseToolResolver, SlashClass}; @@ -1182,6 +1184,15 @@ fn dead_ccteam_tool_face(sys: &protocol::SystemMsg) -> Option { /// (the caller then falls back to the usage-sum + heuristic). Short timeout — /// it must never stall the status tap. async fn get_context_usage(transport: &StreamJsonTransport) -> Option<(u64, u64)> { + get_context_breakdown(transport) + .await + .map(|b| (b.used_tokens, b.window_tokens)) +} + +/// The same `get_context_usage` answer, kept whole: what the window is made +/// of (issue #21). One request serves both the statusline numbers and the +/// context panel, so the panel costs claude nothing extra per turn. +async fn get_context_breakdown(transport: &StreamJsonTransport) -> Option { let body = transport .request_control("get_context_usage", json!({}), Duration::from_secs(3)) .await @@ -1189,13 +1200,7 @@ async fn get_context_usage(transport: &StreamJsonTransport) -> Option<(u64, u64) if body.subtype != "success" { return None; } - let resp = body.response.as_ref()?; - let used = resp.get("totalTokens").and_then(|v| v.as_u64())?; - let window = resp - .get("maxTokens") - .and_then(|v| v.as_u64()) - .or_else(|| resp.get("rawMaxTokens").and_then(|v| v.as_u64()))?; - Some((used, window)) + context::parse_context_usage(body.response.as_ref()?, Utc::now()) } /// `get_usage` control_request → ACCOUNT-level usage / rate-limits @@ -1561,155 +1566,6 @@ pub fn persisted_session_model(project_dir: &Path, sid: &str) -> Option }) } -/// Read at most the trailing `max_bytes` of a file as a UTF-8 string (lossy). -/// Bounds a `goal_status` scan so a huge transcript can't stall the statusline; -/// a partial first line (when we seek mid-file) just fails to parse and is -/// skipped by the caller. -fn read_transcript_tail(path: &Path, max_bytes: u64) -> Option { - use std::io::{Read, Seek, SeekFrom}; - let mut f = std::fs::File::open(path).ok()?; - let len = f.metadata().ok()?.len(); - if len > max_bytes { - f.seek(SeekFrom::Start(len - max_bytes)).ok()?; - } - let mut buf = Vec::new(); - f.read_to_end(&mut buf).ok()?; - Some(String::from_utf8_lossy(&buf).into_owned()) -} - -/// What the transcript says about the session's turn GATING: the goal it is -/// working toward, and how often a Stop hook has refused to let its turn end. -/// Read together because they come from ONE file read of the same tail — and -/// because they are one answer: a goal whose condition is not met yet is -/// exactly what keeps refusing (GitHub #206). -struct TurnGating { - goal: Option, - /// Refusals inside [`STOP_HOOK_RECENT_WINDOW`]. - stop_hook_blocks: u32, -} - -/// How far back a Stop-hook refusal still counts as describing the session's -/// state NOW. The transcript has no turn-boundary marker to bound the scan -/// with, and an all-time count would keep accusing a session whose hook was -/// removed hours ago — a wall-clock window is the honest bound a reader can -/// also state out loud ("N times in the last hour"). -const STOP_HOOK_RECENT_WINDOW: chrono::Duration = chrono::Duration::hours(1); - -/// The marker Claude writes for a Stop hook that denied a stop. Verified -/// against a real transcript (2026-09-11, `excore`): one record per refusal, -/// `{"type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook -/// feedback:…"}}`. -const STOP_HOOK_MARKER: &str = "Stop hook feedback"; - -/// The session's current `/goal` + Stop-hook refusals, read from the Claude -/// transcript. Claude writes the active goal as a `{type:"attachment", -/// attachment:{type: "goal_status", condition, met}}` record on the user turn — -/// it is NOT on the stream-json stdout stream and has no control_request (both -/// verified by live probe), so the only read path is the transcript jsonl (the -/// same file the TUI adapter tails — reading it is the blessed path, not -/// terminal scraping). `None` when the transcript is absent or unreadable, -/// which is what makes "cannot tell" distinguishable from "nothing refused". -fn read_turn_gating(cwd: &Path, uuid: &str) -> Option { - let path = anthropic_project_dir(cwd)?.join(format!("{uuid}.jsonl")); - let body = read_transcript_tail(&path, 8 * 1024 * 1024)?; - Some(TurnGating { - goal: parse_latest_goal_status(&body), - stop_hook_blocks: count_recent_stop_hook_blocks(&body, chrono::Utc::now()), - }) -} - -/// Count the Stop-hook refusals in the last [`STOP_HOOK_RECENT_WINDOW`]. -/// -/// Same cost discipline as [`parse_latest_goal_status`]: scan from the END, -/// `contains` pre-filter before any parse, and stop at the first refusal older -/// than the window (the transcript is append-ordered, so everything before it -/// is older still). A refusal with no readable timestamp is counted — it is a -/// refusal that happened; only a DATED one can be ruled out of the window. -fn count_recent_stop_hook_blocks(body: &str, now: chrono::DateTime) -> u32 { - let floor = now - STOP_HOOK_RECENT_WINDOW; - let mut blocks = 0u32; - for line in body.lines().rev() { - if !line.contains(STOP_HOOK_MARKER) { - continue; - } - let Ok(v) = serde_json::from_str::(line) else { - continue; - }; - // The marker has to be the message's OWN text, not a quotation of it - // inside some other record (an assistant explaining the hook, a tool - // result echoing the transcript). - let text = v - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_str()) - .unwrap_or_default(); - if !text.trim_start().starts_with(STOP_HOOK_MARKER) { - continue; - } - let dated = v - .get("timestamp") - .and_then(|t| t.as_str()) - .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) - .map(|t| t.with_timezone(&chrono::Utc)); - if dated.is_some_and(|at| at < floor) { - break; - } - blocks = blocks.saturating_add(1); - } - blocks -} - -/// Scan transcript jsonl lines for the LAST `goal_status` attachment. A later -/// `/goal clear` (or an empty condition) resets it to `None`. Pure (no fs) so -/// it is unit-testable; `read_turn_gating` wraps it with the file read. -/// -/// COST DISCIPLINE (2026-08-02): the tail handed here is up to 8 MB and this -/// runs on every statusline read — per live session on the team graph, so a -/// naive full parse is tens of millions of JSON parses per page refresh. Two -/// properties keep it cheap without changing the answer: -/// - scan from the END and return the first hit (the LAST record — identical -/// result, but an active goal is found within a few lines); -/// - `contains` pre-filter before `serde_json` (a goal record is rare, and -/// substring scanning is orders of magnitude cheaper than parsing). -fn parse_latest_goal_status(body: &str) -> Option { - for line in body.lines().rev() { - // Cheap reject: only lines that literally mention the marker can match. - if !line.contains("goal_status") { - continue; - } - let Ok(v) = serde_json::from_str::(line) else { - continue; - }; - let Some(att) = v.get("attachment") else { - continue; - }; - if att.get("type").and_then(|t| t.as_str()) != Some("goal_status") { - continue; - } - let condition = att - .get("condition") - .and_then(|c| c.as_str()) - .unwrap_or("") - .to_string(); - let met = att.get("met").and_then(|m| m.as_bool()).unwrap_or(false); - // A cleared goal carries an empty condition → no active goal. This is - // the LAST record, so it decides on its own — nothing earlier can - // revive a cleared goal. - return if condition.trim().is_empty() { - None - } else { - // Claude's goal has no state axis beyond `met` — its "why is this - // goal not advancing" fact is `stop_hook_blocks`. - Some(crate::GoalStatus { - condition, - met, - state: None, - }) - }; - } - None -} - /// Translate one outbound message and forward its events to the stream's /// channel. `Err(())` means the consumer dropped the stream (stop). async fn forward( @@ -1736,10 +1592,20 @@ async fn refresh_context_after_turn( project_dir: &Path, sid: &str, ) { - let real = get_context_usage(transport).await; + let breakdown = get_context_breakdown(transport).await; + // The turn-end breakdown doubles as the session's context snapshot, so a + // released session can still show what its window held (and `/compact`, + // a turn like any other, refreshes it). + if let Some(b) = &breakdown { + write_context_file(project_dir, sid, b); + } let snapshot = if let Ok(mut s) = status.lock() { - s.context = real.map(|(used, window)| { - crate::ContextUsage::known(used, window, crate::ContextSource::Derived) + s.context = breakdown.map(|b| { + crate::ContextUsage::known( + b.used_tokens, + b.window_tokens, + crate::ContextSource::Derived, + ) }); let is_1m = matches!(&s.context, Some(c) if c.window_tokens >= 1_000_000); if is_1m { @@ -1855,7 +1721,16 @@ impl ClaudeStreamJsonAdapter { ); } } - Self::init_transport(transport).await + let init = Self::init_transport(transport).await; + if init.is_err() { + // The failed handshake already killed the child: a record left + // behind would read as a body that exited unobserved, and the + // next spawn would post-mortem a turn that never ran. + if let Some((project_dir, sid)) = body { + crate::execution::session_body::clear(project_dir, sid); + } + } + init } /// v0.9.0 W3 (F3, tech-design §0.4/§4.3) — the remote counterpart of @@ -1992,15 +1867,9 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { "Claude has no session-mode axis (DSH agent presets only today)", )); } - // v0.8.11 E2 — pin-point isolate the official Telegram plugin (its - // bot-token getUpdates poll structurally collides with ccteam's IM - // gateway). Same managed layer the tmux path uses; only this one - // plugin. A remote project's daemon-side `project_dir` is a DATA - // HOME, not a vendor working tree: never materialize `.claude/` - // there; the satellite's real working tree owns its settings. - if ctx.remote.is_none() { - crate::execution::claude_tui::ensure_telegram_plugin_disabled(&ctx.project_dir)?; - } + // The official Telegram plugin is pinned off through `--settings` in + // `spawn_spec::build_argv` — no project settings file is written + // (docs-local issue #212). let bin = spawn_spec::claude_bin(); // §七 ⑤ — stable per-(slug,sid) uuid: the stateless resume key. let uuid = spawn_spec::deterministic_session_uuid(&ctx.slug, &ctx.sid); @@ -2053,11 +1922,11 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { }; let env = spawn_spec::build_env(&spec.role, &ctx.slug, &ctx.secret, &ctx.sid); - // Try the resume spawn first when a prior transcript exists (or, - // remote, optimistically); on failure fall back to a fresh - // `--session-id` spawn and emit a chat_session_reset with an - // explicit reason (the honest context-loss signal — never silently - // synthesize). The transport-building step differs (local child vs + // Resume when a prior transcript exists (or, remote, optimistically). + // Remote: a failed resume falls back to a fresh `--session-id` spawn + // and emits a chat_session_reset with an explicit reason (the honest + // context-loss signal — never silently synthesize). Local: NO + // fallback — see the local arm. The transport-building step differs (local child vs // `ccteam-exec.v1` WS bridge — tech-design §0.4: execution location // is a transport parameter); everything after this `match` is // identical for both. @@ -2091,31 +1960,30 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { } } else { let body = Some((ctx.project_dir.as_path(), ctx.sid.as_str())); - match Self::spawn_and_init(&make_argv(resume), &env, &ctx.cwd, body).await { - Ok(ok) => ok, - Err(resume_err) if resume => { + // `resume` here means the transcript `.jsonl` is on disk, and + // claude refuses `--session-id ` for an existing transcript + // ("Session ID … is already in use", exit 1): a fresh fallback on + // the same uuid can never start, so it only turned one failed + // resume into a second, misleading failure. Surface the resume + // failure itself. Nothing sticks: the transcript is untouched and + // the next message tries `--resume` again (docs-local issue #211). + Self::spawn_and_init(&make_argv(resume), &env, &ctx.cwd, body) + .await + .map_err(|err| { + if !resume { + return err; + } tracing::warn!( sid = %ctx.sid, slug = %ctx.slug, - error = %resume_err, - "claude-stream-json: --resume spawn failed; falling back to fresh --session-id" + error = %err, + "claude-stream-json: --resume spawn failed; the transcript is kept and the next message retries it" ); - let fresh = - Self::spawn_and_init(&make_argv(false), &env, &ctx.cwd, body).await?; - if let Some(progress_path) = progress_jsonl_from_env(&ctx.slug) { - let ev = build_chat_session_reset_event_with_reason( - &spec.role, - &ctx.sid, - "resume_failed_fallback_to_fresh", - ); - if let Err(err) = append_event(&progress_path, &ev) { - tracing::warn!(error = %err, "claude-stream-json: append reset event failed"); - } - } - fresh - } - Err(e) => return Err(e), - } + HarnessError::SpawnFailed(format!( + "claude --resume {uuid} failed: {err}. The conversation is intact on \ + disk; the next resume retries it" + )) + })? }; let identity = SessionIdentity { @@ -2692,6 +2560,17 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { .flatten() } + async fn detached_activity(&self, ctx: &UnobservedTurnCtx) -> Option { + if ctx.vendor_uuid.is_empty() { + return None; + } + let path = anthropic_project_dir(&ctx.cwd)?.join(format!("{}.jsonl", ctx.vendor_uuid)); + tokio::task::spawn_blocking(move || recovery::activity(&path, std::time::SystemTime::now())) + .await + .ok() + .flatten() + } + async fn handle_directive( &self, h: &ThreadHandle, @@ -2964,8 +2843,9 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { // (probed) — read the current one from the transcript (cwd + the vendor // uuid name the file). Overrides any goal from a persisted snapshot. // - // OFF THE RUNTIME (2026-08-02): this is SYNC fs — it seeks and reads up - // to 8 MB, then scans it. Doing that inside an `async fn` parks a Tokio + // OFF THE RUNTIME (2026-08-02): this is SYNC fs — the first read of a + // transcript scans up to 8 MB (later reads only what was appended, see + // `turn_gating`). Doing that inside an `async fn` parks a Tokio // worker thread, and this call is fanned out per live session by the // team graph, so enough live sessions starve the runtime and EVERY // endpoint the daemon serves (plus every vendor event pump) stalls @@ -2973,7 +2853,7 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { // it belongs. if let Some(cwd) = h.raw_extras.get("cwd").and_then(|v| v.as_str()) { let (cwd, uuid) = (PathBuf::from(cwd), h.identity.clone()); - let gating = tokio::task::spawn_blocking(move || read_turn_gating(&cwd, &uuid)) + let gating = tokio::task::spawn_blocking(move || turn_gating::read(&cwd, &uuid)) .await .unwrap_or(None); status.goal = gating.as_ref().and_then(|gating| gating.goal.clone()); @@ -2990,6 +2870,11 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { get_account_usage(&live.transport).await } + async fn context_breakdown(&self, h: &ThreadHandle) -> Option { + let live = self.lookup(&h.identity)?; + get_context_breakdown(&live.transport).await + } + async fn running_tasks(&self, h: &ThreadHandle) -> Vec { // The session's currently-running subagent/workflow tasks, reflected // from claude's `system:task_*` lifecycle by the status tap. A snapshot @@ -3515,10 +3400,10 @@ mod account_usage_tests { mod effort_tests { use super::protocol::{McpServerStatus, SystemMsg}; use super::{ - claude_model_options, count_recent_stop_hook_blocks, dead_ccteam_tool_face, - is_model_placeholder, normalize_effort, parse_latest_goal_status, persisted_session_model, - preserve_1m_tag, reflect_task_event, set_effort_level, split_model_effort, - task_outlives_turn, write_status_file, ClaudeModelOption, TaskTracker, EFFORT_LEVELS, + claude_model_options, dead_ccteam_tool_face, is_model_placeholder, normalize_effort, + persisted_session_model, preserve_1m_tag, reflect_task_event, set_effort_level, + split_model_effort, task_outlives_turn, write_status_file, ClaudeModelOption, TaskTracker, + EFFORT_LEVELS, }; use crate::ThreadStatus; use std::sync::Mutex; @@ -3859,73 +3744,6 @@ mod effort_tests { ); } - #[test] - fn parse_latest_goal_status_takes_the_last_record_and_honors_clear() { - // No goal record → None. - assert!(parse_latest_goal_status(r#"{"type":"user"}"#).is_none()); - // One active goal. - let one = r#"{"type":"assistant"} -{"type":"attachment","attachment":{"type":"goal_status","met":false,"condition":"ship payments"}}"#; - let g = parse_latest_goal_status(one).unwrap(); - assert_eq!(g.condition, "ship payments"); - assert!(!g.met); - // The LAST record wins (met flips true). - let two = format!( - "{one}\n{}", - r#"{"type":"attachment","attachment":{"type":"goal_status","met":true,"condition":"ship payments"}}"# - ); - assert!(parse_latest_goal_status(&two).unwrap().met); - // A trailing clear (empty condition) → no active goal. - let cleared = format!( - "{one}\n{}", - r#"{"type":"attachment","attachment":{"type":"goal_status","met":false,"condition":""}}"# - ); - assert!(parse_latest_goal_status(&cleared).is_none()); - // Half-flushed / non-JSON lines are skipped, not fatal. - assert!(parse_latest_goal_status("{partial\n{\"x\":1}").is_none()); - } - - /// GitHub #206 (P2) — a `/goal` Stop hook that refuses every stop is why a - /// session can be silent for hours, and the transcript records each - /// refusal. Count the recent ones so `/status` can say so; a refusal from - /// a hook that is long gone must not keep accusing the session. - #[test] - fn stop_hook_refusals_are_counted_within_the_recent_window() { - let now = chrono::DateTime::parse_from_rfc3339("2026-09-11T05:08:00Z") - .unwrap() - .with_timezone(&chrono::Utc); - let refusal = |ts: &str| { - format!( - r#"{{"type":"user","isMeta":true,"timestamp":"{ts}","message":{{"role":"user","content":"Stop hook feedback:\n[keep going]"}}}}"# - ) - }; - // Nothing to count. - assert_eq!(count_recent_stop_hook_blocks("", now), 0); - assert_eq!( - count_recent_stop_hook_blocks(r#"{"type":"assistant"}"#, now), - 0 - ); - // Two refusals inside the window, one hours old → the old one stops - // the scan instead of inflating the count. - let body = [ - refusal("2026-09-10T17:29:00Z"), - r#"{"type":"assistant"}"#.to_string(), - refusal("2026-09-11T04:24:00Z"), - // The millisecond form a real transcript writes (probed - // 2026-09-11: 123 refusals in `excore`'s live session). - refusal("2026-09-11T05:07:30.470Z"), - ] - .join("\n"); - assert_eq!(count_recent_stop_hook_blocks(&body, now), 2); - // A quotation of the marker is not a refusal — only a message whose - // OWN text is the hook's feedback counts. - let quoted = r#"{"type":"assistant","timestamp":"2026-09-11T05:07:40Z","message":{"role":"assistant","content":"the Stop hook feedback says to keep going"}}"#; - assert_eq!(count_recent_stop_hook_blocks(quoted, now), 0); - // An undated refusal happened; only a DATED one can be ruled out. - let undated = r#"{"type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook feedback:\nnot yet"}}"#; - assert_eq!(count_recent_stop_hook_blocks(undated, now), 1); - } - #[test] fn preserve_1m_tag_carries_user_intent_without_inventing_it() { // User set `opus[1m]`; the API id is bare → carry the tag over (so the diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/recovery.rs b/crates/ccteam-harness/src/execution/claude_stream_json/recovery.rs index f66b519ba..84e0e02a2 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/recovery.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/recovery.rs @@ -26,13 +26,14 @@ //! parse is skipped, never guessed. use std::fs::File; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::Path; +use std::time::{Duration, SystemTime}; use chrono::{DateTime, Utc}; use serde_json::Value; -use crate::RecoveredTurn; +use crate::{DetachedActivity, RecoveredTurn}; /// Read `path` and fold every assistant record with `timestamp > /// observed_until` into one [`RecoveredTurn`]. `None` when the file is @@ -177,6 +178,66 @@ pub fn recover_after( }) } +/// How much of the transcript's tail [`activity`] reads: enough for the last +/// few records of any turn, bounded so a 200 MB transcript costs nothing. +const ACTIVITY_TAIL_BYTES: u64 = 512 * 1024; + +/// Read the activity of the body writing `path`: has its newest turn ended, +/// and how long has the record been silent (the transcript and the +/// subagent transcripts beside it, `/subagents/*.jsonl`, which grow +/// while a Task subagent works and the main file does not). +/// +/// The newest turn has ended when the newest top-level conversation record +/// (`type` user|assistant, not a sidechain) is an assistant message with +/// `stop_reason: end_turn`. Anything after it — a tool result, a queued +/// prompt, a background-task notification — means a turn is running again. +/// Bookkeeping records (attachments, queue operations, titles) say nothing +/// either way and are skipped. No conversation record in the tail reads as +/// "not ended": the caller then waits for its long stall bound. +pub fn activity(path: &Path, now: SystemTime) -> Option { + let mut file = File::open(path).ok()?; + let len = file.metadata().ok()?.len(); + let start = len.saturating_sub(ACTIVITY_TAIL_BYTES); + file.seek(SeekFrom::Start(start)).ok()?; + let mut tail = Vec::new(); + file.read_to_end(&mut tail).ok()?; + let text = String::from_utf8_lossy(&tail); + let mut lines: Vec<&str> = text.lines().collect(); + if start > 0 && !lines.is_empty() { + // The first line was cut by the seek. + lines.remove(0); + } + let mut turn_ended = false; + for line in lines.iter().rev() { + let Ok(row) = serde_json::from_str::(line.trim()) else { + continue; + }; + let kind = row.get("type").and_then(Value::as_str); + if !matches!(kind, Some("user") | Some("assistant")) { + continue; + } + if row.get("isSidechain").and_then(Value::as_bool) == Some(true) { + continue; + } + turn_ended = kind == Some("assistant") + && row.pointer("/message/stop_reason").and_then(Value::as_str) == Some("end_turn"); + break; + } + let mut newest = std::fs::metadata(path).ok()?.modified().ok()?; + let subagents = path.with_extension("").join("subagents"); + if let Ok(entries) = std::fs::read_dir(&subagents) { + for entry in entries.flatten() { + if let Ok(modified) = entry.metadata().and_then(|m| m.modified()) { + newest = newest.max(modified); + } + } + } + Some(DetachedActivity { + turn_ended, + quiet_for: now.duration_since(newest).unwrap_or(Duration::ZERO), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -332,4 +393,75 @@ mod tests { let cut = Utc.with_ymd_and_hms(2026, 8, 19, 2, 9, 0).unwrap(); assert!(recover_after(&path, cut, None).is_none()); } + + fn row(kind: &str, stop: Option<&str>, sidechain: bool) -> String { + let mut message = serde_json::json!({"role": kind, "content": "x"}); + if let Some(stop) = stop { + message["stop_reason"] = Value::String(stop.into()); + } + serde_json::json!({"type": kind, "isSidechain": sidechain, "message": message, + "timestamp": "2026-10-14T00:14:54Z"}) + .to_string() + } + + fn activity_of(lines: &[String]) -> DetachedActivity { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("u.jsonl"); + std::fs::write(&path, lines.join("\n") + "\n").unwrap(); + activity(&path, SystemTime::now() + Duration::from_secs(90)).unwrap() + } + + #[test] + fn activity_reads_an_ended_turn_through_bookkeeping_records() { + let got = activity_of(&[ + row("user", None, false), + row("assistant", Some("end_turn"), false), + r#"{"type":"attachment"}"#.to_string(), + r#"{"type":"last-prompt"}"#.to_string(), + ]); + assert!(got.turn_ended); + assert!(got.quiet_for >= Duration::from_secs(89), "{got:?}"); + } + + #[test] + fn activity_reads_a_running_turn() { + // A tool call in flight, then a background-task notification that + // opened a new turn after an ended one: both are mid-turn. + assert!(!activity_of(&[row("assistant", Some("tool_use"), false)]).turn_ended); + assert!( + !activity_of(&[ + row("assistant", Some("end_turn"), false), + row("user", None, false), + ]) + .turn_ended + ); + // A subagent's record does not end the parent's turn. + assert!( + !activity_of(&[ + row("assistant", Some("tool_use"), false), + row("assistant", Some("end_turn"), true), + ]) + .turn_ended + ); + } + + #[test] + fn activity_counts_subagent_writes_as_activity() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("u.jsonl"); + std::fs::write(&path, row("assistant", Some("tool_use"), false) + "\n").unwrap(); + let old = SystemTime::now() - Duration::from_secs(3600); + File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(old) + .unwrap(); + let sub = tmp.path().join("u").join("subagents"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("agent-a.jsonl"), "{}\n").unwrap(); + let got = activity(&path, SystemTime::now()).unwrap(); + assert!(got.quiet_for < Duration::from_secs(60), "{got:?}"); + assert!(activity(&tmp.path().join("missing.jsonl"), SystemTime::now()).is_none()); + } } diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/spawn_spec.rs b/crates/ccteam-harness/src/execution/claude_stream_json/spawn_spec.rs index 2c6ad1960..50b3a06ce 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/spawn_spec.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/spawn_spec.rs @@ -111,6 +111,14 @@ pub fn build_argv(bin: &str, input: &StreamJsonSpawnInput<'_>) -> Vec { // still handled by `--strict-mcp-config`; permissions by the flags // below. "--setting-sources=user,project,local".into(), + // Isolate the official Telegram plugin (its bot-token `getUpdates` + // long-poll structurally collides with ccteam's IM gateway) through + // claude's flag-settings layer: it outranks user/project/local, so the + // pin always wins, and it lives in argv — ccteam writes no + // `.claude/settings.local.json` for it (docs-local issue #212). Only + // this one plugin; every other stays exactly as the user set it. + "--settings".into(), + flag_settings_json(), ]; // Curated per-session MCP (v0.8.24 C1): only load this config under @@ -158,6 +166,17 @@ pub fn build_argv(bin: &str, input: &StreamJsonSpawnInput<'_>) -> Vec { argv } +/// The `--settings` JSON every stream-json spawn carries: only the Telegram +/// plugin pin (see [`build_argv`]). +fn flag_settings_json() -> String { + serde_json::json!({ + "enabledPlugins": { + crate::execution::claude_tui::TELEGRAM_PLUGIN_ID: false, + } + }) + .to_string() +} + /// Env pairs forwarded into the stream-json child. Mirrors the tmux /// path's `chat_spawn_env_owned` so the in-process MCP forwarder (cto /// scheduling gate) can authenticate `session_*` calls against the @@ -324,6 +343,26 @@ mod tests { } } + /// docs-local issue #212 — the Telegram pin rides the flag-settings + /// layer (`--settings `), not a project settings file, and carries + /// nothing but that one plugin. + #[test] + fn argv_pins_telegram_off_via_flag_settings() { + for resume in [false, true] { + let argv = build_argv("claude", &input("", "u-1", resume)); + let at = argv + .iter() + .position(|a| a == "--settings") + .unwrap_or_else(|| panic!("missing --settings: {argv:?}")); + let v: serde_json::Value = + serde_json::from_str(&argv[at + 1]).expect("--settings value is JSON"); + assert_eq!( + v, + serde_json::json!({"enabledPlugins": {"telegram@claude-plugins-official": false}}) + ); + } + } + #[test] fn argv_never_injects_system_prompt() { // Zero-injection red line: --append-system-prompt must never appear. diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs b/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs index 055593968..9221b824b 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs @@ -36,7 +36,7 @@ //! [`Outbound`] and returns the events it produced. The transport's //! `events()` task owns one translator and drives it. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use serde_json::Value; @@ -185,6 +185,10 @@ pub struct StreamTranslator { last_text: Option, /// Item-id counter for tool/reasoning items within a turn. item_seq: u64, + /// The active turn's tool calls still awaiting their `tool_result`, by + /// the vendor's `tool_use_id` → (our item id, tool name, input). The + /// result arrives on a later `user` line and completes the SAME item. + open_tools: HashMap, /// Canonical model id (`message.model`) of the active turn's latest /// assistant message — the deterministic per-turn cost source. The /// `result` line carries no model, so we carry it forward from the @@ -270,15 +274,16 @@ impl StreamTranslator { self.on_system(&sys); Vec::new() } - // `user` replay echoes, control frames, partials: no neutral event - // (transcript authority + HITL handled elsewhere). A user line the - // vendor wrote ITSELF is not classified from its text — the turn it - // opens is recognised by having reserved no id (see - // [`TurnIdentity::open`]). - Outbound::User(_) - | Outbound::ControlRequest(_) - | Outbound::ControlResponse(_) - | Outbound::Other => Vec::new(), + // A `user` line completes the tool calls whose results it + // carries; replay echoes, control frames and partials produce no + // neutral event (transcript authority + HITL handled elsewhere). A + // user line the vendor wrote ITSELF is not classified from its + // text — the turn it opens is recognised by having reserved no id + // (see [`TurnIdentity::open`]). + Outbound::User(env) => self.on_tool_results(&env), + Outbound::ControlRequest(_) | Outbound::ControlResponse(_) | Outbound::Other => { + Vec::new() + } } } @@ -304,6 +309,7 @@ impl StreamTranslator { self.acc_text.clear(); self.last_text = None; self.item_seq = 0; + self.open_tools.clear(); self.turn_model = None; out.push(ThreadEvent::TurnStarted { turn_id: id, @@ -402,12 +408,24 @@ impl StreamTranslator { // within the turn (the raw tool_use id is fine too, but this // keeps them grep-correlatable with the turn). match ev { - BlockItem::Tool { name, args } => { + BlockItem::Tool { + name, + args, + tool_use_id, + } => { let id = self.next_item_id(); + if let Some(tool_use_id) = tool_use_id { + self.open_tools + .insert(tool_use_id, (id.clone(), name.clone(), args.clone())); + } out.push(ThreadEvent::ItemStarted { item: ThreadItem { id, - details: ThreadItemDetails::ToolCall { name, args }, + details: ThreadItemDetails::ToolCall { + name, + args, + output: None, + }, }, }); } @@ -425,6 +443,41 @@ impl StreamTranslator { out } + /// Complete every open tool call a `user` line answers: its + /// `tool_result` blocks become `ItemCompleted{ToolCall}` under the item id + /// the call started with, carrying the input again plus the result text. + /// A result for a call this turn never opened (a replay echo, a turn that + /// already closed) is ignored. + fn on_tool_results(&mut self, env: &MessageEnvelope) -> Vec { + let Some(Value::Array(blocks)) = env.message.get("content") else { + return Vec::new(); + }; + let mut out = Vec::new(); + for block in blocks { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + let Some(tool_use_id) = block.get("tool_use_id").and_then(Value::as_str) else { + continue; + }; + let Some((id, name, args)) = self.open_tools.remove(tool_use_id) else { + continue; + }; + let output = crate::tool_result_text(block.get("content").unwrap_or(&Value::Null)); + out.push(ThreadEvent::ItemCompleted { + item: ThreadItem { + id, + details: ThreadItemDetails::ToolCall { + name, + args, + output: Some(output), + }, + }, + }); + } + out + } + fn on_result(&mut self, r: ResultMsg) -> Vec { let mut out = Vec::new(); // A `result` can arrive without a preceding assistant block (a @@ -533,7 +586,12 @@ pub(super) fn public_text(message: &Value) -> String { /// One non-text content block worth surfacing as a progress item. enum BlockItem { - Tool { name: String, args: Value }, + Tool { + name: String, + args: Value, + /// The vendor's `tool_use` id — what its `tool_result` names. + tool_use_id: Option, + }, Reasoning(String), } @@ -563,7 +621,13 @@ fn extract_blocks(message: &Value) -> (String, Vec) { .unwrap_or("tool") .to_string(); let args = block.get("input").cloned().unwrap_or(Value::Null); - items.push(BlockItem::Tool { name, args }); + let tool_use_id = + block.get("id").and_then(|v| v.as_str()).map(str::to_string); + items.push(BlockItem::Tool { + name, + args, + tool_use_id, + }); } "thinking" => { if let Some(t) = block.get("thinking").and_then(|v| v.as_str()) { @@ -759,6 +823,57 @@ mod tests { assert_eq!(messages(&evs), vec!["running ls".to_string()]); } + fn tool_results(content: Value) -> Outbound { + Outbound::User(MessageEnvelope { + message: json!({"role": "user", "content": content}), + session_id: "u-1".into(), + parent_tool_use_id: None, + }) + } + + /// The step inspector needs what a tool returned: the `tool_result` on the + /// following `user` line completes the SAME item the call opened, with the + /// input repeated and the result as text. A result naming no open call + /// (a replay echo) adds nothing. + #[test] + fn a_tool_result_completes_the_call_it_answers() { + let mut t = StreamTranslator::new(); + let started = t.ingest(assistant(json!([ + {"type": "tool_use", "name": "Bash", "input": {"command": "ls"}, "id": "tu-1"} + ]))); + let started_id = started + .iter() + .find_map(|e| match e { + ThreadEvent::ItemStarted { item } => Some(item.id.clone()), + _ => None, + }) + .expect("tool started"); + + let done = t.ingest(tool_results(json!([ + {"type": "tool_result", "tool_use_id": "tu-1", + "content": [{"type": "text", "text": "a.txt"}, {"type": "image"}]} + ]))); + assert_eq!(done.len(), 1, "{done:?}"); + let ThreadEvent::ItemCompleted { item } = &done[0] else { + panic!("expected ItemCompleted, got {:?}", done[0]); + }; + assert_eq!(item.id, started_id, "the result completes the started item"); + let ThreadItemDetails::ToolCall { name, args, output } = &item.details else { + panic!("expected ToolCall, got {:?}", item.details); + }; + assert_eq!(name, "Bash"); + assert_eq!(args, &json!({"command": "ls"})); + assert_eq!(output.as_deref(), Some("a.txt\n[image]")); + + let echo = t.ingest(tool_results(json!([ + {"type": "tool_result", "tool_use_id": "tu-1", "content": "again"} + ]))); + assert!( + echo.is_empty(), + "an answered call is not completed twice: {echo:?}" + ); + } + #[test] fn a_text_block_is_a_message_the_moment_it_arrives() { // #209 — the gateway can only release what it has been given. A block diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/transport.rs b/crates/ccteam-harness/src/execution/claude_stream_json/transport.rs index de8cb05c0..4747e6729 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/transport.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/transport.rs @@ -115,6 +115,13 @@ impl StreamJsonTransport { .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + // The body leads its own process group, so ending it (a user stop, or + // a detached body a queued turn is waiting on) can end the background + // processes it started too — `session_body::terminate` signals the + // group. Never the daemon's group: a body that outlives the daemon + // must not be reachable by a signal aimed at the daemon either. + #[cfg(unix)] + cmd.process_group(0); let mut child = cmd .spawn() .with_context(|| format!("spawn {program} (stream-json)"))?; diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs b/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs new file mode 100644 index 000000000..4c10b5952 --- /dev/null +++ b/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs @@ -0,0 +1,308 @@ +//! What a Claude transcript says about the session's turn GATING: the goal it +//! is working toward, and how often a Stop hook has refused to let its turn +//! end (GitHub #206). Read from the transcript jsonl because neither is on the +//! stream-json stdout stream nor has a control_request (both verified by live +//! probe) — reading the transcript is the blessed path, not terminal scraping. +//! +//! COST (issue #213): `thread_status` asks for this at every turn boundary and +//! per live session on every team-graph / status poll, and a long session's +//! transcript is tens of MB. The transcript is append-only, so the answer is a +//! fold over its lines: [`read`] keeps the fold per transcript ([`FoldCache`]) +//! and feeds it only the lines appended since the last read. The first read of a file is +//! bounded to its trailing [`FIRST_READ_MAX_BYTES`]. + +use std::path::Path; +use std::sync::LazyLock; + +use crate::execution::journal::{FoldCache, LineFold}; +use crate::execution::transcript_tail::anthropic_project_dir; + +/// The session's current `/goal` + recent Stop-hook refusals. Read together +/// because they are one answer: a goal whose condition is not met yet is +/// exactly what keeps refusing. +pub(super) struct TurnGating { + pub(super) goal: Option, + /// Refusals inside [`STOP_HOOK_RECENT_WINDOW`]. + pub(super) stop_hook_blocks: u32, +} + +/// How far back a Stop-hook refusal still counts as describing the session's +/// state NOW. The transcript has no turn-boundary marker to bound the scan +/// with, and an all-time count would keep accusing a session whose hook was +/// removed hours ago — a wall-clock window is the honest bound a reader can +/// also state out loud ("N times in the last hour"). +const STOP_HOOK_RECENT_WINDOW: chrono::Duration = chrono::Duration::hours(1); + +/// The marker Claude writes for a Stop hook that denied a stop. Verified +/// against a real transcript (2026-09-11, `excore`): one record per refusal, +/// `{"type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook +/// feedback:…"}}`. +const STOP_HOOK_MARKER: &str = "Stop hook feedback"; + +/// The first read of a transcript scans at most this much of its tail, so a +/// huge transcript can't stall the statusline. Later reads fold in every +/// appended line, so whether a goal set more than this far back is still +/// visible depends on the cache's history: kept if this process has been +/// folding the transcript since before it scrolled past the bound, not seen +/// on a first read (a daemon restart, or a fold evicted past +/// [`MAX_CACHED_TRANSCRIPTS`]). +const FIRST_READ_MAX_BYTES: u64 = 8 * 1024 * 1024; + +/// Transcripts whose fold is kept (see [`FoldCache`]) — a daemon outlives +/// thousands of sessions, only a handful of which are being looked at. +const MAX_CACHED_TRANSCRIPTS: usize = 256; + +/// The fold of every transcript line read so far. +#[derive(Default)] +struct GatingScan { + /// The LAST `goal_status` record: `None` = none seen, `Some(None)` = the + /// goal was cleared (a cleared goal carries an empty condition). + goal: Option>, + /// Refusals in transcript order; `None` = undated. Pruned (see + /// [`Self::recent_blocks`]) so it holds only what can still count. + refusals: Vec>>, +} + +impl LineFold for GatingScan { + fn ingest(&mut self, line: &[u8]) { + self.ingest_line(&String::from_utf8_lossy(line)); + } +} + +impl GatingScan { + fn ingest_line(&mut self, line: &str) { + // Cheap reject before any parse: a goal record and a refusal are + // rare, and substring scanning is orders of magnitude cheaper than + // `serde_json`. + let goal = line.contains("goal_status"); + let refusal = line.contains(STOP_HOOK_MARKER); + if !goal && !refusal { + return; + } + // A half-flushed / non-JSON line is skipped, not fatal. + let Ok(v) = serde_json::from_str::(line) else { + return; + }; + if goal { + if let Some(att) = v + .get("attachment") + .filter(|att| att.get("type").and_then(|t| t.as_str()) == Some("goal_status")) + { + let condition = att + .get("condition") + .and_then(|c| c.as_str()) + .unwrap_or("") + .to_string(); + let met = att.get("met").and_then(|m| m.as_bool()).unwrap_or(false); + // The LAST record decides on its own — nothing earlier can + // revive a cleared goal. Claude's goal has no state axis + // beyond `met` — its "why is this goal not advancing" fact is + // `stop_hook_blocks`. + self.goal = Some((!condition.trim().is_empty()).then_some(crate::GoalStatus { + condition, + met, + state: None, + })); + } + } + if refusal { + // The marker has to be the message's OWN text, not a quotation of + // it inside some other record (an assistant explaining the hook, a + // tool result echoing the transcript). + let text = v + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .unwrap_or_default(); + if text.trim_start().starts_with(STOP_HOOK_MARKER) { + self.refusals.push( + v.get("timestamp") + .and_then(|t| t.as_str()) + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map(|t| t.with_timezone(&chrono::Utc)), + ); + } + } + } + + fn goal(&self) -> Option { + self.goal.clone().flatten() + } + + /// The refusals in the last [`STOP_HOOK_RECENT_WINDOW`]: walking back from + /// the newest, stop at the first refusal older than the window (the + /// transcript is append-ordered, so everything before it is older still). + /// A refusal with no readable timestamp is counted — it is a refusal that + /// happened; only a DATED one can be ruled out of the window. What lies at + /// or before that stop can never count again (the window only moves + /// forward), so it is dropped. + fn recent_blocks(&mut self, now: chrono::DateTime) -> u32 { + let floor = now - STOP_HOOK_RECENT_WINDOW; + let stale = self + .refusals + .iter() + .rposition(|at| at.is_some_and(|at| at < floor)); + if let Some(stale) = stale { + self.refusals.drain(..=stale); + } + u32::try_from(self.refusals.len()).unwrap_or(u32::MAX) + } +} + +/// The session's current `/goal` + Stop-hook refusals, read from the Claude +/// transcript (cwd + the vendor uuid name the file). Claude writes the active +/// goal as a `{type:"attachment", attachment:{type: "goal_status", condition, +/// met}}` record on the user turn. `None` when the transcript is absent or +/// unreadable, which is what makes "cannot tell" distinguishable from +/// "nothing refused". +pub(super) fn read(cwd: &Path, uuid: &str) -> Option { + let path = anthropic_project_dir(cwd)?.join(format!("{uuid}.jsonl")); + read_path(&path, chrono::Utc::now()) +} + +fn read_path(path: &Path, now: chrono::DateTime) -> Option { + static CACHE: LazyLock> = + LazyLock::new(|| FoldCache::new(MAX_CACHED_TRANSCRIPTS, Some(FIRST_READ_MAX_BYTES))); + CACHE.read(path, GatingScan::default, |scan| TurnGating { + goal: scan.goal(), + stop_hook_blocks: scan.recent_blocks(now), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn latest_goal(body: &str) -> Option { + let mut scan = GatingScan::default(); + body.lines().for_each(|line| scan.ingest_line(line)); + scan.goal() + } + + fn recent_blocks(body: &str, now: chrono::DateTime) -> u32 { + let mut scan = GatingScan::default(); + body.lines().for_each(|line| scan.ingest_line(line)); + scan.recent_blocks(now) + } + + fn refusal(ts: &str) -> String { + format!( + r#"{{"type":"user","isMeta":true,"timestamp":"{ts}","message":{{"role":"user","content":"Stop hook feedback:\n[keep going]"}}}}"# + ) + } + + fn at(ts: &str) -> chrono::DateTime { + chrono::DateTime::parse_from_rfc3339(ts) + .unwrap() + .with_timezone(&chrono::Utc) + } + + #[test] + fn latest_goal_takes_the_last_record_and_honors_clear() { + // No goal record → None. + assert!(latest_goal(r#"{"type":"user"}"#).is_none()); + // One active goal. + let one = r#"{"type":"assistant"} +{"type":"attachment","attachment":{"type":"goal_status","met":false,"condition":"ship payments"}}"#; + let g = latest_goal(one).unwrap(); + assert_eq!(g.condition, "ship payments"); + assert!(!g.met); + // The LAST record wins (met flips true). + let two = format!( + "{one}\n{}", + r#"{"type":"attachment","attachment":{"type":"goal_status","met":true,"condition":"ship payments"}}"# + ); + assert!(latest_goal(&two).unwrap().met); + // A trailing clear (empty condition) → no active goal. + let cleared = format!( + "{one}\n{}", + r#"{"type":"attachment","attachment":{"type":"goal_status","met":false,"condition":""}}"# + ); + assert!(latest_goal(&cleared).is_none()); + // Half-flushed / non-JSON lines are skipped, not fatal. + assert!(latest_goal("{partial\n{\"x\":1}").is_none()); + } + + /// GitHub #206 (P2) — a `/goal` Stop hook that refuses every stop is why a + /// session can be silent for hours, and the transcript records each + /// refusal. Count the recent ones so `/status` can say so; a refusal from + /// a hook that is long gone must not keep accusing the session. + #[test] + fn stop_hook_refusals_are_counted_within_the_recent_window() { + let now = at("2026-09-11T05:08:00Z"); + // Nothing to count. + assert_eq!(recent_blocks("", now), 0); + assert_eq!(recent_blocks(r#"{"type":"assistant"}"#, now), 0); + // Two refusals inside the window, one hours old → the old one stops + // the scan instead of inflating the count. + let body = [ + refusal("2026-09-10T17:29:00Z"), + r#"{"type":"assistant"}"#.to_string(), + refusal("2026-09-11T04:24:00Z"), + // The millisecond form a real transcript writes (probed + // 2026-09-11: 123 refusals in `excore`'s live session). + refusal("2026-09-11T05:07:30.470Z"), + ] + .join("\n"); + assert_eq!(recent_blocks(&body, now), 2); + // A quotation of the marker is not a refusal — only a message whose + // OWN text is the hook's feedback counts. + let quoted = r#"{"type":"assistant","timestamp":"2026-09-11T05:07:40Z","message":{"role":"assistant","content":"the Stop hook feedback says to keep going"}}"#; + assert_eq!(recent_blocks(quoted, now), 0); + // An undated refusal happened; only a DATED one can be ruled out. + let undated = r#"{"type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook feedback:\nnot yet"}}"#; + assert_eq!(recent_blocks(undated, now), 1); + } + + /// Issue #213: a later read folds in only what was appended — the answer + /// tracks the file (new goal, new refusals, the window sliding forward) + /// exactly as a full re-read would, and a half-flushed line waits for its + /// newline instead of being lost. + #[test] + fn a_later_read_folds_in_only_the_appended_lines() { + use std::io::Write; + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("t.jsonl"); + let goal = |cond: &str| { + format!( + r#"{{"type":"attachment","attachment":{{"type":"goal_status","met":false,"condition":"{cond}"}}}}"# + ) + }; + std::fs::write( + &path, + format!("{}\n{}\n", goal("first"), refusal("2026-09-11T05:00:00Z")), + ) + .unwrap(); + let g = read_path(&path, at("2026-09-11T05:10:00Z")).unwrap(); + assert_eq!(g.goal.unwrap().condition, "first"); + assert_eq!(g.stop_hook_blocks, 1); + + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + let second = goal("second"); + let (head, tail) = second.split_at(20); + write!(f, "{}\n{head}", refusal("2026-09-11T05:20:00Z")).unwrap(); + let g = read_path(&path, at("2026-09-11T05:30:00Z")).unwrap(); + assert_eq!( + g.goal.unwrap().condition, + "first", + "half a line is not read" + ); + assert_eq!(g.stop_hook_blocks, 2); + + writeln!(f, "{tail}").unwrap(); + // 06:10 — the 05:00 refusal has left the window. + let g = read_path(&path, at("2026-09-11T06:10:00Z")).unwrap(); + assert_eq!(g.goal.unwrap().condition, "second"); + assert_eq!(g.stop_hook_blocks, 1); + + // The path now names a different (shorter) file → the fold restarts. + std::fs::remove_file(&path).unwrap(); + std::fs::write(&path, format!("{}\n", goal(""))).unwrap(); + let g = read_path(&path, at("2026-09-11T06:10:00Z")).unwrap(); + assert!(g.goal.is_none()); + assert_eq!(g.stop_hook_blocks, 0); + } +} diff --git a/crates/ccteam-harness/src/execution/codex_app_server.rs b/crates/ccteam-harness/src/execution/codex_app_server.rs index 492f52bc3..580613f34 100644 --- a/crates/ccteam-harness/src/execution/codex_app_server.rs +++ b/crates/ccteam-harness/src/execution/codex_app_server.rs @@ -3858,6 +3858,7 @@ fn translate_item_event( .and_then(|v| v.as_str()) .unwrap_or("in_progress"), ), + output: crate::codex_item_output(&item_val), } } Some("file_change") | Some("fileChange") => { @@ -3909,6 +3910,7 @@ fn translate_item_event( .unwrap_or("") .to_string(), args: item_val.get("arguments").cloned().unwrap_or(Value::Null), + output: crate::codex_item_output(&item_val), }, Some("web_search") | Some("webSearch") => ThreadItemDetails::WebSearch { query: item_val diff --git a/crates/ccteam-harness/src/execution/codex_exec.rs b/crates/ccteam-harness/src/execution/codex_exec.rs index 33e3fa574..e3a82ed23 100644 --- a/crates/ccteam-harness/src/execution/codex_exec.rs +++ b/crates/ccteam-harness/src/execution/codex_exec.rs @@ -779,6 +779,7 @@ fn parse_jsonl_item(item: &Value) -> ThreadItem { .and_then(|v| v.as_str()) .unwrap_or("in_progress") .to_string(), + output: crate::codex_item_output(item), }, "file_change" => { let path = item @@ -816,6 +817,7 @@ fn parse_jsonl_item(item: &Value) -> ThreadItem { .unwrap_or("") .to_string(), args: item.get("arguments").cloned().unwrap_or(Value::Null), + output: crate::codex_item_output(item), }, "web_search" => ThreadItemDetails::WebSearch { query: item diff --git a/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz b/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz index 1277149e6..70d83063b 100644 Binary files a/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz and b/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz differ diff --git a/crates/ccteam-harness/src/execution/experience.rs b/crates/ccteam-harness/src/execution/experience.rs index 5ce5d31fe..2ab9ac65c 100644 --- a/crates/ccteam-harness/src/execution/experience.rs +++ b/crates/ccteam-harness/src/execution/experience.rs @@ -113,10 +113,7 @@ pub fn append_experience(project_dir: &Path, record: &ExperienceRecord) -> Resul fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let line = serde_json::to_string(record)? + "\n"; - let mut f = fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) + let mut f = crate::execution::fs_atomic::open_jsonl_append(&path) .with_context(|| format!("open {}", path.display()))?; f.write_all(line.as_bytes()) .with_context(|| format!("append to {}", path.display()))?; diff --git a/crates/ccteam-harness/src/execution/fs_atomic.rs b/crates/ccteam-harness/src/execution/fs_atomic.rs index 4d6e6f74c..d721a37be 100644 --- a/crates/ccteam-harness/src/execution/fs_atomic.rs +++ b/crates/ccteam-harness/src/execution/fs_atomic.rs @@ -110,6 +110,38 @@ pub fn read_jsonl(path: &Path) -> Result> Ok(out) } +/// Open an append-only JSONL log for appending (creating it when missing), +/// first terminating a torn last line — the write-side counterpart of +/// [`read_jsonl`]'s tolerance. +/// +/// An interrupted append (ENOSPC, crash) leaves a partial line with no +/// trailing `\n`. Appending straight after it glues the next record onto the +/// fragment, so the reader loses BOTH; after a disk-full window that meant the +/// first record written once space came back was lost too (issue #210). One +/// one-byte read at open fixes it: the fragment stays a lone unparseable line +/// and every later record lands on its own line. +/// +/// Callers must write each record with ONE `write_all` of the whole line +/// (serialize to a buffer first), so a concurrent appender never observes — +/// and "heals" — another writer's half-written record. +pub fn open_jsonl_append(path: &Path) -> std::io::Result { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .append(true) + .open(path)?; + if file.metadata()?.len() > 0 { + file.seek(SeekFrom::End(-1))?; + let mut last = [0u8; 1]; + file.read_exact(&mut last)?; + if last[0] != b'\n' { + file.write_all(b"\n")?; + } + } + Ok(file) +} + /// A staging file that removes itself unless the rename claimed it. struct TmpFile { path: std::path::PathBuf, @@ -228,6 +260,42 @@ mod tests { assert_eq!(tmp_files(dir.path()), Vec::::new()); } + /// Issue #210: an append after a torn (ENOSPC-truncated) last line starts + /// a fresh line, so the new record survives; a healthy or empty log gets + /// no extra blank line. + #[test] + fn jsonl_append_terminates_a_torn_tail_first() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("log.jsonl"); + + open_jsonl_append(&path) + .unwrap() + .write_all(b"{\"n\":1}\n") + .unwrap(); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(b"{\"n\":2,\"tru") + .unwrap(); + open_jsonl_append(&path) + .unwrap() + .write_all(b"{\"n\":3}\n") + .unwrap(); + open_jsonl_append(&path) + .unwrap() + .write_all(b"{\"n\":4}\n") + .unwrap(); + + let rows: Vec = read_jsonl(&path).unwrap(); + let ns: Vec = rows.iter().map(|r| r["n"].as_i64().unwrap()).collect(); + assert_eq!(ns, vec![1, 3, 4], "only the torn fragment is lost"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "{\"n\":1}\n{\"n\":2,\"tru\n{\"n\":3}\n{\"n\":4}\n" + ); + } + /// Two writers of ONE target never share a staging file. Before this, /// every call staged through `.tmp`: the second writer truncated the /// first one's tmp and whichever renamed second failed diff --git a/crates/ccteam-harness/src/execution/journal.rs b/crates/ccteam-harness/src/execution/journal.rs index 2862c6518..e2015d7f0 100644 --- a/crates/ccteam-harness/src/execution/journal.rs +++ b/crates/ccteam-harness/src/execution/journal.rs @@ -456,6 +456,135 @@ impl ReverseLines { } } +/// A fold over the lines of an append-only journal — see [`FoldCache`]. +pub trait LineFold: Send + 'static { + /// One complete, non-blank line (without its `\n`), in file order. + fn ingest(&mut self, line: &[u8]); +} + +/// Per-file folds over append-only journals, each fed only the lines appended +/// since its last read (issue #213). +/// +/// A question asked of a journal at every turn boundary or status poll ("how +/// many user turns", "the current goal") used to re-read the whole file each +/// time — O(session lifetime) per ask. Since the file only grows, the answer +/// is a fold over its lines: keep the fold and its byte offset, and on the +/// next ask read from that offset. A trailing line without `\n` is left for +/// the next read (its writer may still be extending it). +/// +/// CONTRACT — only for files that are append-only for the life of the path: +/// the cache tells "same file, grown" from "different file" by (dev, inode) +/// and `offset <= len` alone. A path whose file shrank or changed inode +/// restarts its fold, but a file rewritten IN PLACE to at least its old +/// length (or a new file that lands on the old inode and is already longer) +/// is indistinguishable from an append, and its fold silently carries the +/// old lines. turns.jsonl and the vendor transcripts are append-only; do not +/// put a rewritten file behind this. +/// +/// Bounded: past `capacity` files the least recently read fold is dropped, and +/// rebuilt from the file if asked again. +pub struct FoldCache { + entries: std::sync::Mutex>>, + capacity: usize, + first_read_max: Option, +} + +struct FoldEntry { + file_id: (u64, u64), + /// Byte position just past the last complete line folded in. + offset: u64, + fold: F, + last_read: std::time::Instant, +} + +impl FoldCache { + /// `first_read_max` bounds a fold's FIRST read to the file's trailing + /// bytes (`None` = the whole file); the partial first line it may start + /// mid-way through just fails to parse in the fold. + pub fn new(capacity: usize, first_read_max: Option) -> Self { + Self { + entries: std::sync::Mutex::new(std::collections::HashMap::new()), + capacity: capacity.max(1), + first_read_max, + } + } + + /// Bring `path`'s fold up to date and answer `query` from it. `None` when + /// the file is absent or unreadable. + pub fn read( + &self, + path: &Path, + init: impl FnOnce() -> F, + query: impl FnOnce(&mut F) -> R, + ) -> Option { + let mut file = File::open(path).ok()?; + let meta = file.metadata().ok()?; + let (file_id, len) = (fold_file_id(&meta), meta.len()); + // Take the entry OUT for the read, so the lock is never held across + // file I/O (two concurrent asks of one file each fold their own; the + // later insert wins, both are correct). + let cached = self + .lock() + .remove(path) + .filter(|entry| entry.file_id == file_id && entry.offset <= len); + let mut entry = cached.unwrap_or_else(|| FoldEntry { + file_id, + offset: self.first_read_max.map_or(0, |max| len.saturating_sub(max)), + fold: init(), + last_read: std::time::Instant::now(), + }); + if len > entry.offset { + file.seek(SeekFrom::Start(entry.offset)).ok()?; + let mut buf = Vec::new(); + (&mut file) + .take(len - entry.offset) + .read_to_end(&mut buf) + .ok()?; + BYTES_READ.fetch_add(buf.len() as u64, Ordering::Relaxed); + let complete = buf.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1); + for line in buf[..complete].split(|b| *b == b'\n') { + let line = trim_ascii(line); + if !line.is_empty() { + entry.fold.ingest(line); + } + } + entry.offset += complete as u64; + } + let answer = query(&mut entry.fold); + entry.last_read = std::time::Instant::now(); + let mut entries = self.lock(); + if entries.len() >= self.capacity && !entries.contains_key(path) { + if let Some(oldest) = entries + .iter() + .min_by_key(|(_, entry)| entry.last_read) + .map(|(path, _)| path.clone()) + { + entries.remove(&oldest); + } + } + entries.insert(path.to_path_buf(), entry); + Some(answer) + } + + fn lock( + &self, + ) -> std::sync::MutexGuard<'_, std::collections::HashMap>> + { + self.entries.lock().unwrap_or_else(|e| e.into_inner()) + } +} + +#[cfg(unix)] +fn fold_file_id(meta: &std::fs::Metadata) -> (u64, u64) { + use std::os::unix::fs::MetadataExt; + (meta.dev(), meta.ino()) +} + +#[cfg(not(unix))] +fn fold_file_id(_meta: &std::fs::Metadata) -> (u64, u64) { + (0, 0) +} + fn trim_ascii(mut bytes: &[u8]) -> &[u8] { while bytes.first().is_some_and(u8::is_ascii_whitespace) { bytes = &bytes[1..]; @@ -496,6 +625,35 @@ mod tests { std::fs::write(path, raw).unwrap(); } + /// Counts the lines folded in, so a test sees exactly what each read fed. + struct Lines(Vec); + impl LineFold for Lines { + fn ingest(&mut self, line: &[u8]) { + self.0.push(String::from_utf8_lossy(line).into_owned()); + } + } + + /// Issue #213: a fold is fed each complete line once; a file that shrank + /// restarts its fold; past capacity the least recently read fold goes. + #[test] + fn fold_cache_feeds_each_line_once_and_stays_bounded() { + let dir = tempfile::tempdir().unwrap(); + let (a, b) = (dir.path().join("a.jsonl"), dir.path().join("b.jsonl")); + let cache = FoldCache::new(1, None); + let read = |path: &Path| cache.read(path, || Lines(Vec::new()), |f| f.0.clone()); + assert_eq!(read(&a), None, "absent file"); + std::fs::write(&a, "one\n\ntw").unwrap(); + assert_eq!(read(&a).unwrap(), vec!["one"]); + let mut f = std::fs::OpenOptions::new().append(true).open(&a).unwrap(); + writeln!(f, "o").unwrap(); + assert_eq!(read(&a).unwrap(), vec!["one", "two"]); + std::fs::write(&a, "x\n").unwrap(); + assert_eq!(read(&a).unwrap(), vec!["x"], "shrunk file restarts"); + std::fs::write(&b, "y\n").unwrap(); + assert_eq!(read(&b).unwrap(), vec!["y"]); + assert_eq!(cache.lock().len(), 1, "capacity 1 evicted a"); + } + #[test] fn tail_and_last_skip_torn_utf8_and_corrupt_trailing_rows() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ccteam-harness/src/execution/pi_rpc/translate.rs b/crates/ccteam-harness/src/execution/pi_rpc/translate.rs index 15a5e5c00..ba30adbf0 100644 --- a/crates/ccteam-harness/src/execution/pi_rpc/translate.rs +++ b/crates/ccteam-harness/src/execution/pi_rpc/translate.rs @@ -113,7 +113,11 @@ impl PiTurnTranslator { events: vec![ThreadEvent::ItemStarted { item: ThreadItem { id: format!("{}-tool-{}", active.turn_id, active.item_seq), - details: ThreadItemDetails::ToolCall { name, args }, + details: ThreadItemDetails::ToolCall { + name, + args, + output: None, + }, }, }], settled: false, @@ -128,6 +132,7 @@ impl PiTurnTranslator { details: ThreadItemDetails::CommandExecution { cmd: name, status: if is_error { "failed" } else { "completed" }.to_string(), + output: None, }, }, }], diff --git a/crates/ccteam-harness/src/execution/progress_bridge.rs b/crates/ccteam-harness/src/execution/progress_bridge.rs index 974ebccec..44857474f 100644 --- a/crates/ccteam-harness/src/execution/progress_bridge.rs +++ b/crates/ccteam-harness/src/execution/progress_bridge.rs @@ -769,10 +769,7 @@ fn append_serialized(path: &Path, line: &[u8]) -> Result { recover_progress_checkpoint_locked(path)?; } - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(path) + let mut file = crate::execution::fs_atomic::open_jsonl_append(path) .with_context(|| format!("open {}", path.display()))?; file.write_all(line) .with_context(|| format!("write event to {}", path.display()))?; diff --git a/crates/ccteam-harness/src/execution/session_body.rs b/crates/ccteam-harness/src/execution/session_body.rs index 070b919aa..f88cb0507 100644 --- a/crates/ccteam-harness/src/execution/session_body.rs +++ b/crates/ccteam-harness/src/execution/session_body.rs @@ -28,9 +28,18 @@ //! - Same-uid soft identity, like every other rung: a same-uid process can //! forge a `body.json`. What this prevents is a *misattribution by ccteam //! itself* (two bodies for one sid), not an attack. -//! - [`terminate`] exists for EXPLICIT user stops (`/stop`, `agent_stop`, -//! project stop). The daemon never calls it on its own initiative — a -//! detached body is waited for, not killed (AGENTS.md §三 "永不主动 kill"). +//! - [`terminate`] ends a body's PROCESS GROUP (the body and every +//! background process it started — bodies are spawned as group leaders) +//! on an EXPLICIT user stop (`/stop`, `agent_stop`, project stop): the +//! session is over, and so is its work. A body that is not its own group +//! leader (spawned by an older build, inside the daemon's group) gets only +//! its own pid signalled: ccteam never signals a group it did not create. +//! - [`kill_body`] ends ONLY the body's process, keeping the session (a user +//! `/interrupt`, or a turn queued behind a detached body that is idle or +//! stalled): AGENTS.md §三 "kill 的是进程,不是会话". The processes it +//! started are the user's work and are never signalled; the ones still +//! running afterwards are reported ([`Orphan`]) so nobody has to guess what +//! holds a port. use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -176,26 +185,35 @@ pub fn body_is_alive(body: &SessionBody, sid: &str) -> bool { } } -/// Terminate a body that a user EXPLICITLY asked to stop: SIGTERM, wait up to -/// `grace`, then SIGKILL. Each signal re-verifies the fingerprint first so a -/// pid recycled mid-call is never touched. Returns `Ok(true)` if the process -/// is gone afterwards. +/// End a body and the background processes it started: SIGTERM, wait up to +/// `grace` for the body to go, then SIGKILL. Each signal re-verifies the +/// fingerprint first so a pid recycled mid-call is never touched, and goes to +/// the body's process group when the body leads its own group (see the +/// module doc) — `claude` answers SIGTERM by ending its background tasks +/// itself, and the group SIGKILL sweeps whatever ignored that, so no orphaned +/// dev server is left holding a port the resumed session needs. Returns +/// `Ok(true)` if the body is gone afterwards. pub fn terminate(body: &SessionBody, sid: &str, grace: Duration) -> Result { if !body_is_alive(body, sid) { + // The body is gone — another end (an `/interrupt`) may have killed + // just it — but its group may not be: a stop still ends what the + // body started. + sweep_group_left_by(body.pid, sid)?; return Ok(true); } - signal(body.pid, libc::SIGTERM)?; + let group = leads_own_group(body.pid); + signal_body(body.pid, group, libc::SIGTERM)?; let started = Instant::now(); - while started.elapsed() < grace { - if !body_is_alive(body, sid) { - return Ok(true); - } + while started.elapsed() < grace && body_is_alive(body, sid) { std::thread::sleep(Duration::from_millis(100)); } - if !body_is_alive(body, sid) { - return Ok(true); + if body_is_alive(body, sid) { + signal_body(body.pid, group, libc::SIGKILL)?; + } else if group { + // The leader is gone; sweep group members that outlived it. The + // group id cannot be reused while any member is alive. + signal_group(body.pid, libc::SIGKILL)?; } - signal(body.pid, libc::SIGKILL)?; // SIGKILL is not instantaneous; give the kernel a moment to reap. let started = Instant::now(); while started.elapsed() < Duration::from_secs(2) { @@ -207,6 +225,362 @@ pub fn terminate(body: &SessionBody, sid: &str, grace: Duration) -> Result Ok(!body_is_alive(body, sid)) } +/// SIGKILL the process group a dead body led, when a member is still +/// running AND names this sid in its environment (the body's children +/// inherit `CCTEAM_CHAT_SID`). A group id cannot be reused while a member +/// lives; the environment check keeps a group that merely shares the number +/// — the body's pid recycled as another group's leader — untouched. +fn sweep_group_left_by(pgid: u32, sid: &str) -> Result<()> { + let Ok(entries) = std::fs::read_dir("/proc") else { + return Ok(()); + }; + let ours = entries.flatten().any(|entry| { + let Some(pid) = entry + .file_name() + .to_str() + .and_then(|n| n.parse::().ok()) + else { + return false; + }; + let in_group = std::fs::read_to_string(format!("/proc/{pid}/stat")) + .ok() + .and_then(|stat| { + stat.rsplit_once(')') + .and_then(|(_, rest)| rest.split_whitespace().nth(2)) + .and_then(|g| g.parse::().ok()) + }) + == Some(pgid); + in_group && environ_names_sid(pid, sid) == Some(true) + }); + if ours { + signal_group(pgid, libc::SIGKILL)?; + } + Ok(()) +} + +/// Signal the body, as a group when it leads one. +fn signal_body(pid: u32, group: bool, sig: libc::c_int) -> Result<()> { + if group { + signal_group(pid, sig) + } else { + signal(pid, sig) + } +} + +/// True iff `pid` is the leader of its own process group (pgid == pid). +#[cfg(unix)] +fn leads_own_group(pid: u32) -> bool { + let Ok(raw) = libc::pid_t::try_from(pid) else { + return false; + }; + // SAFETY: getpgid(2) reads a process attribute; no memory is touched. + let pgid = unsafe { libc::getpgid(raw) }; + pgid == raw +} + +#[cfg(not(unix))] +fn leads_own_group(_pid: u32) -> bool { + false +} + +/// A process a body started that is still running after the body was ended. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Orphan { + pub pid: u32, + /// Its command line, redacted and shortened for a receipt + /// ([`redact_cmdline`]). + pub cmd: String, + /// Its OS start-time fingerprint, so a report made later never names a + /// process that merely reuses the pid. + pub fingerprint: String, +} + +impl Orphan { + /// Is this exact process (pid + start time) still running? + pub fn still_running(&self) -> bool { + !process_is_zombie(self.pid) + && process_fingerprint(self.pid).is_ok_and(|now| now == self.fingerprint) + } +} + +/// Longest command line an [`Orphan`] carries. +const ORPHAN_CMD_MAX: usize = 80; + +/// Every live descendant of `pid` with its start fingerprint, both taken +/// from ONE read of `/proc//stat` (Linux; empty where there is no +/// `/proc`). +fn descendants(pid: u32) -> Vec<(u32, String)> { + let Ok(entries) = std::fs::read_dir("/proc") else { + return Vec::new(); + }; + // (pid, ppid, starttime) + let mut table: Vec<(u32, u32, String)> = Vec::new(); + for entry in entries.flatten() { + let Some(child) = entry + .file_name() + .to_str() + .and_then(|n| n.parse::().ok()) + else { + continue; + }; + let Ok(stat) = std::fs::read_to_string(format!("/proc/{child}/stat")) else { + continue; + }; + let Some((_, rest)) = stat.rsplit_once(')') else { + continue; + }; + let fields: Vec<&str> = rest.split_whitespace().collect(); + // After `)`: state(0) ppid(1) … starttime(19). + let (Some(ppid), Some(start)) = ( + fields.get(1).and_then(|p| p.parse::().ok()), + fields.get(19), + ) else { + continue; + }; + table.push((child, ppid, (*start).to_string())); + } + let mut out: Vec<(u32, String)> = Vec::new(); + let mut frontier = vec![pid]; + while let Some(parent) = frontier.pop() { + for (child, ppid, start) in &table { + if *ppid == parent && !out.iter().any(|(p, _)| p == child) { + out.push((*child, start.clone())); + frontier.push(*child); + } + } + } + out +} + +fn cmdline_argv(pid: u32) -> Vec { + std::fs::read(format!("/proc/{pid}/cmdline")) + .unwrap_or_default() + .split(|b| *b == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect() +} + +/// A command line fit for an IM receipt and a ledger: what the user would +/// recognise, never a secret or the home path. +/// +/// - claude's Bash tool wraps every command as `bash -c 'source +/// ~/.claude/shell-snapshots/… && eval '' …'`; the eval payload is +/// what was run, so that is shown instead of the wrapper. +/// - Redacted to `***`: `key=value` and `--key value` when the key names a +/// token, secret, password, passwd, key, auth or credential; `Bearer `; +/// the `user:pass@` of a URL; values with a known token prefix (`sk-`, +/// `ghp_`, `gho_`, `github_pat_`, `xox?-`, `AKIA`); any run of 32 or more +/// base64/hex characters. +/// - Control characters and backticks are replaced (it is shown inside a +/// Markdown code span), the home directory reads as `~`, and the result is +/// cut at [`ORPHAN_CMD_MAX`] characters. +pub fn redact_cmdline(argv: &[String], home: Option<&str>) -> String { + use std::sync::OnceLock; + // (pattern, match on the ASCII-lowercased text?). Each pattern names the + // secret part `v` (or `v2`/`v3` for the quoted alternatives); only that + // is replaced. ASCII lowercasing keeps byte + // offsets, so a match on the lowercased copy maps onto the original. + static RULES: OnceLock> = OnceLock::new(); + let rules = RULES.get_or_init(|| { + // Key names that mark their value secret; `pwd` only as a whole word. + const SENSITIVE: &str = "(?:[a-z0-9_.-]*(?:token|secret|password|passwd|pass|key|auth|credential)[a-z0-9_.-]*|pwd)"; + // A value: single-quoted, double-quoted, or bare. + const VALUE: &str = r#"(?:'(?P[^']*)'|"(?P[^"]*)"|(?P[^ \t'"]+))"#; + [ + // An HTTP auth scheme's credentials. + (r#"(?:bearer|basic)[ \t]+(?P[^ \t'"]+)"#.to_string(), true), + // URL userinfo, the user possibly empty (`redis://:pw@h`). + (r#"://(?P[^/ \t@]*:[^/ \t@]+)@"#.to_string(), false), + // KEY=value, --key=value. + (format!(r#"(?:^|[^a-z0-9_.-])-{{0,2}}{SENSITIVE}={VALUE}"#), true), + // --key value. + ( + format!( + r#"(?:^|[ \t])--?{SENSITIVE}[ \t]+(?:'(?P[^']*)'|"(?P[^"]*)"|(?P[^ \t'"-][^ \t'"]*))"# + ), + true, + ), + // A header or a JSON member: `X-Api-Key: v`, `{"token":"v"}`. + ( + r#"(?:^|[^a-z0-9_-])[a-z0-9_-]*(?:token|secret|key|auth|password|pass)[a-z0-9_-]*"?[ \t]*:[ \t]*"?(?:(?:bearer|basic)[ \t]+)?(?P[^ \t'",}]+)"# + .to_string(), + true, + ), + // `curl -u user:pass`. + (r#"(?:^|[ \t])-u[ \t]+[^ \t:'"/]+:(?P[^ \t'"/][^ \t'"]*)"#.to_string(), false), + // mysql / mariadb tools: `-pSECRET`. + ( + r#"(?:mysql|mariadb|mysqldump|mysqladmin)(?:[ \t][^\n]*?)?[ \t]-p(?P[^ \t'"]+)"# + .to_string(), + true, + ), + // A cookie header carries a session: the whole value. + ( + r#"(?:^|[^a-z0-9_-])(?:set-)?cookie[ \t]*:[ \t]*(?P[^'"\n]+)"#.to_string(), + true, + ), + // `docker login … -p SECRET`. + ( + r#"docker[ \t]+login(?:[ \t][^|;&\n]*?)?[ \t]-p[ \t]+(?P[^ \t'"]+)"#.to_string(), + true, + ), + // `echo SECRET | docker login --password-stdin` and the like. + ( + r#"(?:echo|printf)[ \t]+(?:-n[ \t]+)?(?:'(?P[^']*)'|"(?P[^"]*)"|(?P[^ \t'"|]+))[ \t]*\|[^|\n]*--password-stdin"# + .to_string(), + true, + ), + // `sshpass -p SECRET`. + (r#"sshpass[ \t]+-p[ \t]+(?P[^ \t'"]+)"#.to_string(), true), + // Values with a known token prefix. + ( + r"(?:^|[^A-Za-z0-9_])(?P(?:sk-|ghp_|gho_|github_pat_|xox[abposr]-|AKIA)[A-Za-z0-9_-]+)" + .to_string(), + false, + ), + // Anything long enough to be a key. + (r"(?P[A-Za-z0-9+/=_-]{32,})".to_string(), false), + ] + .into_iter() + .map(|(pattern, lower)| (regex::Regex::new(&pattern).expect("redaction rule"), lower)) + .collect() + }); + let joined = argv.join(" "); + let mut cmd = claude_bash_payload(argv).unwrap_or(joined); + if let Some(home) = home.filter(|h| h.len() > 1) { + cmd = cmd.replace(home, "~"); + } + for (rule, lower) in rules { + let haystack = if *lower { + cmd.to_ascii_lowercase() + } else { + cmd.clone() + }; + let mut out = String::with_capacity(cmd.len()); + let mut last = 0; + for caps in rule.captures_iter(&haystack) { + let Some(v) = caps + .name("v") + .or_else(|| caps.name("v2")) + .or_else(|| caps.name("v3")) + else { + continue; + }; + out.push_str(&cmd[last..v.start()]); + out.push_str("***"); + last = v.end(); + } + out.push_str(&cmd[last..]); + cmd = out; + } + let cmd: String = cmd + .chars() + .map(|c| match c { + '`' => '\'', + c if c.is_control() => ' ', + c => c, + }) + .collect(); + let cmd = cmd.trim().to_string(); + if cmd.chars().count() > ORPHAN_CMD_MAX { + let cut: String = cmd.chars().take(ORPHAN_CMD_MAX).collect(); + format!("{cut}…") + } else { + cmd + } +} + +/// The command claude's Bash tool actually ran, out of its +/// `bash -c '… shell-snapshots … eval '' …'` wrapper. +fn claude_bash_payload(argv: &[String]) -> Option { + let [shell, flag, script, ..] = argv else { + return None; + }; + if !shell.ends_with("bash") || flag != "-c" || !script.contains("shell-snapshots") { + return None; + } + let start = script.find("eval '")? + "eval '".len(); + let rest = &script[start..]; + // The payload is single-quoted, with each inner quote spelled '"'"'. + let unescaped = rest.replace("'\"'\"'", "\u{0}"); + let end = unescaped.find('\'')?; + Some(unescaped[..end].replace('\u{0}', "'")) +} + +/// End a detached body's PROCESS and nothing else: one SIGKILL to the +/// verified pid. `claude` answers SIGTERM by killing every background task it +/// started (measured, 2.1.296), and those are the user's work — a dev server, +/// a stack, a job scheduled for later — so no SIGTERM, no group signal. The +/// descendants are listed (fingerprinted) BEFORE the kill, and the ones still +/// running after it come back as [`Orphan`]s: never signalled, only reported. +/// +/// A body killed mid-write can leave its transcript's last line torn — the +/// case for `/interrupt` and for a body ended as stalled; claude resumes past +/// a torn line (measured). `Ok(None)` when the body survived the SIGKILL. +pub fn kill_body(body: &SessionBody, sid: &str) -> Result>> { + if !body_is_alive(body, sid) { + return Ok(Some(Vec::new())); + } + let children = descendants(body.pid); + let home = dirs::home_dir().map(|h| h.display().to_string()); + let cmds: Vec = children + .iter() + .map(|(pid, _)| redact_cmdline(&cmdline_argv(*pid), home.as_deref())) + .collect(); + // The /proc walk took time: re-verify the incarnation right before the + // signal, so a pid recycled meanwhile is never touched. + if !body_is_alive(body, sid) { + return Ok(Some(Vec::new())); + } + signal(body.pid, libc::SIGKILL)?; + let started = Instant::now(); + while body_is_alive(body, sid) { + if started.elapsed() > Duration::from_secs(2) { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(Some( + children + .into_iter() + .zip(cmds) + .map(|((pid, fingerprint), cmd)| Orphan { + pid, + cmd, + fingerprint, + }) + .filter(Orphan::still_running) + .collect(), + )) +} + +/// kill(2) to the process group `pgid` (ESRCH — the group is gone — is +/// success). +#[cfg(unix)] +fn signal_group(pgid: u32, sig: libc::c_int) -> Result<()> { + let raw = libc::pid_t::try_from(pgid).context("pgid out of range")?; + if raw <= 1 { + anyhow::bail!("refusing to signal process group {raw}"); + } + // SAFETY: kill(2) on a group this module verified it leads; the return + // value reports failure, no memory is touched. + let rc = unsafe { libc::kill(-raw, sig) }; + if rc == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + Err(err).with_context(|| format!("signal {sig} to body process group {pgid}")) +} + +#[cfg(not(unix))] +fn signal_group(_pgid: u32, _sig: i32) -> Result<()> { + anyhow::bail!("session bodies are only managed on Unix") +} + #[cfg(unix)] fn signal(pid: u32, sig: libc::c_int) -> Result<()> { let raw = libc::pid_t::try_from(pid).context("pid out of range")?; @@ -449,4 +823,295 @@ mod tests { record(tmp.path(), "", Some(std::process::id()), "test").unwrap(); assert!(!body_path(tmp.path(), "").exists()); } + + #[test] + fn terminate_ends_a_group_leader_body_with_its_background_children() { + use std::os::unix::process::CommandExt; + let tmp = tempfile::tempdir().unwrap(); + let pidfile = tmp.path().join("bg.pid"); + // A body that leads its own group and started a background process + // that ignores SIGTERM (an orphan-to-be dev server). + let mut child = Command::new("sh") + .arg("-c") + .arg(format!( + "sh -c 'trap \"\" TERM; sleep 60' & echo $! > {}; wait", + pidfile.display() + )) + .env(BODY_SID_ENV, "s13") + .process_group(0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sh"); + record(tmp.path(), "s13", Some(child.id()), "test").unwrap(); + let started = Instant::now(); + let bg = loop { + if let Some(pid) = std::fs::read_to_string(&pidfile) + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + break pid; + } + assert!(started.elapsed() < Duration::from_secs(5), "no bg pid"); + std::thread::sleep(Duration::from_millis(20)); + }; + let body = read(tmp.path(), "s13").unwrap(); + // The test process is the body's parent: reap it while waiting. + let reaper = std::thread::spawn(move || child.wait()); + assert!(terminate(&body, "s13", Duration::from_millis(500)).unwrap()); + reaper.join().unwrap().unwrap(); + let started = Instant::now(); + while process_exists(bg) && started.elapsed() < Duration::from_secs(3) { + std::thread::sleep(Duration::from_millis(50)); + } + assert!( + !process_exists(bg), + "the background child died with its group" + ); + } + + #[test] + fn terminate_never_signals_a_group_the_body_does_not_lead() { + // A body inside ANOTHER group (the test runner's, standing in for an + // older daemon's): only the body's own pid may be signalled. + let tmp = tempfile::tempdir().unwrap(); + let mut child = spawn_sleep("s14"); + record(tmp.path(), "s14", Some(child.id()), "test").unwrap(); + assert!(!leads_own_group(child.id())); + let body = read(tmp.path(), "s14").unwrap(); + let reaper = std::thread::spawn(move || child.wait()); + assert!(terminate(&body, "s14", Duration::from_secs(3)).unwrap()); + reaper.join().unwrap().unwrap(); + // Still here: the runner's group was not signalled. + } + + #[cfg(target_os = "linux")] + #[test] + fn kill_body_ends_only_the_body_and_reports_its_surviving_children() { + let tmp = tempfile::tempdir().unwrap(); + let pidfile = tmp.path().join("bg.pid"); + let mut child = Command::new("sh") + .arg("-c") + .arg(format!("sleep 61 & echo $! > {}; wait", pidfile.display())) + .env(BODY_SID_ENV, "s15") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sh"); + record(tmp.path(), "s15", Some(child.id()), "test").unwrap(); + let started = Instant::now(); + let bg = loop { + if let Some(pid) = std::fs::read_to_string(&pidfile) + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + break pid; + } + assert!(started.elapsed() < Duration::from_secs(5), "no bg pid"); + std::thread::sleep(Duration::from_millis(20)); + }; + let body = read(tmp.path(), "s15").unwrap(); + let reaper = std::thread::spawn(move || child.wait()); + let orphans = kill_body(&body, "s15").unwrap().expect("body gone"); + reaper.join().unwrap().unwrap(); + assert!( + process_exists(bg), + "the background child is never signalled" + ); + assert_eq!(orphans.len(), 1, "{orphans:?}"); + assert_eq!(orphans[0].pid, bg); + assert_eq!(orphans[0].cmd, "sleep 61"); + assert!(orphans[0].still_running()); + signal(bg, libc::SIGKILL).unwrap(); + } + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|p| p.to_string()).collect() + } + + #[test] + fn redact_cmdline_hides_secrets_and_the_home_path() { + let home = Some("/home/alice"); + let cases: &[(&[&str], &str)] = &[ + (&["npm", "run", "dev"], "npm run dev"), + ( + &["deploy", "--api-key", "abc123", "--port", "80"], + "deploy --api-key *** --port 80", + ), + ( + &["env", "GITHUB_TOKEN=abc", "PASSWORD=hunter2", "x"], + "env GITHUB_TOKEN=*** PASSWORD=*** x", + ), + ( + &["curl", "-H", "Authorization: Bearer eyJabc.def"], + "curl -H Authorization: Bearer ***", + ), + ( + &["git", "clone", "https://bob:pw@github.com/x"], + "git clone https://***@github.com/x", + ), + ( + &[ + "run", + "sk-proj-abcdef", + "ghp_xyz", + "xoxb-1-2", + "AKIAEXAMPLE", + ], + "run *** *** *** ***", + ), + ( + &[ + "sh", + "/home/alice/bin/x", + "0123456789abcdef0123456789abcdef01", + ], + "sh ~/bin/x ***", + ), + (&["echo", "a`b", "c\u{7}d"], "echo a'b c d"), + ( + &["psql", "--password='hunter2'", "db"], + "psql --password='***' db", + ), + ( + &["TOKEN=\"abc123\"", "npm", "start"], + "TOKEN=\"***\" npm start", + ), + (&["tool", "--token", "\"abc\""], "tool --token \"***\""), + ( + &["curl", "-H", "'X-Api-Key: abc123'"], + "curl -H 'X-Api-Key: ***'", + ), + ( + &["curl", "-H", "'Authorization: Basic dXNlcjpwYXNz'"], + "curl -H 'Authorization: Basic ***'", + ), + ( + &["echo", "{\"token\":\"abc123\"}"], + "echo {\"token\":\"***\"}", + ), + (&["curl", "-u", "admin:s3cret", "h"], "curl -u admin:*** h"), + (&["mysql", "-u", "root", "-phunter2"], "mysql -u root -p***"), + ( + &["sshpass", "-p", "hunter2", "ssh", "h"], + "sshpass -p *** ssh h", + ), + (&["env", "DB_PASS=hunter2", "x"], "env DB_PASS=*** x"), + (&["tool", "--pass", "x"], "tool --pass ***"), + ( + &["redis-cli", "-u", "redis://:pw@h"], + "redis-cli -u redis://***@h", + ), + ( + &["app", "--port", "3000", "PWD=/x"], + "app --port 3000 PWD=***", + ), + ( + &["curl", "-H", "'Cookie: session=abc; theme=dark'"], + "curl -H 'Cookie: ***'", + ), + ( + &["curl", "-H", "'Set-Cookie: id=xyz'"], + "curl -H 'Set-Cookie: ***'", + ), + ( + &["docker", "login", "-u", "bob", "-p", "s3cret", "ghcr.io"], + "docker login -u bob -p *** ghcr.io", + ), + ( + &[ + "echo", + "s3cret", + "|", + "docker", + "login", + "--password-stdin", + "-u", + "bob", + ], + "echo *** | docker login --password-stdin -u bob", + ), + ( + &[ + "printf", + "'s3cret'", + "|", + "docker", + "login", + "--password-stdin", + ], + "printf '***' | docker login --password-stdin", + ), + ]; + for (input, want) in cases { + assert_eq!(redact_cmdline(&argv(input), home), *want, "{input:?}"); + } + } + + #[test] + fn redact_cmdline_shows_the_command_claude_ran_not_its_wrapper() { + let wrapper = argv(&[ + "/bin/bash", + "-c", + "source /home/alice/.claude/shell-snapshots/snapshot-bash-1.sh 2>/dev/null || true \ + && eval 'PORT=3000 TOKEN=s3cr3t npm run dev -- --name '\"'\"'my app'\"'\"'' < /dev/null \ + && pwd -P >| /tmp/claude-1-cwd", + ]); + let shown = redact_cmdline(&wrapper, Some("/home/alice")); + assert_eq!(shown, "PORT=3000 TOKEN=*** npm run dev -- --name 'my app'"); + assert!(!shown.contains("/home/alice")); + let long = argv(&["x".repeat(200).as_str()]); + assert!(redact_cmdline(&long, None).chars().count() <= ORPHAN_CMD_MAX + 1); + } + + #[cfg(target_os = "linux")] + #[test] + fn terminate_after_the_body_already_died_still_ends_its_group() { + use std::os::unix::process::CommandExt; + let tmp = tempfile::tempdir().unwrap(); + let pidfile = tmp.path().join("bg.pid"); + let mut child = Command::new("sh") + .arg("-c") + .arg(format!("sleep 63 & echo $! > {}; wait", pidfile.display())) + .env(BODY_SID_ENV, "s16") + .process_group(0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sh"); + record(tmp.path(), "s16", Some(child.id()), "test").unwrap(); + let started = Instant::now(); + let bg = loop { + if let Some(pid) = std::fs::read_to_string(&pidfile) + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + break pid; + } + assert!(started.elapsed() < Duration::from_secs(5), "no bg pid"); + std::thread::sleep(Duration::from_millis(20)); + }; + let body = read(tmp.path(), "s16").unwrap(); + // An /interrupt's SIGKILL got the body first. + let reaper = std::thread::spawn(move || child.wait()); + assert!(kill_body(&body, "s16").unwrap().is_some()); + reaper.join().unwrap().unwrap(); + assert!(process_exists(bg)); + // The stop that took over still ends what the body started. + assert!(terminate(&body, "s16", Duration::from_millis(200)).unwrap()); + let started = Instant::now(); + while process_exists(bg) + && !process_is_zombie(bg) + && started.elapsed() < Duration::from_secs(3) + { + std::thread::sleep(Duration::from_millis(50)); + } + assert!( + !process_exists(bg) || process_is_zombie(bg), + "the group was swept" + ); + } } diff --git a/crates/ccteam-harness/src/execution/session_meta.rs b/crates/ccteam-harness/src/execution/session_meta.rs index bf4a5dd73..1bf681a2e 100644 --- a/crates/ccteam-harness/src/execution/session_meta.rs +++ b/crates/ccteam-harness/src/execution/session_meta.rs @@ -10,7 +10,7 @@ //! monotonic sid counter (`state/sessions/next-sid`); on restart it cold-start //! rebuilds the live map from these `meta.json` files. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use anyhow::Result; @@ -393,6 +393,15 @@ pub fn list_session_metas_reporting( return (vec![], failures); } }; + // Parsed metas from this directory's previous scan, keyed by chat-dir + // name; replaced by what THIS scan saw, so it never outgrows the disk. + let scan_start = std::time::SystemTime::now(); + let mut previous = META_SCAN_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&chat_base) + .unwrap_or_default(); + let mut seen: HashMap = HashMap::with_capacity(previous.len()); let mut out: Vec = Vec::new(); for entry in entries { let entry = match entry { @@ -403,23 +412,114 @@ pub fn list_session_metas_reporting( } }; let meta_path = entry.path().join("meta.json"); + let stamp = match std::fs::metadata(&meta_path) { + Ok(stat) => MetaStamp::of(&stat), + // A chat dir with no `meta.json` is not a session at all. + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(err) => { + failures.push((meta_path, err)); + continue; + } + }; + let name = entry.file_name(); + if let Some(cached) = previous + .remove(&name) + .filter(|c| c.trusted && c.stamp == stamp) + { + out.push(cached.meta.clone()); + seen.insert(name, cached); + continue; + } match std::fs::read_to_string(&meta_path) { Ok(raw) => match serde_json::from_str::(&raw) { - Ok(meta) => out.push(meta), + Ok(meta) => { + out.push(meta.clone()); + let trusted = stamp.settled_before(scan_start); + seen.insert( + name, + CachedMeta { + stamp, + trusted, + meta, + }, + ); + } Err(err) => failures.push(( meta_path, std::io::Error::new(std::io::ErrorKind::InvalidData, err), )), }, - // A chat dir with no `meta.json` is not a session at all. Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => failures.push((meta_path, err)), } } + META_SCAN_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(chat_base, seen); out.sort_by(|a, b| b.last_active.cmp(&a.last_active)); (out, failures) } +/// Every session listing (web status + team graph polls, the history sidebar, +/// IM `/sessions` `/status` `@handle`) scans ALL of a project's session dirs, +/// and a long-lived project has thousands (issue #213: 1263) while only a few +/// change between scans. Each scan therefore stats every `meta.json` but +/// re-reads and re-parses only those whose stamp moved since the last scan. +/// +/// The stamp alone can miss a rewrite: `atomic_write_durable`'s temp file +/// can land on the inode the previous rename freed (two rewrites put the +/// path back on its original inode), a rewrite keeps the length (fixed-width +/// RFC 3339 fields), and mtime only advances on the kernel's coarse tick +/// (~4 ms here, 1 s on some filesystems). So the racy-timestamp rule git +/// uses for its index: a cached parse is served only if the file's mtime was +/// already [`RACY_WINDOW`] old when it was read — a later write must then +/// carry a newer mtime. A file modified that recently is re-read every scan +/// until it settles. +const RACY_WINDOW: std::time::Duration = std::time::Duration::from_secs(2); + +static META_SCAN_CACHE: std::sync::LazyLock< + std::sync::Mutex>>, +> = std::sync::LazyLock::new(Default::default); + +struct CachedMeta { + stamp: MetaStamp, + /// The file's mtime was outside [`RACY_WINDOW`] when it was read. + trusted: bool, + meta: SessionMeta, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct MetaStamp { + file_id: (u64, u64), + len: u64, + modified: Option, +} + +impl MetaStamp { + /// Was the file last modified at least [`RACY_WINDOW`] before `read_at`? + fn settled_before(&self, read_at: std::time::SystemTime) -> bool { + self.modified + .and_then(|modified| modified.checked_add(RACY_WINDOW)) + .is_some_and(|settled| settled < read_at) + } + + fn of(stat: &std::fs::Metadata) -> Self { + #[cfg(unix)] + let file_id = { + use std::os::unix::fs::MetadataExt; + (stat.dev(), stat.ino()) + }; + #[cfg(not(unix))] + let file_id = (0, 0); + Self { + file_id, + len: stat.len(), + modified: stat.modified().ok(), + } + } +} + // ── external vendor discovery ───────────────────────────────────────────────── /// A Claude session discovered from `~/.claude/projects/` that has no ccteam @@ -641,6 +741,108 @@ mod title_tests { } } + /// Issue #213: a rescan re-reads only what changed, and still sees every + /// rewrite, new session and removed session. + #[test] + fn rescans_see_every_rewrite_addition_and_removal() { + let tmp = tempfile::TempDir::new().unwrap(); + let meta = |sid: &str, title: &str| SessionMeta { + sid: sid.into(), + title: Some(title.into()), + ..blank_meta() + }; + write_session_meta(tmp.path(), &meta("s1", "one")).unwrap(); + write_session_meta(tmp.path(), &meta("s2", "two")).unwrap(); + let titles = || { + let mut t: Vec<(String, String)> = list_session_metas(tmp.path()) + .into_iter() + .map(|m| (m.sid, m.title.unwrap_or_default())) + .collect(); + t.sort(); + t + }; + assert_eq!( + titles(), + vec![("s1".into(), "one".into()), ("s2".into(), "two".into())] + ); + // A settled file is served from cache; its rewrite is still seen + // (the new write carries a fresh mtime). + let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600); + std::fs::File::options() + .write(true) + .open(session_meta_path(tmp.path(), "s1")) + .unwrap() + .set_modified(old) + .unwrap(); + list_session_metas(tmp.path()); + list_session_metas(tmp.path()); + write_session_meta(tmp.path(), &meta("s1", "uno")).unwrap(); + write_session_meta(tmp.path(), &meta("s3", "three")).unwrap(); + std::fs::remove_dir_all(chat_dir(tmp.path(), "s2")).unwrap(); + assert_eq!( + titles(), + vec![("s1".into(), "uno".into()), ("s3".into(), "three".into())] + ); + } + + /// Issue #213 (checker repro, 170/200 on ext4): two same-length rewrites + /// within one mtime tick can put `meta.json` back on its original inode + /// with an unchanged stamp — a fresh file must never be served from + /// cache. + #[test] + fn back_to_back_same_length_rewrites_are_never_missed() { + for _ in 0..50 { + let tmp = tempfile::TempDir::new().unwrap(); + let meta = |title: &str| SessionMeta { + title: Some(title.into()), + ..blank_meta() + }; + write_session_meta(tmp.path(), &meta("one")).unwrap(); + assert_eq!( + list_session_metas(tmp.path())[0].title.as_deref(), + Some("one") + ); + write_session_meta(tmp.path(), &meta("two")).unwrap(); + write_session_meta(tmp.path(), &meta("uno")).unwrap(); + assert_eq!( + list_session_metas(tmp.path())[0].title.as_deref(), + Some("uno") + ); + } + } + + /// The same miss made deterministic: a rewrite that leaves inode, length + /// and mtime exactly as they were. A recently modified file is never + /// served from cache, so the new content is still read. + #[test] + fn an_unchanged_stamp_on_a_fresh_file_is_not_trusted() { + let tmp = tempfile::TempDir::new().unwrap(); + let meta = |title: &str| SessionMeta { + title: Some(title.into()), + ..blank_meta() + }; + write_session_meta(tmp.path(), &meta("one")).unwrap(); + assert_eq!( + list_session_metas(tmp.path())[0].title.as_deref(), + Some("one") + ); + let path = session_meta_path(tmp.path(), "s1"); + let mtime = std::fs::metadata(&path).unwrap().modified().unwrap(); + let body = serde_json::to_string_pretty(&meta("uno")).unwrap(); + let file = std::fs::File::options() + .write(true) + .truncate(true) + .open(&path) + .unwrap(); + std::io::Write::write_all(&mut &file, body.as_bytes()).unwrap(); + file.set_modified(mtime).unwrap(); + drop(file); + assert_eq!( + list_session_metas(tmp.path())[0].title.as_deref(), + Some("uno") + ); + } + // ---- truncate_title ---------------------------------------------------- #[test] diff --git a/crates/ccteam-harness/src/execution/session_status.rs b/crates/ccteam-harness/src/execution/session_status.rs index d26826314..f1e76a3eb 100644 --- a/crates/ccteam-harness/src/execution/session_status.rs +++ b/crates/ccteam-harness/src/execution/session_status.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; -use crate::ThreadStatus; +use crate::{ContextBreakdown, ThreadStatus}; use super::fs_atomic::atomic_write_durable; @@ -76,10 +76,67 @@ pub fn read_status_file_reporting( } } +/// `/.ccteam/chat//context.json` — the session's last +/// [`ContextBreakdown`] (issue #21), beside `status.json` for the same reason: +/// a released session must still be able to say what its window held. +pub fn context_json_path(project_dir: &Path, sid: &str) -> PathBuf { + status_json_path(project_dir, sid).with_file_name("context.json") +} + +/// Persist the latest breakdown atomically. Best-effort like +/// [`write_status_file`]: a failed write only leaves the previous snapshot. +pub fn write_context_file(project_dir: &Path, sid: &str, breakdown: &ContextBreakdown) { + let path = context_json_path(project_dir, sid); + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return; + } + } + let Ok(body) = serde_json::to_vec(breakdown) else { + return; + }; + let _ = atomic_write_durable(&path, &body); +} + +/// The persisted breakdown, or `None` if absent / unreadable / stale-shaped. +pub fn read_context_file(project_dir: &Path, sid: &str) -> Option { + let body = std::fs::read(context_json_path(project_dir, sid)).ok()?; + serde_json::from_slice(&body).ok() +} + #[cfg(test)] mod tests { use super::*; - use crate::{ContextSource, ContextUsage}; + use crate::{ContextCategory, ContextCategoryKind, ContextSource, ContextUsage}; + + #[test] + fn roundtrips_a_context_breakdown_beside_the_status() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(read_context_file(dir.path(), "s3"), None); + let breakdown = ContextBreakdown { + vendor: "claude".into(), + model: Some("claude-sonnet-5-5".into()), + captured_at: chrono::Utc::now(), + used_tokens: 29_198, + window_tokens: 1_000_000, + categories: vec![ContextCategory { + id: "memory_files".into(), + name: "Memory files".into(), + tokens: 15_169, + kind: ContextCategoryKind::Used, + items: Vec::new(), + }], + ..Default::default() + }; + write_context_file(dir.path(), "s3", &breakdown); + assert_eq!( + context_json_path(dir.path(), "s3").parent(), + status_json_path(dir.path(), "s3").parent() + ); + assert_eq!(read_context_file(dir.path(), "s3"), Some(breakdown)); + std::fs::write(context_json_path(dir.path(), "s3"), b"{not json").unwrap(); + assert_eq!(read_context_file(dir.path(), "s3"), None); + } #[test] fn roundtrips_a_status_with_provenance() { diff --git a/crates/ccteam-harness/src/execution/transcript_tail.rs b/crates/ccteam-harness/src/execution/transcript_tail.rs index c3b01c3fc..5860b8caa 100644 --- a/crates/ccteam-harness/src/execution/transcript_tail.rs +++ b/crates/ccteam-harness/src/execution/transcript_tail.rs @@ -308,9 +308,10 @@ pub fn is_subagent_jsonl(path: &Path) -> bool { } /// In-memory carry-over for `tool_use` ↔ `tool_result` pairing across -/// poll cycles. `tool_use_id` → tool name. Keep it small (the pending -/// set only spans one active assistant turn). -pub type PendingTools = HashMap; +/// poll cycles. `tool_use_id` → (tool name, input), so the result completes +/// the call with both. Keep it small (the pending set only spans one active +/// assistant turn). +pub type PendingTools = HashMap; /// Result of one `read_new` pass. #[derive(Debug, Default)] @@ -523,7 +524,7 @@ pub fn parse_transcript_line(row: &Value, pending: &mut PendingTools) -> Vec Vec Vec Vec Result ensure_dir(project_dir, sid)?; let path = turns_jsonl_path(project_dir, sid); let line = serde_json::to_string(record)? + "\n"; - let mut f = fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) + let mut f = crate::execution::fs_atomic::open_jsonl_append(&path) .with_context(|| format!("open {}", path.display()))?; f.write_all(line.as_bytes()) .with_context(|| format!("append to {}", path.display()))?; @@ -206,6 +203,81 @@ pub fn read_all_turns(project_dir: &Path, sid: &str) -> Result> super::fs_atomic::read_jsonl(&path) } +/// Counts the gateway asks of a session's turns.jsonl at every turn boundary +/// (pump start, meta refresh, final answer, `agent` wait settle). Kept as an +/// incremental fold ([`journal::FoldCache`]) so each ask reads only the rows +/// appended since the last one, not the whole session history (issue #213). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TurnsDigest { + /// Rows with a non-empty `user` — the session's user turns. + pub user_turns: u64, + /// Rows carrying a `status` — one per completed execution turn (only the + /// final answer of a turn carries it). + pub status_turns: u64, + /// Highest `N` among `turn_id`s shaped `-` — where a new pump + /// resumes numbering. COUNTING rows was a proxy that held only while + /// every such row had text: a closing row (empty `assistant`, #209) is + /// not counted, so the next pump re-issued its id — and a turn id already + /// in `notified_turns` silently swallows the completion that reuses it. + pub max_answer_seq: u64, + /// `turn_id` of the newest row. + pub last_turn_id: Option, +} + +struct DigestFold { + prefix: String, + digest: TurnsDigest, +} + +impl journal::LineFold for DigestFold { + fn ingest(&mut self, line: &[u8]) { + // Same tolerance as `read_all_turns`: an unparseable row is skipped. + let Ok(turn) = serde_json::from_slice::(line) else { + return; + }; + let d = &mut self.digest; + if !turn.user.is_empty() { + d.user_turns += 1; + } + if turn.status.is_some() { + d.status_turns += 1; + } + if let Some(seq) = turn + .turn_id + .strip_prefix(&self.prefix) + .and_then(|n| n.parse::().ok()) + { + d.max_answer_seq = d.max_answer_seq.max(seq); + } + d.last_turn_id = Some(turn.turn_id); + } +} + +/// The session's [`TurnsDigest`] — empty when it has no turns.jsonl yet, +/// `None` when the file exists but cannot be read (callers keep what they +/// knew rather than reporting zero turns). +pub fn digest(project_dir: &Path, sid: &str) -> Option { + /// Sessions whose digest is kept: live ones are asked at every turn; a + /// dropped digest is just rebuilt from the file. + const MAX_CACHED_DIGESTS: usize = 512; + static CACHE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| journal::FoldCache::new(MAX_CACHED_DIGESTS, None)); + let path = turns_jsonl_path(project_dir, sid); + let digest = CACHE.read( + &path, + || DigestFold { + prefix: format!("{sid}-"), + digest: TurnsDigest::default(), + }, + |fold| fold.digest.clone(), + ); + match digest { + Some(digest) => Some(digest), + None if !path.exists() => Some(TurnsDigest::default()), + None => None, + } +} + /// Return the last `n` parseable turns, in chronological order. F118 /// `rebuild_from_turns_jsonl` uses this to bound the conversation /// history it injects into a fresh tmux session. @@ -217,6 +289,101 @@ pub fn last_n_turns(project_dir: &Path, sid: &str, n: usize) -> Result/.ccteam/chat//steps.jsonl` for +/// the web step inspector. Observability only: nothing replays or bills from +/// it, and losing it loses no conversation (that is `turns.jsonl`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StepRecord { + /// Unique across the session's life — the live activity frame carries it, + /// and it is what the inspector asks for. + pub step_id: String, + pub ts: DateTime, + /// `tool_call` | `command_exec` | `file_change` | `web_search` | `thinking`. + pub kind: String, + /// Tool / category name; empty for thinking. + #[serde(default)] + pub name: String, + /// The call's input, pretty JSON or the bare command / query / path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// What the step returned — a tool's output, or the reasoning text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + /// Whether a field was cut at [`STEP_FIELD_MAX_BYTES`]. + #[serde(default)] + pub truncated: bool, +} + +impl StepRecord { + /// Cap every field at [`STEP_FIELD_MAX_BYTES`], flagging the cut. + pub fn bounded(mut self) -> Self { + for field in [&mut self.input, &mut self.output].into_iter().flatten() { + if field.len() > STEP_FIELD_MAX_BYTES { + let mut cut = STEP_FIELD_MAX_BYTES; + while !field.is_char_boundary(cut) { + cut -= 1; + } + field.truncate(cut); + self.truncated = true; + } + } + self + } +} + +/// Resolve `/.ccteam/chat//steps.jsonl`. +pub fn steps_jsonl_path(project_dir: &Path, sid: &str) -> PathBuf { + chat_dir(project_dir, sid).join("steps.jsonl") +} + +/// Append one step (bounded first). The session's event pump is the only +/// writer, so a line is never interleaved with another. +pub fn append_step(project_dir: &Path, sid: &str, record: StepRecord) -> Result<()> { + ensure_dir(project_dir, sid)?; + let path = steps_jsonl_path(project_dir, sid); + let line = serde_json::to_string(&record.bounded())? + "\n"; + let mut f = crate::execution::fs_atomic::open_jsonl_append(&path) + .with_context(|| format!("open {}", path.display()))?; + f.write_all(line.as_bytes()) + .with_context(|| format!("append to {}", path.display()))?; + Ok(()) +} + +/// The newest record for `step_id`, or `None` when the step never finished +/// (or the file is absent). Lines are matched as bytes before being parsed, +/// so a lookup parses one record, not the session's whole history; a torn or +/// otherwise unreadable line is skipped, never an error for every lookup. +pub fn find_step(project_dir: &Path, sid: &str, step_id: &str) -> Result> { + use std::io::BufRead; + let path = steps_jsonl_path(project_dir, sid); + let file = match fs::File::open(&path) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).with_context(|| format!("open {}", path.display())), + }; + let needle = format!("\"step_id\":{}", serde_json::to_string(step_id)?); + let needle = needle.as_bytes(); + let mut found = None; + for line in std::io::BufReader::new(file).split(b'\n') { + let line = line.with_context(|| format!("read {}", path.display()))?; + if !line.windows(needle.len()).any(|w| w == needle) { + continue; + } + if let Ok(record) = serde_json::from_slice::(&line) { + if record.step_id == step_id { + found = Some(record); + } + } + } + Ok(found) +} + #[cfg(test)] mod tests { use super::*; @@ -316,6 +483,83 @@ mod tests { ); } + /// Issue #213: the incremental digest answers exactly what a full re-read + /// would, across appends between asks, a corrupt row, and a torn tail. + #[test] + fn digest_tracks_appends_like_a_full_reread() { + let tmp = TempDir::new().unwrap(); + let full = |dir: &Path| { + let turns = read_all_turns(dir, "s9").unwrap(); + TurnsDigest { + user_turns: turns.iter().filter(|t| !t.user.is_empty()).count() as u64, + status_turns: turns.iter().filter(|t| t.status.is_some()).count() as u64, + max_answer_seq: turns + .iter() + .filter_map(|t| t.turn_id.strip_prefix("s9-")?.parse().ok()) + .max() + .unwrap_or(0), + last_turn_id: turns.last().map(|t| t.turn_id.clone()), + } + }; + assert_eq!(digest(tmp.path(), "s9").unwrap(), TurnsDigest::default()); + append_turn(tmp.path(), "s9", &mk_turn("s9-3", "r", "hi", "a")).unwrap(); + assert_eq!(digest(tmp.path(), "s9").unwrap(), full(tmp.path())); + let mut done = mk_turn("s9-1", "r", "", "b"); + done.status = Some(TurnStatus { + model: None, + context: None, + turn: 1, + cost_usd: None, + tokens_total: None, + }); + append_turn(tmp.path(), "s9", &done).unwrap(); + let path = turns_jsonl_path(tmp.path(), "s9"); + let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); + writeln!(file, "not-json").unwrap(); + write!(file, "{{\"turn_id\":\"torn").unwrap(); + drop(file); + let d = digest(tmp.path(), "s9").unwrap(); + assert_eq!(d, full(tmp.path())); + assert_eq!((d.user_turns, d.status_turns, d.max_answer_seq), (1, 1, 3)); + append_turn(tmp.path(), "s9", &mk_turn("x-7", "r", "again", "c")).unwrap(); + let d = digest(tmp.path(), "s9").unwrap(); + assert_eq!(d, full(tmp.path())); + assert_eq!(d.last_turn_id.as_deref(), Some("x-7")); + // Present but unreadable is not "no turns". + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = |m| fs::set_permissions(&path, fs::Permissions::from_mode(m)).unwrap(); + mode(0o000); + let unreadable = fs::File::open(&path).is_err(); // root reads anyway + if unreadable { + assert_eq!(digest(tmp.path(), "s9"), None); + } + mode(0o644); + } + } + + /// Issue #210: after an ENOSPC-torn tail the next appended turn lands on + /// its own line instead of being glued to the fragment (both lost). + #[test] + fn append_after_a_torn_tail_keeps_the_new_turn() { + let tmp = TempDir::new().unwrap(); + append_turn(tmp.path(), "eve", &mk_turn("t0", "eve", "u", "a")).unwrap(); + let path = turns_jsonl_path(tmp.path(), "eve"); + let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); + write!(file, "{{\"turn_id\":\"torn").unwrap(); + drop(file); + append_turn(tmp.path(), "eve", &mk_turn("t1", "eve", "u", "a")).unwrap(); + append_turn(tmp.path(), "eve", &mk_turn("t2", "eve", "u", "a")).unwrap(); + + let ids: Vec = read_all_turns(tmp.path(), "eve") + .unwrap() + .into_iter() + .map(|turn| turn.turn_id) + .collect(); + assert_eq!(ids, vec!["t0", "t1", "t2"]); + } + #[test] fn last_n_zero_returns_empty() { let tmp = TempDir::new().unwrap(); @@ -395,4 +639,60 @@ mod tests { assert!(encoded.get("error_kind").is_none()); assert!(encoded.get("error").is_none()); } + + fn step(id: &str, output: &str) -> StepRecord { + StepRecord { + step_id: id.to_string(), + ts: Utc::now(), + kind: "tool_call".to_string(), + name: "Bash".to_string(), + input: Some("{\"command\":\"ls\"}".to_string()), + output: Some(output.to_string()), + truncated: false, + } + } + + #[test] + fn a_step_is_found_by_id_and_the_newest_record_wins() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_step(dir.path(), "s1", "p-1-a").unwrap(), None); + append_step(dir.path(), "s1", step("p-1-a", "first")).unwrap(); + append_step(dir.path(), "s1", step("p-1-ab", "prefix sibling")).unwrap(); + append_step(dir.path(), "s1", step("p-1-a", "second")).unwrap(); + let got = find_step(dir.path(), "s1", "p-1-a").unwrap().unwrap(); + assert_eq!(got.output.as_deref(), Some("second")); + assert_eq!( + find_step(dir.path(), "s1", "p-1-ab") + .unwrap() + .unwrap() + .output + .as_deref(), + Some("prefix sibling") + ); + assert_eq!(find_step(dir.path(), "s2", "p-1-a").unwrap(), None); + } + + #[test] + fn a_torn_line_with_broken_utf8_does_not_fail_every_lookup() { + let dir = tempfile::tempdir().unwrap(); + append_step(dir.path(), "s1", step("p-1-a", "kept")).unwrap(); + let path = steps_jsonl_path(dir.path(), "s1"); + let mut f = fs::OpenOptions::new().append(true).open(&path).unwrap(); + f.write_all(b"{\"step_id\":\"p-1-b\",\"output\":\"\xe4\xb8") + .unwrap(); + drop(f); + let got = find_step(dir.path(), "s1", "p-1-a").unwrap().unwrap(); + assert_eq!(got.output.as_deref(), Some("kept")); + assert_eq!(find_step(dir.path(), "s1", "p-1-b").unwrap(), None); + } + + #[test] + fn an_oversized_field_is_cut_at_a_char_boundary_and_flagged() { + let dir = tempfile::tempdir().unwrap(); + let big = "é".repeat(STEP_FIELD_MAX_BYTES); + append_step(dir.path(), "s1", step("p-1-big", &big)).unwrap(); + let got = find_step(dir.path(), "s1", "p-1-big").unwrap().unwrap(); + assert!(got.truncated); + assert!(got.output.unwrap().len() <= STEP_FIELD_MAX_BYTES); + } } diff --git a/crates/ccteam-harness/src/lib.rs b/crates/ccteam-harness/src/lib.rs index 357bde424..0bdf0688a 100644 --- a/crates/ccteam-harness/src/lib.rs +++ b/crates/ccteam-harness/src/lib.rs @@ -47,20 +47,23 @@ pub mod usage_catalog; pub mod vendor_compat; pub use adapter::{ - bounded_tail, ccteam_root_from_env, format_tokens, parse_backgrounded_short_id, - parse_cc_state_json, parse_pid_from_state, pluck, pluck_f64, pluck_pct, pluck_str, sigkill_pid, - sigterm_pid, state_json_path, AccountUsage, AgentSpecBrief, AgentVendor, ApprovalIR, - ApprovalKind, ApprovalRisk, ApprovalScope, CanonicalEvent, ChoiceOption, ChoicePrompt, - ChoiceSelection, ContextSource, ContextUsage, DetachOutcome, Directive, DirectiveOutcome, - EventAttachment, ExecutionMode, GoalStatus, HarnessAdapter, HarnessError, HarnessSnapshot, - HostExecutionScope, ModelWindow, NarrationAccumulator, PartialNarration, PermissionMode, - RecoveredTurn, RunningTask, SessionHandle, SessionProtocol, SessionTitleTarget, SpawnCtx, - SpawnOpts, SubagentState, ThreadErrorEvent, ThreadEvent, ThreadHandle, ThreadItem, - ThreadItemDetails, ThreadStatus, TitleSync, ToolSurfaceRebuild, TurnContinuation, - TurnDisposition, TurnId, TurnInput, TurnOpening, TurnRouting, TurnSubmission, - UnifiedTokenUsage, UnobservedTurnCtx, CCTEAM_HOME_ENV, CLAUDE_BIN_ENV, CLAUDE_JOBS_DIR_ENV, - CODEX_BIN_ENV, CODEX_STATUS_MARKER, CODEX_STATUS_TAIL_LINES, DEFAULT_CLAUDE_SID, GROK_BIN_ENV, - IN_FLIGHT_NARRATION_MAX_CHARS, KIMI_BIN_ENV, OPENCODE_BIN_ENV, + bounded_tail, ccteam_root_from_env, codex_item_output, format_tokens, + parse_backgrounded_short_id, parse_cc_state_json, parse_pid_from_state, pluck, pluck_f64, + pluck_pct, pluck_str, sigkill_pid, sigterm_pid, state_json_path, tool_result_text, + AccountUsage, AgentSpecBrief, AgentVendor, ApprovalIR, ApprovalKind, ApprovalRisk, + ApprovalScope, CanonicalEvent, ChoiceOption, ChoicePrompt, ChoiceSelection, + ContextAttachmentUsage, ContextBreakdown, ContextCategory, ContextCategoryKind, ContextItem, + ContextMessages, ContextRequestUsage, ContextSource, ContextToolUsage, ContextUsage, + DetachOutcome, DetachedActivity, Directive, DirectiveOutcome, EventAttachment, ExecutionMode, + GoalStatus, HarnessAdapter, HarnessError, HarnessSnapshot, HostExecutionScope, ModelWindow, + NarrationAccumulator, PartialNarration, PermissionMode, RecoveredTurn, RunningTask, + SessionHandle, SessionProtocol, SessionTitleTarget, SpawnCtx, SpawnOpts, SubagentState, + ThreadErrorEvent, ThreadEvent, ThreadHandle, ThreadItem, ThreadItemDetails, ThreadStatus, + TitleSync, ToolSurfaceRebuild, TurnContinuation, TurnDisposition, TurnId, TurnInput, + TurnOpening, TurnRouting, TurnSubmission, UnifiedTokenUsage, UnobservedTurnCtx, + CCTEAM_HOME_ENV, CLAUDE_BIN_ENV, CLAUDE_JOBS_DIR_ENV, CODEX_BIN_ENV, CODEX_STATUS_MARKER, + CODEX_STATUS_TAIL_LINES, DEFAULT_CLAUDE_SID, GROK_BIN_ENV, IN_FLIGHT_NARRATION_MAX_CHARS, + KIMI_BIN_ENV, OPENCODE_BIN_ENV, }; pub use enriched_event::{ enrichment_source, BaseEvent, BasePayload, EnrichedEvent, EnrichmentEvent, EnrichmentPayload, diff --git a/crates/ccteam-harness/tests/claude_stream_json_test.rs b/crates/ccteam-harness/tests/claude_stream_json_test.rs index ccf3110d2..08c7e2fb8 100644 --- a/crates/ccteam-harness/tests/claude_stream_json_test.rs +++ b/crates/ccteam-harness/tests/claude_stream_json_test.rs @@ -105,7 +105,11 @@ while True: # maxTokens 1000000 can ONLY come from get_context_usage → it both # proves the source AND drives the [1m] model-id tag. emit({"type":"control_response","response":{"subtype":"success", - "request_id":rid,"response":{"totalTokens":12345,"maxTokens":1000000,"percentage":1}}}) + "request_id":rid,"response":{"totalTokens":12345,"maxTokens":1000000,"percentage":1, + "categories":[{"id":"memory_files","name":"Memory files","tokens":12000,"kind":"used"}, + {"id":"messages","name":"Messages","tokens":345,"kind":"used"}, + {"id":"free_space","name":"Free space","tokens":987655,"kind":"free"}], + "memoryFiles":[{"path":"/p/CLAUDE.md","type":"Project","tokens":12000}]}}}) elif os.environ.get("FAKE_SJ_SET_MODEL_FAIL") == "1": emit({"type":"control_response","response":{"subtype":"error", "request_id":rid,"error":"unsupported model"}}) @@ -449,6 +453,35 @@ async fn fresh_spawn_uses_session_id() { adapter.close_thread(&handle).await.unwrap(); } +/// docs-local issue #212 — a stream-json spawn writes no project Claude +/// settings: the Telegram pin rides `--settings`, so the project gets no +/// `.claude/` (and certainly no `settings.local.json`) from ccteam. +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn spawn_writes_no_project_claude_settings() { + let tmp = tempfile::TempDir::new().unwrap(); + setup(tmp.path()); + let project = tmp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + let mut spawn_ctx = ctx(tmp.path(), "demo", "s1"); + spawn_ctx.cwd = project.clone(); + spawn_ctx.project_dir = project.clone(); + let adapter = ClaudeStreamJsonAdapter::new(); + let handle = adapter + .start_thread(&AgentSpecBrief { role: "".into() }, &spawn_ctx) + .await + .expect("start_thread"); + assert!( + !project.join(".claude").join("settings.local.json").exists(), + "spawn must not write .claude/settings.local.json" + ); + assert!( + !project.join(".claude").exists(), + "spawn must not create .claude/" + ); + adapter.close_thread(&handle).await.unwrap(); +} + #[tokio::test(flavor = "current_thread")] #[serial] async fn resume_spawn_uses_resume_when_transcript_exists() { @@ -1774,6 +1807,51 @@ async fn stream_json_status_survives_release_via_persisted_file() { assert_eq!(c.window_tokens, 1_000_000); } +/// Issue #21 — the context panel reads the live session's breakdown straight +/// from claude (no turn needed), and the turn-end answer is kept as +/// `context.json` so a released session still has one to show. +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn context_breakdown_is_live_and_snapshotted_at_turn_end() { + let tmp = tempfile::TempDir::new().unwrap(); + setup(tmp.path()); + let adapter = ClaudeStreamJsonAdapter::new(); + let handle = adapter + .start_thread( + &AgentSpecBrief { + role: "alice".into(), + }, + &ctx(tmp.path(), "demo", "s1"), + ) + .await + .expect("start_thread"); + + let live = adapter + .context_breakdown(&handle) + .await + .expect("a live session answers before its first turn"); + assert_eq!((live.used_tokens, live.window_tokens), (12_345, 1_000_000)); + assert_eq!(live.categories.len(), 3); + assert_eq!(live.categories[0].items[0].name, "/p/CLAUDE.md"); + + let snapshot_file = tmp.path().join(".ccteam/chat/s1/context.json"); + assert!(!snapshot_file.exists(), "asking is not snapshotting"); + let stream_handle = handle.clone(); + let submit = adapter.submit_turn(&handle, TurnInput::UserText("hi".into())); + let _ = tokio::join!(collect_answer(&adapter, &stream_handle), submit); + assert!( + snapshot_file.exists(), + "the snapshot lands before the turn's end is visible" + ); + + adapter.close_thread(&handle).await.unwrap(); + assert_eq!(adapter.context_breakdown(&handle).await, None); + let snap = ccteam_harness::execution::session_status::read_context_file(tmp.path(), "s1") + .expect("released session keeps its last breakdown"); + assert_eq!(snap.vendor, "claude"); + assert_eq!(snap.categories[0].id, "memory_files"); +} + /// `docs-local/issues/#14②` — claude writer family: the status tap persists a /// clone of the spawn-seeded shared `ThreadStatus`, so that seed carries this /// thread's generation and every write inherits it. A sid outlives its threads @@ -2278,40 +2356,45 @@ async fn fault_matrix_stream_json() { } } -/// E3 — the resume→fresh fallback emits a `chat_session_reset` event -/// carrying the sid + a reason (the honest "context was lost" signal), -/// never silently. We force the resume spawn to die (FAKE_SJ_DIE_ON_RESUME) -/// so start_thread falls back to a fresh `--session-id` spawn + the reset. +/// A failed local `--resume` is surfaced, never "fallen back" from: claude +/// refuses `--session-id ` once `.jsonl` exists, so a fresh spawn +/// on the same uuid can never start (docs-local issue #211). The error names +/// the resume, nothing sticks (no body record, no reset event, the transcript +/// untouched), and the next attempt resumes the same conversation. #[tokio::test(flavor = "current_thread")] #[serial] -async fn resume_failure_emits_reset_event_with_sid_and_reason() { +async fn local_resume_failure_is_surfaced_and_retryable() { let tmp = tempfile::TempDir::new().unwrap(); setup(tmp.path()); - // Pre-create the transcript so start_thread chooses --resume first. + // Pre-create the transcript so start_thread chooses --resume. let uuid = deterministic_session_uuid("demo", "s1"); let dir = anthropic_project_dir(tmp.path()).expect("anthropic dir"); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(format!("{uuid}.jsonl")), "{}\n").unwrap(); - // Resume spawn dies; fresh spawn lives. + let transcript = dir.join(format!("{uuid}.jsonl")); + std::fs::write(&transcript, "{}\n").unwrap(); std::env::set_var("FAKE_SJ_DIE_ON_RESUME", "1"); std::env::set_var("CCTEAM_STREAM_JSON_INIT_TIMEOUT_MS", "1200"); let adapter = ClaudeStreamJsonAdapter::new(); - let h = adapter + let err = adapter .start_thread( &AgentSpecBrief { role: "a".into() }, &ctx(tmp.path(), "demo", "s1"), ) .await - .expect("start_thread should fall back to fresh after resume death"); - // The fresh fallback ran (the recorded mode is session-id, not resume). - assert_eq!(argv_mode(tmp.path()), format!("session-id {uuid}")); - - // The reset event landed in the progress jsonl with sid + reason. - // The path is `/state/progress/.jsonl` — this test read - // it WITHOUT the `state/` segment, so it asserted on an always-empty file - // and failed deterministically wherever the emit path was correct. (The - // sibling `claude_tui_resume_test` spells the same path right.) + .expect_err("a failed resume is an error, not a fresh session"); + assert!( + err.to_string().contains(&format!("--resume {uuid} failed")), + "the error names the resume: {err}" + ); + // The last (only) spawn was the resume — no `--session-id` on that uuid. + assert_eq!(argv_mode(tmp.path()), format!("resume {uuid}")); + assert_eq!(std::fs::read_to_string(&transcript).unwrap(), "{}\n"); + assert_eq!( + ccteam_harness::execution::session_body::probe(tmp.path(), "s1"), + ccteam_harness::execution::session_body::BodyProbe::Absent, + "the killed handshake child leaves no body record" + ); let progress = tmp .path() .join(".ccteam") @@ -2320,11 +2403,20 @@ async fn resume_failure_emits_reset_event_with_sid_and_reason() { .join("demo.jsonl"); let body = std::fs::read_to_string(&progress).unwrap_or_default(); assert!( - body.contains("\"s1\"") && body.contains("resume_failed_fallback_to_fresh"), - "reset event must carry sid + reason; got: {body}" + !body.contains("resume_failed_fallback_to_fresh"), + "no context-reset claim for a session whose context is intact: {body}" ); + // The cause clears; the very next attempt resumes the same conversation. std::env::remove_var("FAKE_SJ_DIE_ON_RESUME"); + let h = adapter + .start_thread( + &AgentSpecBrief { role: "a".into() }, + &ctx(tmp.path(), "demo", "s1"), + ) + .await + .expect("retry resumes"); + assert_eq!(argv_mode(tmp.path()), format!("resume {uuid}")); std::env::remove_var("CCTEAM_STREAM_JSON_INIT_TIMEOUT_MS"); adapter.close_thread(&h).await.unwrap(); } @@ -2358,6 +2450,22 @@ async fn body_record_follows_spawn_close_and_detach() { .unwrap(); let body = session_body::read(tmp.path(), "s41").expect("body.json written at spawn"); assert_eq!(body.adapter, "claude-stream-json"); + // The body leads its own process group, so ending it can end the + // background processes it starts (and a signal to the daemon's group + // never reaches it). + if cfg!(target_os = "linux") { + let stat = std::fs::read_to_string(format!("/proc/{}/stat", body.pid)).unwrap(); + let pgrp: u32 = stat + .rsplit_once(')') + .unwrap() + .1 + .split_whitespace() + .nth(2) + .unwrap() + .parse() + .unwrap(); + assert_eq!(pgrp, body.pid, "the body is its own process group leader"); + } assert!(matches!( session_body::probe(tmp.path(), "s41"), BodyProbe::Alive(_) diff --git a/crates/ccteam-harness/tests/codex_app_server_test.rs b/crates/ccteam-harness/tests/codex_app_server_test.rs index ccd2ac0fa..1d891b376 100644 --- a/crates/ccteam-harness/tests/codex_app_server_test.rs +++ b/crates/ccteam-harness/tests/codex_app_server_test.rs @@ -875,9 +875,14 @@ fn translate_command_execution_status_camelcase_folds_to_snake() { let e = translate_notification(&n, "t-1").unwrap(); match e { ThreadEvent::ItemStarted { item } => match item.details { - ccteam_harness::ThreadItemDetails::CommandExecution { cmd, status } => { + ccteam_harness::ThreadItemDetails::CommandExecution { + cmd, + status, + output, + } => { assert_eq!(cmd, "cargo test"); assert_eq!(status, "in_progress", "camelCase status must fold to snake"); + assert_eq!(output, None, "a running command has no output yet"); } other => panic!("expected CommandExecution, got {other:?}"), }, diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index 8a9b8f239..7646ebb14 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -465,7 +465,11 @@ where bind_operator_rosters(&mut *gateway.lock().await, &creds); // Which channels render buttons is the provider's fact; hand it over so // the gateway's pickers never branch on a platform name. - let caps = channel_caps(&shared_channels.read().unwrap()); + let caps = channel_caps( + &shared_channels + .read() + .unwrap_or_else(|err| err.into_inner()), + ); bind_channel_caps(&mut *gateway.lock().await, &caps); // V0.8.4 P2b — use the externally-supplied channel when `ccteam start` // provided one (so the mcp.sock handler shares this sender); else make @@ -488,6 +492,7 @@ where // setup); the sender is wired in BEFORE `set_event_sink` so every pump it // spawns captures it. let (delegation_tx, delegation_rx) = tokio::sync::mpsc::unbounded_channel(); + let event_delivery_closing; { // Hold the gateway lock only for synchronous setup. Restored-session // resume can wait on vendor startup (stream-json `system:init`), and @@ -505,6 +510,7 @@ where log_orphan_chat_sessions(&g).await; g.set_delegation_notifier_tx(delegation_tx); g.set_event_sink(gateway_event_tx.clone()); + event_delivery_closing = g.event_delivery_closing(); g.set_im_reload_trigger(reload_tx); // v0.8.22 P0-2 — wire the production HITL resolver onto the // stream-json Claude adapter singleton, now that the gateway + @@ -543,7 +549,10 @@ where Gateway::catch_up_scheduled(Arc::clone(&restore_gateway)).await; Gateway::reconcile_restored_sessions_shared(Arc::clone(&restore_gateway)).await; let _ = restore_complete_tx.send(true); - Gateway::run_scheduled_scheduler(restore_gateway).await; + crate::supervise::supervise_loop("scheduled scheduler", || { + Gateway::run_scheduled_scheduler(Arc::clone(&restore_gateway)) + }) + .await; }); // One sid, one body (2026-08-19) — the body watcher: a session whose // process outlived the previous daemon is WAITED for (never duplicated, @@ -551,9 +560,10 @@ where // and the session is rebuilt by sid. Runs for the daemon's whole life — // a detached body can also be found later, on first touch. let body_watch_gateway = Arc::clone(&gateway); - let body_watcher = tokio::spawn(async move { - Gateway::run_body_watcher(body_watch_gateway).await; - }); + let body_watcher = tokio::spawn(crate::supervise::supervise_loop( + "body watcher", + move || Gateway::run_body_watcher(Arc::clone(&body_watch_gateway)), + )); // The idle-release sweeper: the PRIMARY residency policy. A resident // session nobody has spoken to for its harness's `sessions.idle_release_secs` // window gives up its vendor process — the session itself is untouched and @@ -561,9 +571,10 @@ where // produces no work and calls no model, it only stops holding an OS process // past the point where holding it can still save a prompt-cache read. let idle_release_gateway = Arc::clone(&gateway); - tokio::spawn(async move { - Gateway::run_idle_release_sweeper(idle_release_gateway).await; - }); + tokio::spawn(crate::supervise::supervise_loop( + "idle-release sweeper", + move || Gateway::run_idle_release_sweeper(Arc::clone(&idle_release_gateway)), + )); // v0.9.0 W2 (F2/F7) — the delegation notifier: startup reconcile (deliver // notifications missed while the daemon was down) + live delivery of every // completed watched child turn. Owns its own gateway handle + the pump @@ -585,10 +596,12 @@ where // Clone out the (name, ch) pairs under the read lock, then drop the // guard before spawning (never hold a std lock across spawn/await). let pairs: Vec<(String, Arc)> = { - let g = shared_channels.read().unwrap(); + let g = shared_channels + .read() + .unwrap_or_else(|err| err.into_inner()); g.iter().map(|(n, c)| (n.clone(), c.clone())).collect() }; - let mut reg = listeners.lock().unwrap(); + let mut reg = listeners.lock().unwrap_or_else(|err| err.into_inner()); for (name, ch) in pairs { let h = spawn_channel_listener(name.clone(), ch, inbound_tx.clone()); reg.insert(name.clone(), h); @@ -631,7 +644,10 @@ where ); tracing::info!( - channels = shared_channels.read().unwrap().len(), + channels = shared_channels + .read() + .unwrap_or_else(|err| err.into_inner()) + .len(), bots = initial.len(), "ccteam-im: gateway router started (no supervisor tick)" ); @@ -658,27 +674,36 @@ where break Ok(()); } Some(()) = reload_rx.recv() => { - reload_im_channels( - &gateway, - &shared_channels, - &listeners, - &inbound_tx_for_reload, - &args, - &last_creds, - &last_tenants, - &menu_specs, + // This loop IS the daemon: a reload that panics must cost the + // reload, not every listener, consumer and session. + crate::supervise::contain_panic( + "IM reload", + "reload", + reload_im_channels( + &gateway, + &shared_channels, + &listeners, + &inbound_tx_for_reload, + &args, + &last_creds, + &last_tenants, + &menu_specs, + ), ) .await; } } }; + // From here a pump whose send finds the delivery consumer gone is part + // of this stop, not an outage: no ERROR on a clean shutdown. + event_delivery_closing.store(true, std::sync::atomic::Ordering::SeqCst); // V0.6.1 F132 — abort listener + consumer tasks on shutdown so the // daemon doesn't leak background tokio tasks. `JoinHandle::abort` // is best-effort but matches the rest of the F130 supervisor's // shutdown semantics. { - let mut reg = listeners.lock().unwrap(); + let mut reg = listeners.lock().unwrap_or_else(|err| err.into_inner()); for (_name, h) in reg.drain() { h.abort(); } @@ -829,8 +854,9 @@ async fn reload_im_channels( } }; let new_tenants_raw = tenants_fingerprint(args); - let creds_changed = *last_creds.lock().unwrap() != new_creds; - let tenants_changed = *last_tenants.lock().unwrap() != new_tenants_raw; + let creds_changed = *last_creds.lock().unwrap_or_else(|err| err.into_inner()) != new_creds; + let tenants_changed = + *last_tenants.lock().unwrap_or_else(|err| err.into_inner()) != new_tenants_raw; // No-op when nothing changed (e.g. a pref-only `ccteam config`). if !creds_changed && !tenants_changed { tracing::debug!("imd: reload — credentials + tenants unchanged, no-op"); @@ -862,11 +888,18 @@ async fn reload_im_channels( // Apply: for each rebuilt channel, abort its old listener, spawn a new one, // and (re)publish its command menu. for (name, ch) in rebuilt.iter() { - if let Some(old) = listeners.lock().unwrap().remove(name) { + if let Some(old) = listeners + .lock() + .unwrap_or_else(|err| err.into_inner()) + .remove(name) + { old.abort(); } let h = spawn_channel_listener(name.clone(), ch.clone(), inbound_tx.clone()); - listeners.lock().unwrap().insert(name.clone(), h); + listeners + .lock() + .unwrap_or_else(|err| err.into_inner()) + .insert(name.clone(), h); if let Err(e) = ch.register_commands(menu_specs).await { tracing::warn!(channel = %name, error = %e, "imd: reload register_commands failed"); } @@ -876,7 +909,7 @@ async fn reload_im_channels( // changed), so the unchanged dimension's listeners are never touched. { let creds_driven: HashSet<&str> = CHANNEL_BUILDERS.iter().map(|(n, _)| *n).collect(); - let mut map = shared.write().unwrap(); + let mut map = shared.write().unwrap_or_else(|err| err.into_inner()); let vanished: Vec = map .keys() .filter(|k| { @@ -891,7 +924,11 @@ async fn reload_im_channels( .collect(); for k in vanished { map.remove(&k); - if let Some(h) = listeners.lock().unwrap().remove(&k) { + if let Some(h) = listeners + .lock() + .unwrap_or_else(|err| err.into_inner()) + .remove(&k) + { h.abort(); } } @@ -900,10 +937,10 @@ async fn reload_im_channels( } } if creds_changed { - *last_creds.lock().unwrap() = new_creds; + *last_creds.lock().unwrap_or_else(|err| err.into_inner()) = new_creds; } if tenants_changed { - *last_tenants.lock().unwrap() = new_tenants_raw; + *last_tenants.lock().unwrap_or_else(|err| err.into_inner()) = new_tenants_raw; } tracing::info!( creds_changed, @@ -1466,239 +1503,168 @@ fn spawn_inbound_consumer( // claimed — see the `require_mention` gate below. let mut addressed_threads: HashSet<(String, String, String)> = HashSet::new(); while let Some(msg) = rx.recv().await { - let cid = msg.id.clone(); - let route_t0 = std::time::Instant::now(); - tracing::info!( - event = "latency", - stage = "imd.route.begin", - cid = %cid, - channel = %msg.channel, - "latency imd.route.begin" - ); - // Clone the channel out under the read lock, then DROP the guard - // before any `.await` (never hold a std RwLock guard across await). - let channel = { - let g = channels.read().unwrap(); - g.get(&msg.channel).cloned() + // The only inbound consumer: a panic routing one message must not + // end routing for every chat (issue #210, inbound side). + let item = format!("message={} channel={}", msg.id, msg.channel); + crate::supervise::contain_panic( + "inbound consumer", + &item, + route_inbound_message( + &ccteam_root, + &channels, + &sec, + &gateway, + &restore_complete, + &mut addressed_threads, + msg, + ), + ) + .await; + } + tracing::error!( + "ccteam-im: inbound consumer exited; inbound messages are no longer routed" + ); + }) +} + +/// Route one inbound message: security gate, then the gateway, then the +/// replies back to its channel. +async fn route_inbound_message( + ccteam_root: &Path, + channels: &RwLock, + sec: &Mutex, + gateway: &Arc>, + restore_complete: &tokio::sync::watch::Receiver, + addressed_threads: &mut HashSet<(String, String, String)>, + msg: ChannelMessage, +) { + let cid = msg.id.clone(); + let route_t0 = std::time::Instant::now(); + tracing::info!( + event = "latency", + stage = "imd.route.begin", + cid = %cid, + channel = %msg.channel, + "latency imd.route.begin" + ); + // Clone the channel out under the read lock, then DROP the guard + // before any `.await` (never hold a std RwLock guard across await). + let channel = { + let g = channels.read().unwrap_or_else(|err| err.into_inner()); + g.get(&msg.channel).cloned() + }; + let Some(channel) = channel else { + tracing::debug!( + channel = %msg.channel, + sender = %msg.sender, + "imd: no Channel for inbound msg.channel; dropping" + ); + return; + }; + + // The thread reaches the gateway only from a channel whose + // contract is "one thread = one session" (Slack). Everyone else + // (Telegram, Lark, web) never passes one, whatever the platform + // happened to fill in — their routing stays the single stream. + let thread = inbound_session_thread(channel.as_ref(), &msg); + + // Ambient group/channel chatter under `require_mention` is + // answered only when it continues a thread the gateway already + // holds a session in. Decided BEFORE the security layer so that + // chatter never spends the sender's rate-limit budget. + let policy = channel.mention_policy(); + if policy.require_mention { + let thread_key = thread + .as_deref() + .map(|t| (msg.channel.clone(), msg.reply_target.clone(), t.to_string())); + if msg.ambient { + // Held = the gateway has a current session in that thread, + // or an addressed message already claimed it (its session + // may still be spawning off this loop, so the gateway + // cannot know yet). + let held = match &thread_key { + Some(key) if addressed_threads.contains(key) => true, + Some((_, _, t)) => gateway.lock().await.has_current_session( + &msg.channel, + &msg.reply_target, + &msg.sender, + Some(t), + ), + None => false, }; - let Some(channel) = channel else { + if !policy.admits(msg.ambient, held) { tracing::debug!( channel = %msg.channel, sender = %msg.sender, - "imd: no Channel for inbound msg.channel; dropping" + "imd: ambient message dropped (require_mention, no held thread)" ); - continue; - }; - - // The thread reaches the gateway only from a channel whose - // contract is "one thread = one session" (Slack). Everyone else - // (Telegram, Lark, web) never passes one, whatever the platform - // happened to fill in — their routing stays the single stream. - let thread = inbound_session_thread(channel.as_ref(), &msg); - - // Ambient group/channel chatter under `require_mention` is - // answered only when it continues a thread the gateway already - // holds a session in. Decided BEFORE the security layer so that - // chatter never spends the sender's rate-limit budget. - let policy = channel.mention_policy(); - if policy.require_mention { - let thread_key = thread - .as_deref() - .map(|t| (msg.channel.clone(), msg.reply_target.clone(), t.to_string())); - if msg.ambient { - // Held = the gateway has a current session in that thread, - // or an addressed message already claimed it (its session - // may still be spawning off this loop, so the gateway - // cannot know yet). - let held = match &thread_key { - Some(key) if addressed_threads.contains(key) => true, - Some((_, _, t)) => gateway.lock().await.has_current_session( - &msg.channel, - &msg.reply_target, - &msg.sender, - Some(t), - ), - None => false, - }; - if !policy.admits(msg.ambient, held) { - tracing::debug!( - channel = %msg.channel, - sender = %msg.sender, - "imd: ambient message dropped (require_mention, no held thread)" - ); - continue; - } - } else if let Some(key) = thread_key { - // Only the in-flight-spawn window needs this memory (the - // gateway's own focus covers a settled thread), so a - // bounded set that resets when full cannot lose a held - // thread; at worst it forgets a claim whose session is - // still spawning, and only if thousands of other threads - // were addressed within that same few-second window. - if addressed_threads.len() >= ADDRESSED_THREADS_CAP { - addressed_threads.clear(); - } - addressed_threads.insert(key); - } + return; + } + } else if let Some(key) = thread_key { + // Only the in-flight-spawn window needs this memory (the + // gateway's own focus covers a settled thread), so a + // bounded set that resets when full cannot lose a held + // thread; at worst it forgets a claim whose session is + // still spawning, and only if thousands of other threads + // were addressed within that same few-second window. + if addressed_threads.len() >= ADDRESSED_THREADS_CAP { + addressed_threads.clear(); } + addressed_threads.insert(key); + } + } - // (v0.8.5 B1 / B1b) A non-text message legitimately carries empty - // `content`: a selection callback (inline-button / web-chip click — - // its real payload is `msg.selection`, resolved in the gateway) OR - // an attachment-only message (a file/photo with no caption — the - // staged `msg.attachments` are Read by the agent via the `` - // tag). The security layer's `EmptyAfterSanitize` is expected for - // both, not an attack. ACL + rate-limit (which run *before* the - // sanitize check in `evaluate`) still gate it; only the empty-text - // rejection is waived. Without this, every D3/D6 button AND every - // captionless inbound file is silently dropped here (on Telegram - // *and* web chat — both feed this consumer). - // v0.8.20 F2 — a per-tenant bot's channel is "@"; - // the ACL is keyed by PLATFORM (fail-closed on unknown), so strip the - // tenant suffix before the gate (reply routing still uses the full - // channel name elsewhere). - let outcome = sec.lock().await.evaluate( - crate::transport::platform_of(&msg.channel), - &msg.sender, - &msg.content, - ); - let has_nontext_payload = msg.selection.is_some() || !msg.attachments.is_empty(); - let Some(clean_payload) = sec_gate_payload(outcome.clone(), has_nontext_payload) else { + // (v0.8.5 B1 / B1b) A non-text message legitimately carries empty + // `content`: a selection callback (inline-button / web-chip click — + // its real payload is `msg.selection`, resolved in the gateway) OR + // an attachment-only message (a file/photo with no caption — the + // staged `msg.attachments` are Read by the agent via the `` + // tag). The security layer's `EmptyAfterSanitize` is expected for + // both, not an attack. ACL + rate-limit (which run *before* the + // sanitize check in `evaluate`) still gate it; only the empty-text + // rejection is waived. Without this, every D3/D6 button AND every + // captionless inbound file is silently dropped here (on Telegram + // *and* web chat — both feed this consumer). + // v0.8.20 F2 — a per-tenant bot's channel is "@"; + // the ACL is keyed by PLATFORM (fail-closed on unknown), so strip the + // tenant suffix before the gate (reply routing still uses the full + // channel name elsewhere). + let outcome = sec.lock().await.evaluate( + crate::transport::platform_of(&msg.channel), + &msg.sender, + &msg.content, + ); + let has_nontext_payload = msg.selection.is_some() || !msg.attachments.is_empty(); + let Some(clean_payload) = sec_gate_payload(outcome.clone(), has_nontext_payload) else { + tracing::warn!( + cid = %cid, + outcome = ?outcome, + "ccteam-im: gateway inbound rejected by security layer" + ); + return; + }; + + let restore_incomplete = !*restore_complete.borrow(); + if clean_payload.split_whitespace().next() == Some("/sessions") && restore_incomplete { + // Startup restore deliberately runs outside the gateway lock + // so the daemon and web face remain available while vendor + // children resume. A listing must not expose a partial prefix + // of that batch (s1 applied while s2 is still spawning), so + // park ONLY this request on the explicit completion signal. + // Its own task keeps the inbound consumer free to serve every + // unrelated message during restore. + let gateway = Arc::clone(gateway); + let channel = Arc::clone(&channel); + let msg = msg.clone(); + let cid = cid.clone(); + let mut restore_complete = restore_complete.clone(); + let ccteam_root = ccteam_root.to_path_buf(); + tokio::spawn(async move { + if restore_complete.changed().await.is_err() { tracing::warn!( - cid = %cid, - outcome = ?outcome, - "ccteam-im: gateway inbound rejected by security layer" + "ccteam-im: restore task ended before session-list readiness was signalled" ); - continue; - }; - - let restore_incomplete = !*restore_complete.borrow(); - if clean_payload.split_whitespace().next() == Some("/sessions") && restore_incomplete { - // Startup restore deliberately runs outside the gateway lock - // so the daemon and web face remain available while vendor - // children resume. A listing must not expose a partial prefix - // of that batch (s1 applied while s2 is still spawning), so - // park ONLY this request on the explicit completion signal. - // Its own task keeps the inbound consumer free to serve every - // unrelated message during restore. - let gateway = Arc::clone(&gateway); - let channel = Arc::clone(&channel); - let msg = msg.clone(); - let cid = cid.clone(); - let mut restore_complete = restore_complete.clone(); - let ccteam_root = ccteam_root.clone(); - tokio::spawn(async move { - if restore_complete.changed().await.is_err() { - tracing::warn!( - "ccteam-im: restore task ended before session-list readiness was signalled" - ); - } - let replies = gateway - .lock() - .await - .handle_message( - &msg.channel, - &msg.reply_target, - &msg.sender, - thread.as_deref(), - &msg.id, - &clean_payload, - &msg.attachments, - msg.selection.as_ref(), - ) - .await; - deliver_gateway_replies( - &ccteam_root, - &cid, - route_t0, - &msg, - channel.as_ref(), - replies, - ) - .await; - }); - continue; } - - // v0.8.x (concurrency review §4.1 P1) — LOCKING PROTOCOL. Before - // this fix, `gateway.lock().await.handle_message(...).await` held - // the gateway's global lock for the ENTIRE call, and this loop - // awaited it inline before pulling the next message off `rx` — so - // one chat spawning a session (tmux/subprocess spawn, stream-json - // `system:init`) queued every other chat's message behind it, - // both on the lock AND on this loop never reaching `rx.recv()` - // again. - // - // `inbound_may_spawn` is a cheap, synchronous, always-safe-either- - // way hint: a message that can only ever SUBMIT to an - // already-live session (no gateway command, no `@mention`, the - // chat already has a current session) is still processed INLINE - // exactly as before — its own lock section is bounded by the - // adapter's submit timeout, never a spawn, so keeping it on this - // loop costs nothing. A message that might need the implicit - // first-message spawn is instead handed to - // `Gateway::handle_message_shared` on its OWN task: that entry - // point plans under a short lock, drops the lock for the slow - // spawn, then re-locks briefly to apply the result — freeing this - // loop to `rx.recv()` the next chat's message immediately rather - // than queuing behind the spawn. - // - // SCOPE: explicit spawning commands (`/new` `/role` `/use` - // `/clear` `@mention`-to-a-template) are NOT decomposed in this - // pass — `inbound_may_spawn` returns `false` for them, so they - // still run inline, holding the gateway lock across their own - // spawn exactly as before (a pre-existing, documented tradeoff — - // same as `start_session`'s inline compose). Dead-child resume - // is three-phase (v0.9 T2); `resume_dead_session_shared` is the - // lock-free form when a shared handle is available. A scoped, - // incremental step, not a full fix. - let may_spawn = { - let g = gateway.lock().await; - g.inbound_may_spawn( - &msg.channel, - &msg.reply_target, - &msg.sender, - thread.as_deref(), - &clean_payload, - msg.selection.is_some(), - ) - }; - // `/stop` joins the same off-loop entry point for a different - // reason: it has to take the stopped child's delegation claim, and - // a claim is never taken while the gateway lock is held (issue #197 - // E). `handle_message_shared` is where the lock can be dropped. - if may_spawn || Gateway::inbound_needs_shared_entry(&clean_payload) { - let gateway = Arc::clone(&gateway); - let channel = Arc::clone(&channel); - let msg = msg.clone(); - let cid = cid.clone(); - let ccteam_root = ccteam_root.clone(); - tokio::spawn(async move { - let replies = Gateway::handle_message_shared( - gateway, - &msg.channel, - &msg.reply_target, - &msg.sender, - thread.as_deref(), - &msg.id, - &clean_payload, - &msg.attachments, - msg.selection.as_ref(), - ) - .await; - deliver_gateway_replies( - &ccteam_root, - &cid, - route_t0, - &msg, - channel.as_ref(), - replies, - ) - .await; - }); - continue; - } - let replies = gateway .lock() .await @@ -1722,9 +1688,104 @@ fn spawn_inbound_consumer( replies, ) .await; - } - tracing::debug!("imd: inbound consumer exited (all senders closed)"); - }) + }); + return; + } + + // v0.8.x (concurrency review §4.1 P1) — LOCKING PROTOCOL. Before + // this fix, `gateway.lock().await.handle_message(...).await` held + // the gateway's global lock for the ENTIRE call, and this loop + // awaited it inline before pulling the next message off `rx` — so + // one chat spawning a session (tmux/subprocess spawn, stream-json + // `system:init`) queued every other chat's message behind it, + // both on the lock AND on this loop never reaching `rx.recv()` + // again. + // + // `inbound_may_spawn` is a cheap, synchronous, always-safe-either- + // way hint: a message that can only ever SUBMIT to an + // already-live session (no gateway command, no `@mention`, the + // chat already has a current session) is still processed INLINE + // exactly as before — its own lock section is bounded by the + // adapter's submit timeout, never a spawn, so keeping it on this + // loop costs nothing. A message that might need the implicit + // first-message spawn is instead handed to + // `Gateway::handle_message_shared` on its OWN task: that entry + // point plans under a short lock, drops the lock for the slow + // spawn, then re-locks briefly to apply the result — freeing this + // loop to `rx.recv()` the next chat's message immediately rather + // than queuing behind the spawn. + // + // SCOPE: explicit spawning commands (`/new` `/role` `/use` + // `/clear` `@mention`-to-a-template) are NOT decomposed in this + // pass — `inbound_may_spawn` returns `false` for them, so they + // still run inline, holding the gateway lock across their own + // spawn exactly as before (a pre-existing, documented tradeoff — + // same as `start_session`'s inline compose). Dead-child resume + // is three-phase (v0.9 T2); `resume_dead_session_shared` is the + // lock-free form when a shared handle is available. A scoped, + // incremental step, not a full fix. + let may_spawn = { + let g = gateway.lock().await; + g.inbound_may_spawn( + &msg.channel, + &msg.reply_target, + &msg.sender, + thread.as_deref(), + &clean_payload, + msg.selection.is_some(), + ) + }; + // `/stop` joins the same off-loop entry point for a different + // reason: it has to take the stopped child's delegation claim, and + // a claim is never taken while the gateway lock is held (issue #197 + // E). `handle_message_shared` is where the lock can be dropped. + if may_spawn || Gateway::inbound_needs_shared_entry(&clean_payload) { + let gateway = Arc::clone(gateway); + let channel = Arc::clone(&channel); + let msg = msg.clone(); + let cid = cid.clone(); + let ccteam_root = ccteam_root.to_path_buf(); + tokio::spawn(async move { + let replies = Gateway::handle_message_shared( + gateway, + &msg.channel, + &msg.reply_target, + &msg.sender, + thread.as_deref(), + &msg.id, + &clean_payload, + &msg.attachments, + msg.selection.as_ref(), + ) + .await; + deliver_gateway_replies( + &ccteam_root, + &cid, + route_t0, + &msg, + channel.as_ref(), + replies, + ) + .await; + }); + return; + } + + let replies = gateway + .lock() + .await + .handle_message( + &msg.channel, + &msg.reply_target, + &msg.sender, + thread.as_deref(), + &msg.id, + &clean_payload, + &msg.attachments, + msg.selection.as_ref(), + ) + .await; + deliver_gateway_replies(ccteam_root, &cid, route_t0, &msg, channel.as_ref(), replies).await; } /// The thread an inbound message hands the gateway: its platform thread when @@ -1757,7 +1818,16 @@ async fn deliver_gateway_replies( for (seq, reply) in replies.into_iter().enumerate() { let out = SendMessage::new(reply, msg.reply_target.clone()) .in_thread(msg.thread_ts.clone()); - send_gateway_outbound(ccteam_root, cid, seq, &msg.channel, channel, out).await; + send_gateway_outbound( + ccteam_root, + cid, + seq, + &msg.channel, + channel, + out, + PLATFORM_CALL_TIMEOUT, + ) + .await; } tracing::info!( event = "latency", @@ -1770,7 +1840,16 @@ async fn deliver_gateway_replies( Err(err) => { let out = SendMessage::new(format_gateway_user_error(&err), msg.reply_target.clone()) .in_thread(msg.thread_ts.clone()); - send_gateway_outbound(ccteam_root, cid, 0, &msg.channel, channel, out).await; + send_gateway_outbound( + ccteam_root, + cid, + 0, + &msg.channel, + channel, + out, + PLATFORM_CALL_TIMEOUT, + ) + .await; tracing::warn!( event = "latency", stage = "imd.gateway.err", @@ -1792,133 +1871,219 @@ struct StatusHandle { recipient: String, } +/// Upper bound on ONE platform call (send / edit / reaction) the delivery +/// paths await. Delivery is serial — one consumer for every session — so a +/// single hung platform request must not freeze replies for everyone (issue +/// #210). Generous enough for an attachment upload; a timed-out durable send +/// is recorded `Failed` like any other send error. +const PLATFORM_CALL_TIMEOUT: Duration = Duration::from_secs(120); + +/// Await one platform call under `limit`; an elapsed timer becomes an +/// ordinary `Err` so every caller's existing failure handling applies. +async fn bounded_platform_call( + limit: Duration, + what: &str, + call: impl Future>, +) -> Result { + match tokio::time::timeout(limit, call).await { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!( + "{what} timed out after {}s", + limit.as_secs_f64() + )), + } +} + +/// The consumer's cross-event delivery state. +#[derive(Default)] +struct GatewayEgressState { + /// One editable status message per `status_key` (a turn's progress + /// epoch). Bounded: entries are inserted on the first progress of a + /// turn and removed when the turn finalizes (`done`). + status_messages: HashMap, + /// 👀 ack reaction handle map (v0.8.19). Keyed `"{channel}:{message_id}"`; + /// the value is the provider's reaction handle (`Some(reaction_id)` for + /// Lark, `None` for Telegram which clears by message_id alone). An add + /// records the handle; the matching remove pops it. Bounded by the + /// in-flight turn count (each turn adds one entry, removes it on its + /// first event), and any orphan is harmless (a stale handle never + /// dereferences). ALL reaction calls are fire-and-forget — a failure is + /// logged + swallowed, NEVER propagated, so it can't affect delivery. + reaction_handles: HashMap>, +} + fn spawn_gateway_event_consumer( + event_outbox_root: PathBuf, + rx: tokio::sync::mpsc::UnboundedReceiver, + channels: Arc>, +) -> tokio::task::JoinHandle<()> { + spawn_gateway_event_consumer_with_timeout( + event_outbox_root, + rx, + channels, + PLATFORM_CALL_TIMEOUT, + ) +} + +/// The single receiver behind every session's event pump: when it exits, +/// every pump stops at its next event (the sink reports closed), so no reply +/// is delivered anywhere again (issue #210). It therefore survives each +/// event's failure modes — a panic is caught and logged per event, every +/// platform call is bounded by `call_timeout` — and only ends when the +/// daemon drops the senders or aborts it at shutdown. +fn spawn_gateway_event_consumer_with_timeout( event_outbox_root: PathBuf, mut rx: tokio::sync::mpsc::UnboundedReceiver, channels: Arc>, + call_timeout: Duration, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { - // One editable status message per `status_key` (a turn's progress - // epoch). Bounded: entries are inserted on the first progress of a - // turn and removed when the turn finalizes (`done`). - let mut status_messages: HashMap = HashMap::new(); - // 👀 ack reaction handle map (v0.8.19). Keyed `"{channel}:{message_id}"`; - // the value is the provider's reaction handle (`Some(reaction_id)` for - // Lark, `None` for Telegram which clears by message_id alone). An add - // records the handle; the matching remove pops it. Bounded by the - // in-flight turn count (each turn adds one entry, removes it on its - // first event), and any orphan is harmless (a stale handle never - // dereferences). ALL reaction calls are fire-and-forget — a failure is - // logged + swallowed, NEVER propagated, so it can't affect delivery. - let mut reaction_handles: HashMap> = HashMap::new(); + let mut state = GatewayEgressState::default(); while let Some(evt) = rx.recv().await { - // v0.9.0 W4 (F4) — a delegation lifecycle event is broadcast-only - // (the team view's global SSE); it has no IM channel representation - // and no bound `evt.channel` to resolve, so skip it BEFORE the - // channel lookup (avoids a spurious "channel not configured" - // warning firing for every delegation transition). - if matches!( - evt.kind, - GatewayEventKind::Delegation { .. } - | GatewayEventKind::SessionLifecycle { .. } - | GatewayEventKind::ScheduledChanged - ) { - continue; - } - // Clone the channel out under the read lock, then DROP the guard - // before any `.await` (never hold a std RwLock guard across await). - let channel = { - let g = channels.read().unwrap(); - g.get(&evt.channel).cloned() - }; - let Some(channel) = channel else { - tracing::warn!( - channel = %evt.channel, - event_id = %evt.id, - "ccteam-im: gateway event dropped because channel is not configured" - ); - continue; - }; - match evt.kind { - GatewayEventKind::Answer => { - let out = SendMessage::new(evt.content, evt.chat_id) - .in_thread(evt.thread_ts) - .with_attachments(evt.attachments) - .with_options(evt.options); - send_gateway_outbound( - &event_outbox_root, - &evt.id, - 0, - &evt.channel, - channel.as_ref(), - out, - ) - .await; - } - GatewayEventKind::Progress { status_key, done } => { - deliver_progress( - channel.as_ref(), - &mut status_messages, - status_key, - done, - &evt.channel, - evt.chat_id, - evt.thread_ts, - evt.content, - ) - .await; - } - // v0.8.19 — structured per-step activity is WEB-ONLY. IM's - // status is fully driven by the folded `Progress` event above, - // so this is a strict no-op (no send / no edit): IM delivery - // stays byte-identical to before the Activity event existed. - GatewayEventKind::Activity { .. } => {} - // v0.9.0 W4 — unreachable in practice (the early `continue` - // above already skips every `Delegation` event before this - // match), but the arm must exist for exhaustiveness and stays - // correct if that early skip is ever removed. - GatewayEventKind::Delegation { .. } => {} - GatewayEventKind::SessionLifecycle { .. } => {} - GatewayEventKind::ScheduledChanged => {} - // v0.8.19 — the 👀 ack reaction (IM-only; web/discord keep - // the trait's no-op `add_reaction`/`remove_reaction`). Mirror the - // Activity arm's discipline: ALL fire-and-forget — log + swallow, - // never propagate, so a reaction can't break/delay the turn. - GatewayEventKind::Reaction { message_id, on } => { - let key = format!("{}:{}", evt.channel, message_id); - if on { - match channel.add_reaction(&evt.chat_id, &message_id).await { - Ok(handle) => { - reaction_handles.insert(key, handle); - } - Err(err) => { - tracing::warn!( - channel = %evt.channel, - message_id = %message_id, - error = %err, - "ccteam-im: add_reaction failed (ack skipped)" - ); - } - } - } else { - let handle = reaction_handles.remove(&key).flatten(); - if let Err(err) = channel - .remove_reaction(&evt.chat_id, &message_id, handle.as_deref()) - .await - { - tracing::warn!( - channel = %evt.channel, - message_id = %message_id, - error = %err, - "ccteam-im: remove_reaction failed (ack lingers)" - ); - } + let item = format!( + "event={} channel={} sid={}", + evt.id, + evt.channel, + evt.sid.as_deref().unwrap_or("-") + ); + crate::supervise::contain_panic( + "gateway event consumer", + &item, + deliver_gateway_event(&event_outbox_root, &channels, &mut state, evt, call_timeout), + ) + .await; + } + tracing::error!( + "ccteam-im: gateway event consumer exited; session replies are no longer delivered" + ); + }) +} + +/// Deliver one gateway event to its channel. +async fn deliver_gateway_event( + event_outbox_root: &Path, + channels: &RwLock, + state: &mut GatewayEgressState, + evt: GatewayEvent, + call_timeout: Duration, +) { + // v0.9.0 W4 (F4) — a delegation lifecycle event is broadcast-only + // (the team view's global SSE); it has no IM channel representation + // and no bound `evt.channel` to resolve, so skip it BEFORE the + // channel lookup (avoids a spurious "channel not configured" + // warning firing for every delegation transition). + if matches!( + evt.kind, + GatewayEventKind::Delegation { .. } + | GatewayEventKind::SessionLifecycle { .. } + | GatewayEventKind::ScheduledChanged + ) { + return; + } + // Clone the channel out under the read lock, then DROP the guard + // before any `.await` (never hold a std RwLock guard across await). + // A writer that panicked mid-reload poisons the lock but leaves a + // whole map behind — keep delivering from it. + let channel = { + let g = channels.read().unwrap_or_else(|err| err.into_inner()); + g.get(&evt.channel).cloned() + }; + let Some(channel) = channel else { + tracing::warn!( + channel = %evt.channel, + event_id = %evt.id, + "ccteam-im: gateway event dropped because channel is not configured" + ); + return; + }; + match evt.kind { + GatewayEventKind::Answer => { + let out = SendMessage::new(evt.content, evt.chat_id) + .in_thread(evt.thread_ts) + .with_attachments(evt.attachments) + .with_options(evt.options); + send_gateway_outbound( + event_outbox_root, + &evt.id, + 0, + &evt.channel, + channel.as_ref(), + out, + call_timeout, + ) + .await; + } + GatewayEventKind::Progress { status_key, done } => { + deliver_progress( + channel.as_ref(), + &mut state.status_messages, + status_key, + done, + &evt.channel, + evt.chat_id, + evt.thread_ts, + evt.content, + call_timeout, + ) + .await; + } + // v0.8.19 — structured per-step activity is WEB-ONLY. IM's + // status is fully driven by the folded `Progress` event above, + // so this is a strict no-op (no send / no edit): IM delivery + // stays byte-identical to before the Activity event existed. + GatewayEventKind::Activity { .. } => {} + // v0.9.0 W4 — unreachable in practice (the early return above + // already skips every `Delegation` event before this match), but + // the arm must exist for exhaustiveness and stays correct if that + // early skip is ever removed. + GatewayEventKind::Delegation { .. } => {} + GatewayEventKind::SessionLifecycle { .. } => {} + GatewayEventKind::ScheduledChanged => {} + // v0.8.19 — the 👀 ack reaction (IM-only; web/discord keep + // the trait's no-op `add_reaction`/`remove_reaction`). Mirror the + // Activity arm's discipline: ALL fire-and-forget — log + swallow, + // never propagate, so a reaction can't break/delay the turn. + GatewayEventKind::Reaction { message_id, on } => { + let key = format!("{}:{}", evt.channel, message_id); + if on { + match bounded_platform_call( + call_timeout, + "add_reaction", + channel.add_reaction(&evt.chat_id, &message_id), + ) + .await + { + Ok(handle) => { + state.reaction_handles.insert(key, handle); + } + Err(err) => { + tracing::warn!( + channel = %evt.channel, + message_id = %message_id, + error = %err, + "ccteam-im: add_reaction failed (ack skipped)" + ); } } + } else { + let handle = state.reaction_handles.remove(&key).flatten(); + if let Err(err) = bounded_platform_call( + call_timeout, + "remove_reaction", + channel.remove_reaction(&evt.chat_id, &message_id, handle.as_deref()), + ) + .await + { + tracing::warn!( + channel = %evt.channel, + message_id = %message_id, + error = %err, + "ccteam-im: remove_reaction failed (ack lingers)" + ); + } } } - tracing::debug!("imd: gateway event consumer exited"); - }) + } } /// Deliver one progress update: send a fresh status message the first @@ -1935,11 +2100,15 @@ async fn deliver_progress( chat_id: String, thread_ts: Option, content: String, + call_timeout: Duration, ) { if let Some(handle) = status_messages.get(&status_key).cloned() { - if let Err(err) = channel - .edit_message(&handle.recipient, &handle.message_id, &content) - .await + if let Err(err) = bounded_platform_call( + call_timeout, + "progress edit", + channel.edit_message(&handle.recipient, &handle.message_id, &content), + ) + .await { tracing::warn!( channel = %channel_name, @@ -1955,7 +2124,7 @@ async fn deliver_progress( } // First progress for this turn — send a new status message (the seed). let seed = SendMessage::new(content, chat_id.clone()).in_thread(thread_ts.clone()); - match channel.send(&seed).await { + match bounded_platform_call(call_timeout, "progress seed send", channel.send(&seed)).await { Ok(Some(message_id)) if !done => { status_messages.insert( status_key, @@ -1980,6 +2149,14 @@ async fn deliver_progress( #[derive(Debug, Clone, Serialize, Deserialize)] struct DurableOutboundRow { ts_ms: u64, + /// When this id was FIRST queued — carried on every later row of the id + /// so the replay age bound survives compaction. + #[serde(default)] + first_queued_ms: u64, + /// Send attempts STARTED for this id (a `Queued` row is written ahead of + /// each one, so an attempt the daemon died inside still counts). + #[serde(default)] + attempts: u32, id: String, inbound_id: String, channel: String, @@ -1995,8 +2172,28 @@ enum DurableOutboundState { Queued, Sent, Failed, + /// Terminal without delivery: replay gave up on it (attempt or age + /// bound, or its send panicked). Never replayed; compaction drops it. + GaveUp, +} + +impl DurableOutboundState { + /// Nothing is owed for an id whose latest row is in this state. + fn is_settled(self) -> bool { + matches!(self, Self::Sent | Self::GaveUp) + } } +/// Send attempts an id gets in total (the live one plus replays). A `Failed` +/// send may still have landed — a 120 s timeout says nothing about whether +/// the platform accepted the message — so every replay of it risks a +/// duplicate; unbounded, each daemon start re-sent every failure ever. +const DURABLE_OUTBOX_MAX_ATTEMPTS: u32 = 2; + +/// A reply older than this is not worth sending late (the conversation has +/// moved on); replay gives up on it. +const DURABLE_OUTBOX_MAX_REPLAY_AGE: Duration = Duration::from_secs(24 * 3600); + /// The at-least-once outbound ledger, under the daemon's own ccteam root /// (`daemon_ccteam_root`) — threaded from `run_daemon_with_shutdown` rather /// than re-read from env, so an in-process daemon writes into the root its @@ -2012,6 +2209,7 @@ async fn send_gateway_outbound( channel_name: &str, channel: &(dyn Channel + Send + Sync), message: SendMessage, + call_timeout: Duration, ) { // Channel-neutral splitting (V0.8.4 P0 / B2): when the channel // declares a per-message ceiling and the content overflows it, fan @@ -2030,8 +2228,16 @@ async fn send_gateway_outbound( if parts.len() <= 1 { // Unchanged single-message path: id = `{inbound_id}-{seq}`. let id = format!("{inbound_id}-{seq}"); - queue_and_send_durable_part(ccteam_root, id, inbound_id, channel_name, channel, message) - .await; + queue_and_send_durable_part( + ccteam_root, + id, + inbound_id, + channel_name, + channel, + message, + call_timeout, + ) + .await; return; } @@ -2051,6 +2257,7 @@ async fn send_gateway_outbound( channel_name, channel, part_msg, + call_timeout, ) .await; if !sent { @@ -2070,7 +2277,9 @@ async fn send_gateway_outbound( }; let notice = SendMessage::new(body, message.recipient.clone()).in_thread(message.thread_ts.clone()); - if let Err(err) = channel.send(¬ice).await { + if let Err(err) = + bounded_platform_call(call_timeout, "split-failure notice", channel.send(¬ice)).await + { tracing::warn!( inbound_id, channel = %channel_name, @@ -2091,87 +2300,107 @@ async fn queue_and_send_durable_part( channel_name: &str, channel: &(dyn Channel + Send + Sync), message: SendMessage, + call_timeout: Duration, ) -> bool { - append_durable_outbound( - ccteam_root, - DurableOutboundRow { - ts_ms: now_unix_ms_u64(), - id: id.clone(), - inbound_id: inbound_id.to_string(), - channel: channel_name.to_string(), - state: DurableOutboundState::Queued, - message: message.clone(), - platform_message_id: None, - error: None, - }, - ); - finish_durable_outbound_send(ccteam_root, id, inbound_id, channel_name, channel, message).await + let now = now_unix_ms_u64(); + let queued = DurableOutboundRow { + ts_ms: now, + first_queued_ms: now, + attempts: 1, + id, + inbound_id: inbound_id.to_string(), + channel: channel_name.to_string(), + state: DurableOutboundState::Queued, + message, + platform_message_id: None, + error: None, + }; + append_durable_outbound(ccteam_root, queued.clone()); + finish_durable_outbound_send(ccteam_root, queued, channel, call_timeout).await } /// Send a single already-queued durable row and append its terminal /// (`Sent`/`Failed`) ledger entry. Returns `true` on success. async fn finish_durable_outbound_send( ccteam_root: &Path, - id: String, - inbound_id: &str, - channel_name: &str, + queued: DurableOutboundRow, channel: &(dyn Channel + Send + Sync), - message: SendMessage, + call_timeout: Duration, ) -> bool { - match channel.send(&message).await { - Ok(platform_message_id) => { - append_durable_outbound( - ccteam_root, - DurableOutboundRow { - ts_ms: now_unix_ms_u64(), - id, - inbound_id: inbound_id.to_string(), - channel: channel_name.to_string(), - state: DurableOutboundState::Sent, - message, - platform_message_id, - error: None, - }, - ); - true - } + let result = bounded_platform_call(call_timeout, "send", channel.send(&queued.message)).await; + let sent = result.is_ok(); + let terminal = match result { + Ok(platform_message_id) => DurableOutboundRow { + ts_ms: now_unix_ms_u64(), + state: DurableOutboundState::Sent, + platform_message_id, + error: None, + ..queued + }, Err(err) => { - append_durable_outbound( - ccteam_root, - DurableOutboundRow { - ts_ms: now_unix_ms_u64(), - id, - inbound_id: inbound_id.to_string(), - channel: channel_name.to_string(), - state: DurableOutboundState::Failed, - message, - platform_message_id: None, - error: Some(err.to_string()), - }, - ); tracing::warn!( - inbound_id, - channel = %channel_name, + inbound_id = %queued.inbound_id, + channel = %queued.channel, error = %err, "ccteam-im: gateway outbound send failed" ); - false + DurableOutboundRow { + ts_ms: now_unix_ms_u64(), + state: DurableOutboundState::Failed, + platform_message_id: None, + error: Some(err.to_string()), + ..queued + } } - } + }; + append_durable_outbound(ccteam_root, terminal); + sent } -async fn replay_durable_outbox(ccteam_root: &Path, channels: &ChannelMap) { - let path = durable_outbox_path_in(ccteam_root); - let Ok(raw) = std::fs::read_to_string(&path) else { - return; +/// Past this size the ledger is compacted on the next append. Every reply +/// costs a Queued + Sent pair (the full message twice), so an uncompacted +/// ledger grows with every reply the daemon ever sent (issue #213: 80 MB +/// after two months, all of it terminal) and the startup replay scans all of +/// it. Compaction keeps it bounded by what is still owed. +const DURABLE_OUTBOX_COMPACT_BYTES: u64 = 8 * 1024 * 1024; + +/// Serializes every ledger append and compaction in this process — a +/// compaction rewrites the file, so an append must not land between its read +/// and its rename. +fn durable_outbox_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) +} + +/// The rows still owed a delivery: the LATEST row of every id whose latest +/// state is not settled (`Sent`/`GaveUp`), in the order the ids were first +/// queued. Streams the +/// file line by line rather than holding it whole. +fn read_owed_durable_outbound(path: &Path) -> Vec { + use std::io::BufRead; + let Ok(file) = std::fs::File::open(path) else { + return Vec::new(); }; + let mut order: Vec = Vec::new(); let mut latest: HashMap = HashMap::new(); - for (line_idx, line) in raw.lines().enumerate() { + for (line_idx, line) in std::io::BufReader::new(file).lines().enumerate() { + let Ok(line) = line else { + // Not UTF-8 or unreadable: same as a malformed row. + continue; + }; if line.trim().is_empty() { continue; } - match serde_json::from_str::(line) { + match serde_json::from_str::(&line) { + Ok(row) if row.state.is_settled() => { + latest.remove(&row.id); + } Ok(row) => { + if !latest.contains_key(&row.id) { + order.push(row.id.clone()); + } latest.insert(row.id.clone(), row); } Err(err) => { @@ -2184,35 +2413,143 @@ async fn replay_durable_outbox(ccteam_root: &Path, channels: &ChannelMap) { } } } - for row in latest - .into_values() - .filter(|row| row.state != DurableOutboundState::Sent) - { - let Some(channel) = channels.get(&row.channel) else { - append_durable_outbound( - ccteam_root, - DurableOutboundRow { - ts_ms: now_unix_ms_u64(), - id: row.id, - inbound_id: row.inbound_id, - channel: row.channel, - state: DurableOutboundState::Failed, - message: row.message, - platform_message_id: None, - error: Some("replay failed: channel is not configured".to_string()), - }, - ); - continue; - }; - finish_durable_outbound_send( + order + .into_iter() + .filter_map(|id| latest.remove(&id)) + .collect() +} + +/// Rewrite the ledger to just the rows still owed (see +/// [`read_owed_durable_outbound`]). Caller holds [`durable_outbox_lock`]. +/// A settled row carries nothing replay could use, so dropping it keeps the +/// at-least-once contract intact: every Queued/Failed id survives with its +/// latest row. Atomic (temp + rename), so a crash mid-compaction leaves the +/// old ledger. +fn compact_durable_outbox_locked(path: &Path) -> Result> { + let owed = read_owed_durable_outbound(path); + if !path.exists() { + return Ok(owed); + } + let mut bytes = Vec::new(); + for row in &owed { + serde_json::to_writer(&mut bytes, row)?; + bytes.push(b'\n'); + } + ccteam_harness::execution::fs_atomic::atomic_write_durable(path, &bytes)?; + last_compacted_len(path, Some(bytes.len() as u64)); + Ok(owed) +} + +/// The ledger size the last compaction of `path` left (`record` updates it). +/// Caller holds [`durable_outbox_lock`]. +fn last_compacted_len(path: &Path, record: Option) -> u64 { + static LAST: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + let mut last = LAST + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|err| err.into_inner()); + match record { + Some(len) => { + last.insert(path.to_path_buf(), len); + len + } + None => last.get(path).copied().unwrap_or(0), + } +} + +async fn replay_durable_outbox(ccteam_root: &Path, channels: &ChannelMap) { + let path = durable_outbox_path_in(ccteam_root); + let owed = { + let _guard = durable_outbox_lock(); + match compact_durable_outbox_locked(&path) { + Ok(owed) => owed, + Err(err) => { + tracing::warn!(path = %path.display(), error = %err, "ccteam-im: durable outbox compaction failed; replaying uncompacted"); + read_owed_durable_outbound(&path) + } + } + }; + for row in owed { + replay_durable_row(ccteam_root, channels, row, PLATFORM_CALL_TIMEOUT).await; + } +} + +/// Replay one owed row: give up on it past the attempt / age bound, else +/// write the attempt ahead and send it. A send that panics (a row whose +/// content deterministically trips a provider bug) is caught here and marked +/// `GaveUp` — uncaught, it would abort the replay and panic again on every +/// daemon start, holding every later row hostage. +async fn replay_durable_row( + ccteam_root: &Path, + channels: &ChannelMap, + row: DurableOutboundRow, + call_timeout: Duration, +) { + use futures::FutureExt; + let first_queued_ms = if row.first_queued_ms == 0 { + row.ts_ms + } else { + row.first_queued_ms + }; + let age = Duration::from_millis(now_unix_ms_u64().saturating_sub(first_queued_ms)); + let give_up = if row.attempts >= DURABLE_OUTBOX_MAX_ATTEMPTS { + Some(format!("gave up after {} send attempts", row.attempts)) + } else if age > DURABLE_OUTBOX_MAX_REPLAY_AGE { + Some(format!("gave up: queued {}h ago", age.as_secs() / 3600)) + } else { + None + }; + if let Some(reason) = give_up { + tracing::warn!(id = %row.id, channel = %row.channel, %reason, "ccteam-im: durable outbound row not replayed"); + append_durable_outbound(ccteam_root, settled_row(row, reason)); + return; + } + let Some(channel) = channels.get(&row.channel) else { + // Nothing was sent, so no attempt is spent: the row stays owed (as + // Queued or Failed, whichever it was) until the channel comes back or + // the age bound gives up on it. Only the reason is refreshed. + append_durable_outbound( ccteam_root, - row.id, - &row.inbound_id, - &row.channel, - channel.as_ref(), - row.message, - ) - .await; + DurableOutboundRow { + ts_ms: now_unix_ms_u64(), + first_queued_ms, + error: Some("replay failed: channel is not configured".to_string()), + ..row + }, + ); + return; + }; + let queued = DurableOutboundRow { + ts_ms: now_unix_ms_u64(), + first_queued_ms, + attempts: row.attempts + 1, + state: DurableOutboundState::Queued, + platform_message_id: None, + error: None, + ..row + }; + append_durable_outbound(ccteam_root, queued.clone()); + let send = + finish_durable_outbound_send(ccteam_root, queued.clone(), channel.as_ref(), call_timeout); + if let Err(panic) = std::panic::AssertUnwindSafe(send).catch_unwind().await { + let reason = format!( + "replay send panicked: {}", + crate::supervise::panic_message(panic.as_ref()) + ); + tracing::error!(id = %queued.id, channel = %queued.channel, %reason, "ccteam-im: durable outbound replay panicked; row given up, replay continues"); + append_durable_outbound(ccteam_root, settled_row(queued, reason)); + } +} + +/// `row` marked [`DurableOutboundState::GaveUp`] with `reason`. +fn settled_row(row: DurableOutboundRow, reason: String) -> DurableOutboundRow { + DurableOutboundRow { + ts_ms: now_unix_ms_u64(), + state: DurableOutboundState::GaveUp, + platform_message_id: None, + error: Some(reason), + ..row } } @@ -2228,21 +2565,38 @@ fn append_durable_outbound(ccteam_root: &Path, row: DurableOutboundRow) { } fn append_durable_outbound_inner(ccteam_root: &Path, row: &DurableOutboundRow) -> Result<()> { - static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); - let _guard = LOCK - .get_or_init(|| std::sync::Mutex::new(())) - .lock() - .unwrap_or_else(|err| err.into_inner()); + append_durable_outbound_with_limit(ccteam_root, row, DURABLE_OUTBOX_COMPACT_BYTES) +} + +fn append_durable_outbound_with_limit( + ccteam_root: &Path, + row: &DurableOutboundRow, + compact_bytes: u64, +) -> Result<()> { + let _guard = durable_outbox_lock(); let path = durable_outbox_path_in(ccteam_root); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path)?; - serde_json::to_writer(&mut file, row)?; - file.write_all(b"\n")?; + // One `write_all` per record (see `open_jsonl_append`). + let mut line = serde_json::to_vec(row)?; + line.push(b'\n'); + let mut file = ccteam_harness::execution::fs_atomic::open_jsonl_append(&path)?; + file.write_all(&line)?; + // Only a terminal row can shrink the ledger; checking there keeps the + // compaction off the Queued → send hot path. The bound has headroom over + // what the last compaction kept: when the OWED rows alone are past + // `compact_bytes`, a fixed bound would rewrite (and fsync) all of them on + // every later failure. + let bound = compact_bytes.max(2 * last_compacted_len(&path, None)); + if row.state != DurableOutboundState::Queued && file.metadata()?.len() > bound { + drop(file); + if let Err(err) = compact_durable_outbox_locked(&path) { + // The row is already appended; a failed compaction only defers + // the shrink to the next terminal append. + tracing::warn!(path = %path.display(), error = %err, "ccteam-im: durable outbox compaction failed"); + } + } Ok(()) } @@ -2656,6 +3010,466 @@ mod tests { ); } + /// A channel whose `send` panics or never returns for one marked message + /// and records every other send in the wrapped `MockChannel`. + struct TrapChannel { + inner: crate::transport::providers::mock::MockChannel, + } + + #[async_trait::async_trait] + impl Channel for TrapChannel { + fn name(&self) -> &str { + "trap" + } + + async fn send(&self, message: &SendMessage) -> Result> { + if message.content.contains("PANIC") { + panic!("trap channel panicked on send"); + } + if message.content.contains("HANG") { + std::future::pending::<()>().await; + } + if message.content.contains("FAIL") { + anyhow::bail!("trap channel refused the send"); + } + self.inner.send(message).await + } + + async fn listen(&self, _tx: tokio::sync::mpsc::Sender) -> Result<()> { + Ok(()) + } + } + + fn trap_answer(id: &str, content: &str) -> GatewayEvent { + GatewayEvent { + interim: false, + id: id.to_string(), + channel: "trap".to_string(), + chat_id: "chat-1".to_string(), + thread_ts: None, + content: content.to_string(), + kind: GatewayEventKind::Answer, + attachments: Vec::new(), + options: Vec::new(), + status: None, + sid: Some("s1".to_string()), + slug: None, + } + } + + /// Drive the real consumer over a `TrapChannel` and return what the + /// channel recorded once `want` sends landed (or the wait ran out). + async fn deliver_through_trap( + events: Vec, + call_timeout: Duration, + want: usize, + outbox_root: &Path, + ) -> Vec { + let mock = crate::transport::providers::mock::MockChannel::new(); + let mut channels: ChannelMap = HashMap::new(); + channels.insert( + "trap".to_string(), + Arc::new(TrapChannel { + inner: mock.clone(), + }), + ); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let consumer = spawn_gateway_event_consumer_with_timeout( + outbox_root.to_path_buf(), + rx, + Arc::new(RwLock::new(channels)), + call_timeout, + ); + for evt in events { + tx.send(evt).unwrap(); + } + let mut sent = Vec::new(); + for _ in 0..150 { + sent = mock.outbox().await.into_iter().map(|m| m.content).collect(); + if sent.len() >= want { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + !tx.is_closed(), + "the gateway event consumer must still be receiving" + ); + consumer.abort(); + sent + } + + /// Issue #210: one event whose delivery panics must not take the single + /// delivery consumer down with it — when it did, every session's pump + /// stopped at its next event and no reply was ever delivered again. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn gateway_consumer_survives_a_panicking_delivery() { + let outbox_root = TempDir::new().unwrap(); + let sent = deliver_through_trap( + vec![ + trap_answer("e1", "before"), + trap_answer("e2", "PANIC"), + trap_answer("e3", "after"), + ], + PLATFORM_CALL_TIMEOUT, + 2, + outbox_root.path(), + ) + .await; + assert_eq!(sent, vec!["before", "after"]); + } + + /// Issue #210: a platform call that never returns is bounded, recorded + /// `Failed` in the durable ledger, and later events still go out. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn gateway_consumer_bounds_a_hung_platform_call() { + let outbox_root = TempDir::new().unwrap(); + let sent = deliver_through_trap( + vec![trap_answer("e1", "HANG"), trap_answer("e2", "after")], + Duration::from_millis(200), + 1, + outbox_root.path(), + ) + .await; + assert_eq!(sent, vec!["after"]); + + let ledger = std::fs::read_to_string(durable_outbox_path_in(outbox_root.path())).unwrap(); + let hung: Vec = ledger + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|row| row.id == "e1-0") + .collect(); + let last = hung.last().expect("hung send is in the ledger"); + assert_eq!(last.state, DurableOutboundState::Failed); + assert!( + last.error.as_deref().unwrap_or("").contains("timed out"), + "{:?}", + last.error + ); + } + + /// Issue #210: a durable-outbox row appended after an ENOSPC-torn tail is + /// still readable — it is not glued onto the fragment. + #[test] + fn durable_outbox_append_after_torn_tail_keeps_the_row() { + let root = TempDir::new().unwrap(); + let path = durable_outbox_path_in(root.path()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"{\"ts_ms\":1,\"id\":\"torn").unwrap(); + append_durable_outbound_inner( + root.path(), + &DurableOutboundRow { + ts_ms: 2, + first_queued_ms: 2, + attempts: 1, + id: "o-1".into(), + inbound_id: "in-1".into(), + channel: "trap".into(), + state: DurableOutboundState::Queued, + message: SendMessage::new("hi", "chat-1"), + platform_message_id: None, + error: None, + }, + ) + .unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + let rows: Vec = raw + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + assert_eq!(rows.len(), 1, "{raw}"); + assert_eq!(rows[0].id, "o-1"); + } + + /// An inbound channel whose `mention_policy` panics the first time the + /// consumer routes a message through it, and counts every call. + struct PanicOnceInbound { + calls: Arc, + } + + #[async_trait::async_trait] + impl Channel for PanicOnceInbound { + fn name(&self) -> &str { + "trap" + } + + async fn send(&self, _message: &SendMessage) -> Result> { + Ok(None) + } + + async fn listen(&self, _tx: tokio::sync::mpsc::Sender) -> Result<()> { + Ok(()) + } + + fn mention_policy(&self) -> crate::transport::MentionPolicy { + if self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + panic!("trap channel panicked routing a message"); + } + crate::transport::MentionPolicy::default() + } + } + + /// Issue #210 (inbound side): one message whose routing panics must not + /// end the only inbound consumer — the next message is still routed. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn inbound_consumer_survives_a_panicking_message() { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut channels: ChannelMap = HashMap::new(); + channels.insert( + "trap".to_string(), + Arc::new(PanicOnceInbound { + calls: Arc::clone(&calls), + }), + ); + let root = TempDir::new().unwrap(); + let gateway = Arc::new(Mutex::new(Gateway::new( + Arc::new(ccteam_harness::execution::CodexExecAdapter::new()), + "alpha", + root.path(), + ))); + let (_restore_tx, restore_rx) = tokio::sync::watch::channel(true); + let (tx, rx) = tokio::sync::mpsc::channel::(8); + let consumer = spawn_inbound_consumer( + root.path().to_path_buf(), + rx, + Arc::new(RwLock::new(channels)), + Arc::new(Mutex::new(ThreeLayerSec::new(AclPolicy::default()))), + gateway, + restore_rx, + ); + let msg = |id: &str| ChannelMessage { + id: id.into(), + sender: "alice".into(), + reply_target: "chat-1".into(), + content: "hi".into(), + channel: "trap".into(), + timestamp: 0, + thread_ts: None, + attachments: Vec::new(), + selection: None, + ambient: false, + }; + tx.send(msg("m-1")).await.unwrap(); + tx.send(msg("m-2")).await.unwrap(); + for _ in 0..150 { + if calls.load(std::sync::atomic::Ordering::SeqCst) >= 2 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 2, + "the message after the panic must still be routed" + ); + assert!(!consumer.is_finished(), "the inbound consumer must survive"); + consumer.abort(); + } + + fn owed_row(id: &str, state: DurableOutboundState, attempts: u32) -> DurableOutboundRow { + let now = now_unix_ms_u64(); + DurableOutboundRow { + ts_ms: now, + first_queued_ms: now, + attempts, + id: id.into(), + inbound_id: "in".into(), + channel: "trap".into(), + state, + message: SendMessage::new(format!("body of {id} {}", "z".repeat(200)), "chat-1"), + platform_message_id: None, + error: None, + } + } + + /// Issue #213: a replay to a channel that is not configured sends nothing, + /// so it spends no attempt — the row is still delivered once the channel + /// is back, however many starts went by without it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn replay_to_a_missing_channel_spends_no_attempt() { + let root = TempDir::new().unwrap(); + append_durable_outbound_inner( + root.path(), + &owed_row("waiting", DurableOutboundState::Queued, 1), + ) + .unwrap(); + let none: ChannelMap = HashMap::new(); + for _ in 0..3 { + replay_durable_outbox(root.path(), &none).await; + } + let path = durable_outbox_path_in(root.path()); + let owed = read_owed_durable_outbound(&path); + assert_eq!(owed.len(), 1); + assert_eq!(owed[0].attempts, 1, "no send happened, no attempt spent"); + + let mock = crate::transport::providers::mock::MockChannel::new(); + let mut channels: ChannelMap = HashMap::new(); + channels.insert( + "trap".to_string(), + Arc::new(TrapChannel { + inner: mock.clone(), + }), + ); + replay_durable_outbox(root.path(), &channels).await; + assert_eq!(mock.outbox().await.len(), 1); + assert!(read_owed_durable_outbound(&path).is_empty()); + } + + /// Issue #213: when the owed rows alone exceed the compaction bound, a + /// compaction that keeps them raises the next trigger to twice what it + /// kept — not a full rewrite (with fsync) on every later terminal append. + #[test] + fn compaction_bound_has_headroom_over_what_is_owed() { + let root = TempDir::new().unwrap(); + let limit = 4096; + let append = |r: DurableOutboundRow| { + append_durable_outbound_with_limit(root.path(), &r, limit).unwrap(); + }; + // Owed Failed rows well past the bound: the crossing append compacts, + // keeping all of them. + for i in 0..12 { + append(owed_row( + &format!("owed-{i}"), + DurableOutboundState::Failed, + 1, + )); + } + let path = durable_outbox_path_in(root.path()); + let kept = std::fs::metadata(&path).unwrap().len(); + assert!(kept > limit, "owed rows alone exceed the bound: {kept}"); + // A delivered row now: a fixed bound would compact it straight out. + append(owed_row("done", DurableOutboundState::Queued, 1)); + append(owed_row("done", DurableOutboundState::Sent, 1)); + let raw = std::fs::read_to_string(&path).unwrap(); + assert!( + raw.contains("\"id\":\"done\""), + "no rewrite until the ledger doubles what was kept" + ); + assert_eq!(read_owed_durable_outbound(&path).len(), 12); + } + + /// Issue #213: a startup replay sends each owed row at most up to the + /// attempt bound and never past the age bound; a row whose send panics is + /// given up instead of aborting the replay (and re-panicking at every + /// start); and after one replay nothing settled is ever sent again. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn replay_gives_up_on_poison_exhausted_and_stale_rows() { + let root = TempDir::new().unwrap(); + let now = now_unix_ms_u64(); + let row = |id: &str, state, attempts, first_queued_ms, content: &str| DurableOutboundRow { + ts_ms: first_queued_ms, + first_queued_ms, + attempts, + id: id.into(), + inbound_id: "in".into(), + channel: "trap".into(), + state, + message: SendMessage::new(content, "chat-1"), + platform_message_id: None, + error: None, + }; + use DurableOutboundState::{Failed, Queued, Sent}; + let two_days_ago = now - 48 * 3600 * 1000; + for r in [ + row("poison", Queued, 1, now, "PANIC"), + row("owed", Queued, 1, now, "owed"), + row("retry", Failed, 1, now, "retry"), + row("flaky", Failed, 1, now, "FAIL flaky"), + row( + "exhausted", + Failed, + DURABLE_OUTBOX_MAX_ATTEMPTS, + now, + "exhausted", + ), + row("stale", Queued, 1, two_days_ago, "stale"), + row("done", Sent, 1, now, "done"), + ] { + append_durable_outbound_inner(root.path(), &r).unwrap(); + } + let mock = crate::transport::providers::mock::MockChannel::new(); + let mut channels: ChannelMap = HashMap::new(); + channels.insert( + "trap".to_string(), + Arc::new(TrapChannel { + inner: mock.clone(), + }), + ); + let sent = || async { + mock.outbox() + .await + .into_iter() + .map(|m| m.content) + .collect::>() + }; + + replay_durable_outbox(root.path(), &channels).await; + assert_eq!(sent().await, vec!["owed", "retry"], "queue order, bounded"); + let path = durable_outbox_path_in(root.path()); + let owed: Vec<(String, u32)> = read_owed_durable_outbound(&path) + .into_iter() + .map(|r| (r.id, r.attempts)) + .collect(); + assert_eq!( + owed, + vec![("flaky".to_string(), 2)], + "only the retry that failed again" + ); + + // Next start: `flaky` is out of attempts → given up, not re-sent; the + // ledger compacts to nothing. + replay_durable_outbox(root.path(), &channels).await; + replay_durable_outbox(root.path(), &channels).await; + assert_eq!(sent().await, vec!["owed", "retry"], "nothing sent twice"); + assert!(read_owed_durable_outbound(&path).is_empty()); + assert_eq!(std::fs::metadata(&path).unwrap().len(), 0); + } + + /// Issue #213: delivered rows are compacted out once the ledger passes its + /// size bound, while every id still owed (Queued or Failed) keeps its + /// latest row — the at-least-once replay set is unchanged. + #[test] + fn durable_outbox_compaction_keeps_exactly_the_owed_rows() { + let root = TempDir::new().unwrap(); + let row = |id: &str, state| DurableOutboundRow { + ts_ms: 1, + first_queued_ms: 1, + attempts: 1, + id: id.into(), + inbound_id: "in".into(), + channel: "trap".into(), + state, + message: SendMessage::new(format!("body of {id}"), "chat-1"), + platform_message_id: None, + error: None, + }; + let append = |r: DurableOutboundRow| { + append_durable_outbound_with_limit(root.path(), &r, 4096).unwrap(); + }; + append(row("owed-queued", DurableOutboundState::Queued)); + append(row("owed-failed", DurableOutboundState::Queued)); + append(row("owed-failed", DurableOutboundState::Failed)); + for i in 0..200 { + let id = format!("done-{i}"); + append(row(&id, DurableOutboundState::Queued)); + append(row(&id, DurableOutboundState::Sent)); + } + let path = durable_outbox_path_in(root.path()); + let len = std::fs::metadata(&path).unwrap().len(); + assert!(len <= 4096 + 1024, "ledger stays bounded, got {len} bytes"); + + let owed = read_owed_durable_outbound(&path); + let ids: Vec<(&str, DurableOutboundState)> = + owed.iter().map(|r| (r.id.as_str(), r.state)).collect(); + assert_eq!( + ids, + vec![ + ("owed-queued", DurableOutboundState::Queued), + ("owed-failed", DurableOutboundState::Failed), + ] + ); + } + /// (v0.8.5 S4) End-to-end through the *real* security layer: a button /// callback (empty content) yields `EmptyAfterSanitize`, and the gate then /// admits it because a selection is present — the composition the daemon diff --git a/crates/ccteam-im/src/delegation.rs b/crates/ccteam-im/src/delegation.rs index 3c0284238..d62e4bf77 100644 --- a/crates/ccteam-im/src/delegation.rs +++ b/crates/ccteam-im/src/delegation.rs @@ -129,16 +129,35 @@ pub(crate) struct DelegationSummary<'a> { /// The turn's conclusion when the vendor marked one shorter than `answer` /// (the ledger row's `conclusion`): what a bounded excerpt shows first. pub conclusion: Option<&'a str>, - /// The REQUEST this completion answers. The parent needs it to tell which - /// of its outstanding tasks came back (issue #201: three dispatches to one - /// child produced three identical-looking reports). - pub request_id: Option<&'a str>, - /// That request's own label — never a later dispatch's. - pub title: Option<&'a str>, + /// The REQUESTS this completion answers, in dispatch order. The parent + /// needs them to tell which of its outstanding tasks came back (issue + /// #201: three dispatches to one child produced three identical-looking + /// reports). Several when lines were steered into one running turn: that + /// turn has ONE answer, so it is reported once, naming every request it + /// settles (F-GOV-233: sixteen steers, sixteen identical notifications). + pub requests: &'a [AnsweredRequest<'a>], /// How many of this child's requests are still outstanding AFTER this one. pub remaining_queued: usize, } +/// One request a completion settles, as its header names it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct AnsweredRequest<'a> { + pub id: &'a str, + /// That request's own label — never a later dispatch's. + pub title: Option<&'a str>, +} + +impl AnsweredRequest<'_> { + /// `«title» req-…`, or the bare id. + fn label(&self) -> String { + match self.title.map(str::trim).filter(|t| !t.is_empty()) { + Some(title) => format!("«{title}» {}", self.id), + None => self.id.to_string(), + } + } +} + #[derive(Debug, Clone, PartialEq)] pub(crate) enum DelegationOutcome { Done, @@ -230,11 +249,24 @@ impl DelegationSummary<'_> { // several tasks on one child could not tell its reports apart at all // before this (issue #201) — and, told nothing about the queue, it // re-sent the same instruction three times. - if let Some(title) = self.title.map(str::trim).filter(|t| !t.is_empty()) { - first.push_str(&format!(" · «{title}»")); - } - if let Some(request_id) = self.request_id.filter(|id| !id.is_empty()) { - first.push_str(&format!(" · {request_id}")); + let named: Vec<&AnsweredRequest<'_>> = + self.requests.iter().filter(|r| !r.id.is_empty()).collect(); + match named.as_slice() { + [] => {} + [one] => { + if let Some(title) = one.title.map(str::trim).filter(|t| !t.is_empty()) { + first.push_str(&format!(" · «{title}»")); + } + first.push_str(&format!(" · {}", one.id)); + } + many => { + let labels: Vec = many.iter().map(|r| r.label()).collect(); + first.push_str(&format!( + " · answers {} requests: {}", + many.len(), + labels.join(", ") + )); + } } if self.remaining_queued > 0 { first.push_str(&format!(" · {} still queued", self.remaining_queued)); @@ -245,7 +277,11 @@ impl DelegationSummary<'_> { full_answer_marker(omitted, total_chars, self.sid, self.turn_id) }); if answer.text.is_empty() { - first + // Said explicitly: a bare header reads like a truncated message. + match self.outcome { + DelegationOutcome::Done => format!("{first}\n(no reply)"), + DelegationOutcome::Failed { .. } => first, + } } else { format!("{first}\n{}", answer.text) } @@ -255,8 +291,8 @@ impl DelegationSummary<'_> { let mut result = serde_json::Map::new(); result.insert("sid".into(), serde_json::json!(self.sid)); result.insert("turn_id".into(), serde_json::json!(self.turn_id)); - if let Some(request_id) = self.request_id.filter(|id| !id.is_empty()) { - result.insert("request_id".into(), serde_json::json!(request_id)); + if let Some(request) = self.requests.first().filter(|r| !r.id.is_empty()) { + result.insert("request_id".into(), serde_json::json!(request.id)); } result.insert("turn".into(), serde_json::json!(self.turn)); let (kind, error) = match &self.outcome { @@ -784,8 +820,7 @@ mod tests { cost_usd: None, answer: "hello", conclusion: None, - request_id: None, - title: None, + requests: &[], remaining_queued: 0, } .notification_text(NOTIFICATION_ANSWER_MAX_CHARS); @@ -894,8 +929,7 @@ mod tests { cost_usd: Some(0.42), answer: "wave done", conclusion: None, - request_id: None, - title: None, + requests: &[], remaining_queued: 0, }; assert_eq!( @@ -926,8 +960,7 @@ mod tests { cost_usd: None, answer: &long, conclusion: None, - request_id: None, - title: None, + requests: &[], remaining_queued: 0, }; // `final` (the default) and `brief` differ only in the excerpt cap. @@ -978,8 +1011,7 @@ mod tests { cost_usd: None, answer: &answer, conclusion: Some(&receipt), - request_id: None, - title: None, + requests: &[], remaining_queued: 0, }; let text = summary.notification_text(BRIEF_NOTIFICATION_ANSWER_MAX_CHARS); @@ -1109,8 +1141,7 @@ mod tests { cost_usd: None, answer: "ok", conclusion: None, - request_id: None, - title: None, + requests: &[], remaining_queued: 0, } .inline_result(); @@ -1137,8 +1168,7 @@ mod tests { cost_usd: None, answer: "oops", conclusion: None, - request_id: None, - title: None, + requests: &[], remaining_queued: 0, }; assert_eq!( @@ -1159,8 +1189,7 @@ mod tests { cost_usd: None, answer: "ok", conclusion: None, - request_id: None, - title: None, + requests: &[], remaining_queued: 0, } .notification_text(NOTIFICATION_ANSWER_MAX_CHARS); diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 2879a2695..5cbadf07a 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -884,6 +884,11 @@ pub struct Gateway { post_mortem_pending: Vec<(String, String, PathBuf, SessionBody)>, /// Wakes the body watcher early (a new detached sid, a post-mortem). body_watch_notify: Arc, + /// The end of each detached body ccteam is carrying out, keyed by sid: + /// in progress, done (with what it left running) or failed. A std mutex + /// so a kill task without the gateway can hand its result over; the + /// watcher waits for that hand-over before it reports or rebuilds. + body_ends: Arc>>, projects: BTreeMap, /// The chats that speak for the box OWNER, per platform (`"telegram"` → /// `{"339498819"}`, `"lark"` → `{"ou_…"}`). Fed from each global bot's @@ -959,6 +964,11 @@ pub struct Gateway { next_scheduled: u64, /// Durable scheduled rows indexed by globally unique short id. scheduled_items: BTreeMap, + /// Scheduled ids whose fire has begun in this daemon life. An id stays + /// here until its row is completed or failed, so a fire that panicked + /// between delivery and bookkeeping is never sent again on the next + /// pass (at most once per daemon life; a restart re-reads the store). + scheduled_firing: HashSet, /// Wakes the lightweight next-fire timer after create/cancel/GC. scheduled_notify: Arc, event_sink: Option, @@ -971,6 +981,11 @@ pub struct Gateway { /// is wired or on the standalone path. The pump tees through /// [`GatewayEventSink`]; the held sender is the source for new receivers. events_broadcast: tokio::sync::broadcast::Sender, + /// Set once the daemon begins a clean stop, before it aborts the + /// delivery consumer: a pump's last send then meets a closed sink, which + /// is expected and logged quietly instead of as a delivery outage. + /// Shared with every [`GatewayEventSink`] this gateway hands out. + event_delivery_closing: Arc, event_pumps: BTreeMap>, /// Outstanding choice prompts (v0.8.5 D3/D4/D6). Its own lock — held /// separately from the gateway so a long External await (D6, W2) never @@ -1262,13 +1277,24 @@ struct DelegationProgressRecord { reason: Option, } -/// One resolved request and the notification turn (if any) it earns. +/// One resolved request, and whether a notification carries it. struct DelegationDelivery { request: ccteam_harness::DelegationRequest, - /// `None` = `notify:off`, or a parent already blocking on this exact + /// `false` = `notify:off`, or a parent already blocking on this exact /// request inline (it is about to hold the answer; a pushed copy would /// cost it a whole extra turn of context). - notification: Option, + notified: bool, +} + +/// The ONE completion turn a parent receives for a boundary: the turn's answer +/// once, naming every request of that parent it settles. A turn has one +/// answer however many lines were steered into it, so it is told once +/// (F-GOV-233: sixteen steers into one turn came back as sixteen identical +/// notifications). +struct ParentNotification { + parent_sid: String, + request_ids: Vec, + text: String, } struct DelegationDeliveryPlan { @@ -1280,6 +1306,8 @@ struct DelegationDeliveryPlan { /// Every request this boundary resolves — each with its OWN parent, notify /// mode and title. A turn can answer several (injected steers share one). deliveries: Vec, + /// What is actually sent: one turn per parent that is owed one. + notifications: Vec, emitter: DelegationProgressEmitter, } @@ -1515,8 +1543,11 @@ fn stranded_boundary_signals( // because it was killed (issue #197 E). The request // bound to it is already terminal, so this is the second // of two independent guards — the row itself says no. + // A row with no text still ends its turn when it carries the + // turn's status: the closing row of a turn that said nothing + // (F-GOV-233) or whose text went out mid-turn (#209). t.outcome.as_deref() != Some(INTERRUPTED_OUTCOME) - && !t.assistant.is_empty() + && (!t.assistant.is_empty() || t.status.is_some()) && !store.notified_turns.iter().any(|n| n == &t.turn_id) }) .cloned() @@ -2089,6 +2120,11 @@ pub struct SessionActivity { pub status: ActivityStatus, /// Adapter item id — lets the web dedup/merge a start↔complete pair. pub item_id: String, + /// The step's id in `chat//steps.jsonl` — what the web inspector + /// fetches its full input / output / reasoning by. Unique across the + /// session's life (item ids restart every turn); recorded once the step + /// finishes, so a running step's id does not resolve yet. + pub step_id: String, } /// The category of one [`SessionActivity`] step. @@ -2279,6 +2315,8 @@ struct GatewayEventSink { mpsc: tokio::sync::mpsc::UnboundedSender, /// Fan-out for per-session SSE subscribers. broadcast: tokio::sync::broadcast::Sender, + /// The gateway's [`Gateway::close_event_delivery`] flag. + closing: Arc, } impl GatewayEventSink { @@ -2290,7 +2328,7 @@ impl GatewayEventSink { // Broadcast first (cheap clone); a `SendError` here just means no SSE // subscriber is attached, which is normal. let _ = self.broadcast.send(event.clone()); - self.mpsc.send(event).is_ok() + self.deliver(event) } /// Send a delivery-only event to the daemon egress without publishing it @@ -2298,7 +2336,7 @@ impl GatewayEventSink { /// original sid-keyed Answer already feeds SSE, while the duplicate is a /// phone notification only. fn send_delivery_only(&self, event: GatewayEvent) -> bool { - self.mpsc.send(event).is_ok() + self.deliver(event) } /// v0.10.1 — publish to the SSE fan-out (web console + the inline-wait @@ -2307,8 +2345,45 @@ impl GatewayEventSink { /// pump-liveness signal as [`Self::send`] (the mpsc being closed is still /// "the daemon exited"), so a caller's `if !send { break }` is unchanged. fn send_broadcast_only(&self, event: GatewayEvent) -> bool { + let live = !self.mpsc.is_closed(); + if !live { + self.log_consumer_gone(&event); + } let _ = self.broadcast.send(event); - !self.mpsc.is_closed() + live + } + + /// The mpsc leg shared by every send shape. A closed mpsc is the pump's + /// stop signal, and every pump stops on it, so it is logged HERE (once + /// per stopping pump, every send site covered): outside daemon shutdown + /// it means no session's reply reaches IM/web/turns any more (issue #210: + /// the consumer died during ENOSPC and every pump stopped silently). + fn deliver(&self, event: GatewayEvent) -> bool { + match self.mpsc.send(event) { + Ok(()) => true, + Err(tokio::sync::mpsc::error::SendError(event)) => { + self.log_consumer_gone(&event); + false + } + } + } + + fn log_consumer_gone(&self, event: &GatewayEvent) { + let sid = event.sid.as_deref().unwrap_or("-"); + if self.closing.load(std::sync::atomic::Ordering::SeqCst) { + tracing::debug!( + sid, + event_id = %event.id, + "ccteam-im: delivery closed for shutdown; stopping this session's event pump" + ); + return; + } + tracing::error!( + sid, + event_id = %event.id, + channel = %event.channel, + "ccteam-im: gateway event consumer is gone; stopping this session's event pump" + ); } } @@ -2335,6 +2410,11 @@ pub struct HitlPromptContext { /// `chat_permission_prompt_outstanding` operator-visibility line. /// `None` when the gateway was never given project paths (unit tests). pub progress_path: Option, + /// The owning project's slug. Stamped on the prompt event so the + /// project-filtered fleet stream (`/api/v1/agents/events`) shows it to + /// everyone who can see the project — an unattributed frame reaches the + /// operator only, so a tenant never saw its own approval prompts there. + pub slug: String, } /// A live session's delivery route ([`Gateway::reply_target_for`]): the @@ -2588,6 +2668,24 @@ pub struct DetachedBody { pub since: chrono::DateTime, /// `daemon_restart` today (found alive at startup / first touch). pub reason: &'static str, + /// Set while ccteam is ending this body's process, and why: `stopping` + /// (a user stop — the watcher leaves it to the stop), `interrupted` (a + /// user `/interrupt`) or `ended_idle` / `ended_stalled` (a queued turn was waiting on a + /// body gone idle or stalled). The watcher records the exit under it. + pub ending: Option<&'static str>, +} + +/// Where ccteam's end of one detached body stands (see `Gateway::body_ends`). +#[derive(Debug, Clone)] +enum BodyEnd { + /// The kill is running; nothing about this body may be reported yet. + /// `token` names this kill, so only it writes the outcome; `since` lets a + /// hand-over that never comes (its task gone) age into `Failed`. + InProgress { token: u64, since: Instant }, + /// The body is gone; what it left running, for the report. + Ended(Vec), + /// The kill failed; the mark must be lifted and the body watched again. + Failed, } impl DetachedBody { @@ -3578,6 +3676,7 @@ impl Gateway { detached: BTreeMap::new(), post_mortem_pending: Vec::new(), body_watch_notify: Arc::new(tokio::sync::Notify::new()), + body_ends: Arc::new(std::sync::Mutex::new(HashMap::new())), projects, operator_chats: BTreeMap::new(), button_channels: BTreeSet::new(), @@ -3596,9 +3695,11 @@ impl Gateway { next_session: 0, next_scheduled: 0, scheduled_items: BTreeMap::new(), + scheduled_firing: HashSet::new(), scheduled_notify: Arc::new(tokio::sync::Notify::new()), event_sink: None, events_broadcast, + event_delivery_closing: Arc::new(std::sync::atomic::AtomicBool::new(false)), event_pumps: BTreeMap::new(), pending: Arc::new(tokio::sync::Mutex::new( crate::pending::PendingInteractions::new(), @@ -3666,6 +3767,7 @@ impl Gateway { self.event_sink = Some(GatewayEventSink { mpsc: tx, broadcast: self.events_broadcast.clone(), + closing: Arc::clone(&self.event_delivery_closing), }); let ids = self.sessions.keys().cloned().collect::>(); for id in ids { @@ -3673,6 +3775,14 @@ impl Gateway { } } + /// The flag the daemon raises when it begins a clean stop, before it + /// aborts the delivery consumer: from then on a closed delivery sink is + /// shutdown, not an outage, so pumps that stop on it log at debug. Handed + /// out so the daemon can raise it without taking the gateway lock. + pub fn event_delivery_closing(&self) -> Arc { + Arc::clone(&self.event_delivery_closing) + } + /// v0.9.0 W2 (F2) — wire the sender the detached event pumps use to signal a /// completed child turn to the delegation notifier task. MUST be called /// BEFORE [`set_event_sink`](Self::set_event_sink) (which spawns the pumps) @@ -4295,6 +4405,7 @@ impl Gateway { body, since: chrono::Utc::now(), reason, + ending: None, }; tracing::info!( session = %sid, @@ -4443,6 +4554,11 @@ impl Gateway { let Some(detached) = self.detached.remove(sid) else { return Ok(false); }; + // What an earlier end of this body left running belongs to the + // incarnation the stop ends; never report it for a later one. + if let Ok(mut map) = self.body_ends.lock() { + map.remove(sid); + } self.principals.forget(sid); let body = detached.body.clone(); let sid_owned = sid.to_string(); @@ -4469,6 +4585,248 @@ impl Gateway { Ok(true) } + /// An EXPLICIT user `/interrupt` of a detached body: end the PROCESS, keep + /// the SESSION. Returns `Ok(true)` when `sid` was detached (its process is + /// now being ended); `Ok(false)` when `sid` was not detached. + /// + /// The `&mut` door (IM commands, buttons) cannot drop the gateway lock, so + /// the kill runs on its own task and wakes the watcher when done; nothing + /// waits on it under the lock. The REST door awaits it off the lock + /// instead ([`Self::end_detached_body_shared`]). + pub(crate) fn end_detached_body(&mut self, sid: &str) -> bool { + self.settle_body_ends(); + let Some(detached) = self.detached.get_mut(sid) else { + return false; + }; + if detached.ending.is_some() { + return true; + } + detached.ending = Some("interrupted"); + let body = detached.body.clone(); + let sid_owned = sid.to_string(); + let notify = Arc::clone(&self.body_watch_notify); + let ends = Arc::clone(&self.body_ends); + let token = Self::start_body_end(&ends, sid); + tokio::spawn(async move { + // A failure is handed over as `Failed`: the next reader lifts the + // mark, or every later /interrupt would be a no-op and a natural + // exit would be recorded as interrupted. + if let Err(error) = + Self::end_body_process(body, sid_owned.clone(), "interrupted", ends, token).await + { + tracing::warn!(session = %sid_owned, %error, "ccteam-im: could not end a detached body on interrupt"); + } + notify.notify_one(); + }); + tracing::info!(session = %sid, "ccteam-im: ending a detached body on user interrupt; it resumes by sid"); + true + } + + async fn body_end_in_progress(gateway: &Arc>, sid: &str) -> bool { + let g = gateway.lock().await; + g.body_ends + .lock() + .map(|map| matches!(map.get(sid), Some(BodyEnd::InProgress { .. }))) + .unwrap_or(false) + } + + /// How long a kill may stay `InProgress` before its hand-over is treated + /// as lost (a kill takes at most the SIGTERM grace plus the SIGKILL wait). + const BODY_END_STALE: std::time::Duration = std::time::Duration::from_secs(30); + + /// Mark a kill of `sid`'s body as started; returns its token. + fn start_body_end(ends: &Arc>>, sid: &str) -> u64 { + static NEXT: AtomicU64 = AtomicU64::new(1); + let token = NEXT.fetch_add(1, Ordering::Relaxed); + Self::set_body_end( + ends, + sid, + BodyEnd::InProgress { + token, + since: Instant::now(), + }, + ); + token + } + + fn set_body_end( + ends: &Arc>>, + sid: &str, + end: BodyEnd, + ) { + if let Ok(mut map) = ends.lock() { + map.insert(sid.to_string(), end); + } + } + + /// Lift the mark of every body whose end FAILED (handed over by a kill + /// task), so it is watched — and interruptible — again. Called by every + /// reader of the mark before it trusts it. + fn settle_body_ends(&mut self) { + let failed: Vec = match self.body_ends.lock() { + Ok(mut map) => { + let failed: Vec = map + .iter() + .filter(|(_, end)| match end { + BodyEnd::Failed => true, + // A hand-over that never came is a failed end. + BodyEnd::InProgress { since, .. } => { + since.elapsed() >= Self::BODY_END_STALE + } + BodyEnd::Ended(_) => false, + }) + .map(|(sid, _)| sid.clone()) + .collect(); + for sid in &failed { + map.remove(sid); + } + failed + } + Err(_) => return, + }; + for sid in failed { + if let Some(detached) = self.detached.get_mut(&sid) { + if detached.ending != Some("stopping") { + detached.ending = None; + } + } + } + } + + /// How long a body is given to exit on SIGTERM before its group is + /// SIGKILLed (an explicit stop). + const TERMINATE_GRACE: std::time::Duration = std::time::Duration::from_secs(5); + + /// End one detached body off the lock: a stop ends its whole group + /// ([`session_body::terminate`]); anything that keeps the session ends + /// only the process ([`session_body::kill_body`]). The caller has set the + /// sid's entry in `ends` to `InProgress`; this hands the outcome over in + /// the same entry — `Ended` with what it left running, or `Failed`. + async fn end_body_process( + body: SessionBody, + sid: String, + why: &'static str, + ends: Arc>>, + token: u64, + ) -> Result<()> { + let outcome = Self::run_body_end(body, &sid, why).await; + // Only this kill's own entry: a stale or superseded kill must not + // leave a result for a later incarnation of the sid. + if let Ok(mut map) = ends.lock() { + if matches!(map.get(&sid), Some(BodyEnd::InProgress { token: t, .. }) if *t == token) { + map.insert( + sid.clone(), + match &outcome { + Ok(left) => BodyEnd::Ended(left.clone()), + Err(_) => BodyEnd::Failed, + }, + ); + } + } + outcome.map(|_| ()) + } + + async fn run_body_end( + body: SessionBody, + sid: &str, + why: &'static str, + ) -> Result> { + let pid = body.pid; + #[cfg(test)] + if tests::FAIL_BODY_END_PIDS + .lock() + .unwrap_or_else(|p| p.into_inner()) + .contains(&pid) + { + return Err(anyhow!("test: ending body pid {pid} failed")); + } + #[cfg(test)] + if tests::SLOW_BODY_END_PIDS + .lock() + .unwrap_or_else(|p| p.into_inner()) + .contains(&pid) + { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + } + let sid_for_task = sid.to_string(); + let left = tokio::task::spawn_blocking(move || { + if why == "stopping" { + session_body::terminate(&body, &sid_for_task, Self::TERMINATE_GRACE) + .map(|gone| gone.then(Vec::new)) + } else { + session_body::kill_body(&body, &sid_for_task) + } + }) + .await + .context("join body end")??; + left.ok_or_else(|| anyhow!("{sid}'s process (pid {pid}) is still alive after SIGKILL")) + } + + /// End a detached body's process OFF the gateway lock, marking the entry + /// with `why` first (see [`DetachedBody::ending`]). `Ok(false)` when `sid` + /// is not detached, or when another end already owns it; `Err` when the + /// process outlived the kill (the mark is cleared and the body stays + /// watched). + /// + /// An existing mark is never overwritten, except by a stop: the watcher + /// decides off the lock, so it can reach here after a user's `/stop` (or + /// `/interrupt`) already marked the body, and relabelling that end would + /// let the watcher rebuild a session the user just stopped. + pub async fn end_detached_body_shared( + gateway: &Arc>, + sid: &str, + why: &'static str, + ) -> Result { + let (body, ends, token) = { + let mut g = gateway.lock().await; + g.settle_body_ends(); + let ends = Arc::clone(&g.body_ends); + let Some(detached) = g.detached.get_mut(sid) else { + return Ok(false); + }; + match detached.ending { + None => {} + Some(current) if why == "stopping" && current != "stopping" => {} + Some(_) => return Ok(false), + } + detached.ending = Some(why); + let body = detached.body.clone(); + let token = Self::start_body_end(&ends, sid); + (body, ends, token) + }; + // The kill and its hand-over run on their own task: a caller that + // goes away mid-kill (a web client disconnecting drops this future) + // must not leave the hand-over unwritten and the sid stuck. + let ended = tokio::spawn(Self::end_body_process( + body, + sid.to_string(), + why, + ends, + token, + )) + .await + .unwrap_or_else(|join| Err(anyhow!("body end task failed: {join}"))); + let mut g = gateway.lock().await; + if ended.is_err() { + g.settle_body_ends(); + } + g.body_watch_notify.notify_one(); + ended.map(|()| true) + } + + /// `/interrupt` for a caller holding the shared handle (the REST route): + /// a detached body is ended off the lock and its error is reported as it + /// is; a live session is interrupted as [`Self::interrupt_session`] does. + pub async fn interrupt_session_shared( + gateway: &Arc>, + sid: &str, + ) -> Result<()> { + if Self::end_detached_body_shared(gateway, sid, "interrupted").await? { + return Ok(()); + } + gateway.lock().await.interrupt_session(sid).await + } + /// Queue a turn behind a detached body (the session is not driveable /// until its body exits): the text goes into the session's file-backed /// FIFO, which the watcher drains the moment the rebuilt session is live. @@ -4500,9 +4858,12 @@ impl Gateway { let queued = crate::pending_turns::pending_turn_count(&detached.cwd, sid); let since = detached.since.to_rfc3339(); let receipt = format!( - "⏳ {sid} is still finishing a turn that started before ccteam restarted \ - (body pid {}, unobserved since {}). Your message is queued (#{queued}) and will be \ - delivered as soon as that turn ends; `/stop` ends it now.", + "⏳ {sid}'s process from before ccteam restarted is still running (body pid {}, \ + unobserved since {}): a turn it was finishing, or background tasks it started. Your \ + message is queued (#{queued}). Once that process has gone quiet ccteam ends it \ + and resumes {sid} on the same context with your message; background processes \ + it started keep running and are listed then. `/interrupt {sid}` does that now; \ + `/stop {sid}` ends the session and that process with its background processes.", detached.body.pid, since ); Ok((receipt, format!("queued-behind-body:{sid}:{queued}"))) @@ -4535,41 +4896,213 @@ impl Gateway { pub async fn body_watch_tick(gateway: &Arc>) { let (detached, post_mortems) = { let mut g = gateway.lock().await; + g.settle_body_ends(); ( g.detached.values().cloned().collect::>(), std::mem::take(&mut g.post_mortem_pending), ) }; + // Each body under its own containment: the batch was taken off the + // gateway, so one panicking post-mortem must not drop the rest of it. for (sid, slug, cwd, body) in post_mortems { - Self::recover_and_report_shared(gateway, &sid, &slug, &cwd, body.pid, "exited").await; + crate::supervise::contain_panic( + "body watcher", + &sid, + Self::recover_and_report_shared( + gateway, + &sid, + &slug, + &cwd, + body.pid, + "exited", + &[], + ), + ) + .await; } for d in detached { - // Off the lock: a pure OS probe. - if session_body::body_is_alive(&d.body, &d.sid) { - continue; - } - Self::on_detached_body_exited_shared(gateway, d).await; + let sid = d.sid.clone(); + crate::supervise::contain_panic("body watcher", &sid, async { + // A stop owns its body's end. + if d.ending == Some("stopping") { + return; + } + // A kill ccteam is carrying out has not handed over yet: what + // it left running must be in the report, so wait for it. + if Self::body_end_in_progress(gateway, &d.sid).await { + return; + } + // Off the lock: a pure OS probe. + if session_body::body_is_alive(&d.body, &d.sid) { + // An interrupt still being carried out does not suspend the + // bound: if that kill failed, the bound is what still ends it. + let Some(bound) = Self::detached_wait_bound(gateway, &d).await else { + return; + }; + tracing::info!(session = %d.sid, pid = d.body.pid, bound, "ccteam-im: ending a detached body a queued turn is waiting on; resuming by sid"); + match Self::end_detached_body_shared(gateway, &d.sid, bound).await { + Ok(true) => {} + Ok(false) => return, + Err(error) => { + tracing::warn!(session = %d.sid, pid = d.body.pid, %error, "ccteam-im: could not end a detached body; still waiting for it"); + return; + } + } + } + let reason = d.ending.unwrap_or("exited"); + let reason = { + // The mark may have been set after the snapshot. + let g = gateway.lock().await; + g.detached + .get(&d.sid) + .and_then(|current| current.ending) + .unwrap_or(reason) + }; + if reason == "stopping" { + return; + } + Self::on_detached_body_exited_shared(gateway, d, reason).await; + }) + .await; } } + /// How long a detached body's record must stay silent, with its last turn + /// ended, before a queued turn stops waiting for it. + const DETACHED_IDLE_QUIET: std::time::Duration = std::time::Duration::from_secs(30); + + /// How long a detached body's record may stay silent with a turn still + /// open before a queued turn stops waiting for it. Long on purpose: a + /// foreground tool call writes nothing until it returns (claude's Bash tool + /// caps a call at 10 minutes), and a subagent's writes are counted too. + const DETACHED_STALL: std::time::Duration = std::time::Duration::from_secs(30 * 60); + + /// The bound on waiting behind a detached body. `Some(reason)` when the + /// watcher should end the body's process now so the queued turns reach + /// the same sid; `None` to keep waiting. + /// + /// Policy (docs-local issue #211): only a turn queued for the sid ends the + /// wait — a body nobody is waiting on is left alone (its background work, + /// say a job scheduled for later, runs to its end). Then: + /// - `ended_idle`: the vendor record says the last turn ended and it has been + /// silent for [`Self::DETACHED_IDLE_QUIET`]. The process is alive only for + /// background tasks; it can never take the queued turn itself. + /// - `ended_stalled`: a turn is still open but the record has been silent for + /// [`Self::DETACHED_STALL`] (or, with no readable record, the body has + /// been detached that long). + /// + /// A body that is mid-turn and still writing keeps today's wait: it will + /// finish and exit. Ending the process is not ending the session (AGENTS.md + /// §三): sid, ledger and focus routes stay, and the queued turns are + /// delivered on the same context. + async fn detached_wait_bound( + gateway: &Arc>, + d: &DetachedBody, + ) -> Option<&'static str> { + if crate::pending_turns::pending_turn_count(&d.cwd, &d.sid) == 0 { + return None; + } + let meta = ccteam_harness::read_session_meta(&d.cwd, &d.sid).ok()?; + let adapter = { + let g = gateway.lock().await; + (g.adapter_factory)(meta.vendor, meta.protocol) + }; + let activity = adapter + .detached_activity(&UnobservedTurnCtx { + sid: d.sid.clone(), + slug: d.slug.clone(), + cwd: d.cwd.clone(), + vendor_uuid: meta.vendor_uuid.clone(), + observed_until: chrono::Utc::now(), + last_observed_assistant: None, + }) + .await; + let quiet_for = match activity { + Some(a) if a.turn_ended && a.quiet_for >= Self::DETACHED_IDLE_QUIET => { + return Some("ended_idle") + } + Some(a) => a.quiet_for, + None => (chrono::Utc::now() - d.since).to_std().unwrap_or_default(), + }; + (quiet_for >= Self::DETACHED_STALL).then_some("ended_stalled") + } + /// A detached body exited on its own: forget it, recover + report what it /// said, rebuild the session by sid, drain the turns that queued behind it. async fn on_detached_body_exited_shared( gateway: &Arc>, d: DetachedBody, + reason: &str, ) { - { + let mut orphans = { let mut g = gateway.lock().await; + // A user stop that marked the body after the watcher read its + // mark owns the end from here. + if g.detached.get(&d.sid).and_then(|current| current.ending) == Some("stopping") { + return; + } + // Take the kill's hand-over in the same breath as the entry; a + // kill that has not handed over yet is waited for (next tick). + let end = match g.body_ends.lock() { + Ok(mut map) => match map.get(&d.sid) { + Some(BodyEnd::InProgress { .. }) => return, + _ => map.remove(&d.sid), + }, + Err(_) => None, + }; if g.detached.remove(&d.sid).is_none() { // Stopped by a user between the snapshot and now. return; } session_body::clear(&d.cwd, &d.sid); - } + match end { + Some(BodyEnd::Ended(left)) => left, + _ => Vec::new(), + } + }; tracing::info!(session = %d.sid, pid = d.body.pid, "ccteam-im: detached body exited; recovering + rebuilding by sid"); - Self::recover_and_report_shared(gateway, &d.sid, &d.slug, &d.cwd, d.body.pid, "exited") - .await; - Self::rebuild_one_shared(gateway, &d.sid).await; + // Re-verify by start time: a pid reused since the kill is not ours. + orphans.retain(session_body::Orphan::still_running); + Self::recover_and_report_shared( + gateway, &d.sid, &d.slug, &d.cwd, d.body.pid, reason, &orphans, + ) + .await; + if !orphans.is_empty() { + // The user's work outlived the process ccteam ended: say where + // it is, so a port it holds is not a mystery to anyone. + let g = gateway.lock().await; + if let Ok(meta) = ccteam_harness::read_session_meta(&d.cwd, &d.sid) { + let reply_to = g.owner_reply_target_for_meta(&d.slug, &meta); + let list = orphans + .iter() + .map(|o| format!("pid {} `{}`", o.pid, o.cmd)) + .collect::>() + .join(", "); + let sid = &d.sid; + g.emit_user_signal(GatewayEvent { + interim: false, + id: format!( + "gateway-orphans-{sid}-{}", + chrono::Utc::now().timestamp_millis() + ), + channel: reply_to.channel.clone(), + chat_id: reply_to.chat_id.clone(), + thread_ts: reply_to.thread.clone(), + content: format!( + "ℹ️ {sid}'s old process was ended; what it started is still running, \ + unattended: {list}. ccteam does not stop these — the resumed {sid} \ + does not know about them." + ), + kind: GatewayEventKind::Answer, + attachments: Vec::new(), + options: Vec::new(), + status: None, + sid: Some(sid.clone()), + slug: Some(d.slug.clone()), + }); + } + } + let failure = Self::rebuild_one_shared(gateway, &d.sid).await; let mut g = gateway.lock().await; if g.sessions.contains_key(&d.sid) { g.emit_session_lifecycle(&d.sid, &d.slug, "resumed", "body_exited"); @@ -4577,9 +5110,137 @@ impl Gateway { tracing::warn!(%error, "ccteam-im: persist routing after body exit failed"); } g.drain_and_dispatch_pending_turns(&d.sid).await; + } else if let Some(error) = failure { + // The process is gone but the session did not come back: whoever + // was promised it would must hear why. A human's queued turns stay + // in the FIFO and go out, in order, ahead of the next message that + // reaches this sid (see `submit_resolved`). A delegated task is + // withdrawn instead and its parent told so below: the parent's own + // retry is then the only run of it, never a second, late one. + let withdrawn = crate::pending_turns::withdraw_delegated_turns(&d.cwd, &d.sid) + .unwrap_or_else(|error| { + tracing::warn!(session = %d.sid, %error, "pending_turns: could not withdraw delegated tasks"); + Vec::new() + }); + let queued = crate::pending_turns::pending_turn_count(&d.cwd, &d.sid); + let Ok(meta) = ccteam_harness::read_session_meta(&d.cwd, &d.sid) else { + return; + }; + let reply_to = g.owner_reply_target_for_meta(&d.slug, &meta); + let sid = &d.sid; + g.emit_user_signal(GatewayEvent { + interim: false, + id: format!( + "gateway-resume-failed-{sid}-{}", + chrono::Utc::now().timestamp_millis() + ), + channel: reply_to.channel.clone(), + chat_id: reply_to.chat_id.clone(), + thread_ts: reply_to.thread.clone(), + content: format!( + "⚠️ {sid}'s old process has ended, but {sid} could not be resumed: {error}. \ + {queued} queued message(s) are kept and go out ahead of the next message \ + to {sid} once a resume succeeds; if it keeps failing, `/new` starts a \ + fresh session." + ), + kind: GatewayEventKind::Answer, + attachments: Vec::new(), + options: Vec::new(), + status: None, + sid: Some(sid.clone()), + slug: Some(d.slug.clone()), + }); + // A delegation parent waiting on a task queued here would wait + // forever: answer it through the normal completion path, as an + // honest failure. Its task line was withdrawn above. + let answer = ( + withdrawn, + meta.vendor, + meta.host.clone(), + meta.turn_count, + error, + ); + drop(g); + let (withdrawn, vendor, host, turn, error) = answer; + Self::answer_withdrawn_requests(gateway, &d.sid, withdrawn, vendor, host, turn, &error) + .await; } } + /// Answer the parents of delegated tasks that were withdrawn because + /// `sid` could not be resumed: a terminal failure each parent receives + /// through the ordinary completion path — bound to a turn of its own, so + /// the delivery is matched by identity, durable, deduplicated across a + /// restart, and submitted to the parent the way any completion is (a live + /// parent is steered, a released one gets the pending FIFO and a resume). + async fn answer_withdrawn_requests( + gateway: &Arc>, + sid: &str, + request_ids: Vec, + vendor: AgentVendor, + host: String, + turn: u64, + error: &str, + ) { + if request_ids.is_empty() { + return; + } + let exec_turn_id = format!( + "{sid}-resume-failed-{}", + chrono::Utc::now().timestamp_millis() + ); + { + let claim = Self::claim_delegation_store(gateway, sid).await; + let bound = { + let mut g = gateway.lock().await; + let Some(mirror) = g.delegations.get_mut(sid) else { + return; + }; + let mut bound = 0; + for request_id in &request_ids { + if let Some(request) = mirror.store.get_mut(request_id) { + if !request.state.is_terminal() { + request.turn_id = Some(exec_turn_id.clone()); + request.state = ccteam_harness::RequestState::Submitted; + bound += 1; + } + } + } + bound + }; + if bound == 0 { + return; + } + if let Err(error) = Self::persist_delegation_store(gateway, &claim).await { + tracing::warn!(session = %sid, %error, "ccteam-im: withdrawn tasks' binding is not durable; a restart re-reads them as outstanding"); + } + } + Self::deliver_delegation_signal_shared( + Arc::clone(gateway), + crate::delegation::DelegationSignal { + child_sid: sid.to_string(), + turn_id: exec_turn_id.clone(), + exec_turn_id: Some(exec_turn_id.clone()), + tail: format!( + "{sid} could not be resumed after its old process ended: {error}. \ + The task was withdrawn and did not run; send it again to retry." + ), + vendor, + host, + boundary: true, + continuation: ccteam_harness::TurnContinuation::Settled, + vendor_error: true, + interim_notes: 0, + covered_turns: vec![exec_turn_id], + context_pct: None, + turn, + error_kind: Some("resume_failed".to_string()), + conclusion: None, + }, + ) + .await; + } + /// Recover what `sid`'s body said while unobserved (vendor record via the /// adapter), then report it exactly where the live path would have: the /// turns mirror, the session's reply target, the delegation parent. When @@ -4593,7 +5254,18 @@ impl Gateway { cwd: &Path, pid: u32, reason: &str, + orphans: &[session_body::Orphan], ) -> bool { + let exited_event = |recovered: bool| { + let mut event = + ccteam_harness::execution::progress_bridge::build_session_body_exited_event( + sid, slug, reason, pid, recovered, + ); + if !orphans.is_empty() { + event["orphans"] = serde_json::json!(orphans); + } + event + }; // IO off the lock: the mirror tells us where observation stopped. let turns = ccteam_harness::execution::turns_mirror::read_all_turns(cwd, sid).unwrap_or_default(); @@ -4644,12 +5316,7 @@ impl Gateway { // Nothing ran unobserved (an idle body) — nothing to report, and // the lifecycle row still closes the detached window. let g = gateway.lock().await; - g.append_progress_event( - slug, - ccteam_harness::execution::progress_bridge::build_session_body_exited_event( - sid, slug, reason, pid, false, - ), - ); + g.append_progress_event(slug, exited_event(false)); return false; } let now = chrono::Utc::now(); @@ -4676,18 +5343,31 @@ impl Gateway { continues_exec_turn: None, }, format!( - "↩️ {sid} finished this turn while ccteam was restarting; its answer was \ - recovered from the vendor's own record:\n\n{}", + "↩️ {sid} {}; its answer was recovered from the vendor's own record:\n\n{}", + unobserved_end_phrase(reason), turn.assistant ), false, ), None => { - let note = format!( - "⚠️ {sid}'s turn ended while ccteam was restarting and its answer could not \ - be recovered (the vendor keeps no record ccteam may read). The session's \ - own context is intact — send a follow-up and it will report what it did." - ); + let note = match reason { + "interrupted" | "ended_idle" | "ended_stalled" => format!( + "⚠️ {sid}'s process from before ccteam restarted was ended ({}) before \ + its answer could be recovered. The session's own context is intact — \ + it resumes now; ask it what it did.", + if reason == "interrupted" { + "by /interrupt" + } else { + "a message was waiting and it had gone quiet" + } + ), + _ => format!( + "⚠️ {sid}'s turn ended while ccteam was restarting and its answer could \ + not be recovered (the vendor keeps no record ccteam may read). The \ + session's own context is intact — send a follow-up and it will report \ + what it did." + ), + }; ( note.clone(), ccteam_harness::execution::turns_mirror::TurnRecord { @@ -4745,16 +5425,7 @@ impl Gateway { if let Err(error) = g.persist_session_meta(cwd, &meta) { tracing::warn!(session = %sid, %error, "ccteam-im: failed to persist meta after recovery"); } - g.append_progress_event( - slug, - ccteam_harness::execution::progress_bridge::build_session_body_exited_event( - sid, - slug, - reason, - pid, - recovered.is_some(), - ), - ); + g.append_progress_event(slug, exited_event(recovered.is_some())); g.emit_user_signal(GatewayEvent { interim: false, id: format!("gateway-recovered-{turn_id}"), @@ -4809,40 +5480,41 @@ impl Gateway { /// A typed detached refusal is an info (the watcher keeps it); anything /// else warns. Boot no longer calls this: see /// [`Self::reconcile_restored_sessions`] for why a restart spawns nothing. - async fn rebuild_one_shared(gateway: &Arc>, sid: &str) { + async fn rebuild_one_shared( + gateway: &Arc>, + sid: &str, + ) -> Option { let plan_and_reply = { let mut g = gateway.lock().await; if g.sessions.contains_key(sid) { - None + return None; } else { match g.find_meta_for_sid(sid) { Ok((slug, cwd, meta)) => { let reply_to = g.owner_reply_target_for_meta(&slug, &meta); match g.plan_session_rebuild(&slug, cwd, &meta, &reply_to) { - Ok(plan) => Some((plan, reply_to)), + Ok(plan) => (plan, reply_to), Err(err) if is_body_detached_error(&err) => { tracing::info!( session = %sid, "ccteam-im: restore deferred; the session's body from before the restart is still running (watched, not duplicated)" ); - None + return None; } Err(err) => { tracing::warn!(session = %sid, error = %err, "ccteam-im: restore skipped; project host binding changed"); - None + return Some(format!("{err:#}")); } } } Err(_) => { tracing::warn!(session = %sid, "ccteam-im: restore skipped; no meta.json found"); - None + return Some("its meta.json is missing".to_string()); } } } }; - let Some((plan, reply_to)) = plan_and_reply else { - return; - }; + let (plan, reply_to) = plan_and_reply; // Spawn OUTSIDE the lock — the slow part. match Self::spawn_for_plan(&plan).await { Ok(thread) => { @@ -4853,7 +5525,9 @@ impl Gateway { .await { tracing::warn!(session = %sid, error = %err, "ccteam-im: stale restored session discarded"); + return Some(format!("{err:#}")); } + None } Err(err) => { let sid_owned = plan.sid.clone(); @@ -4863,6 +5537,7 @@ impl Gateway { error = %err, "ccteam-im: restored gateway session rebuild failed; left for on-demand resume" ); + Some(format!("{err:#}")) } } } @@ -5629,15 +6304,20 @@ impl Gateway { }; // Own-only ACL — identical to /stop (a chat can interrupt only // its own / the shared web-pool session). - let accessible = self - .sessions - .get(&sid) - .map(|s| self.chat_can_access(chat, s)) - .unwrap_or(false); + let detached = self.is_session_detached(&sid); + let accessible = match self.sessions.get(&sid) { + Some(s) => self.chat_can_access(chat, s), + None => detached && self.chat_can_access_sid(chat, &sid), + }; if !accessible { return Ok(Some(format!("unknown session for this chat: {sid}"))); } self.interrupt_session(&sid).await?; + if detached { + return Ok(Some(format!( + "正在结束 {sid} 重启前遗留的进程(它启动的后台进程继续运行,结束后会列出);{sid} 随后按原上下文恢复,排队的消息接着送达" + ))); + } Ok(Some(format!( "已中断 session {sid} 当前 turn(会话保留,可继续 /model 等)" ))) @@ -7706,27 +8386,29 @@ impl Gateway { // state machine below rebuilds it whenever a stream ends. let attachment = session.adapter.event_attachment(); let mut events = session.adapter.events(&session.thread); - let mut seq: u64 = project_dir - .as_ref() - .and_then(|dir| { - ccteam_harness::execution::turns_mirror::read_all_turns(dir, &session_id).ok() - }) - .map(|turns| last_answer_seq(&session_id, &turns)) - .unwrap_or(0); - let mut vendor_turn: u64 = project_dir + let digest = project_dir .as_ref() - .and_then(|dir| { - ccteam_harness::execution::turns_mirror::read_all_turns(dir, &session_id).ok() - }) - .map(|turns| { - turns - .iter() - .filter(|turn| !turn.user.is_empty()) - .count() - .saturating_sub(1) as u64 - }) - .unwrap_or(0); // boundary re-read accounts for a row already appended + .and_then(|dir| ccteam_harness::execution::turns_mirror::digest(dir, &session_id)) + .unwrap_or_default(); + let mut seq: u64 = digest.max_answer_seq; + // boundary re-read accounts for a row already appended + let mut vendor_turn: u64 = digest.user_turns.saturating_sub(1); let mut epoch: u64 = 0; // status epoch → one per turn + // Step ids must stay unique across pump lives (`epoch` and the + // adapters' item ids both restart), since `steps.jsonl` outlives + // them: one nonce per pump. + let step_nonce = format!( + "{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + // The current turn's step inputs by item id (a completion may not + // repeat its call's input) and the steps already recorded. + let mut step_turn: u64 = 0; + let mut step_inputs: HashMap = HashMap::new(); + let mut steps_recorded: HashMap = HashMap::new(); let mut fold = crate::progress::ProgressFold::new(); let mut dirty = false; let mut last_emit: Option = None; @@ -8588,15 +9270,12 @@ impl Gateway { let durable = project_dir .as_ref() .and_then(|dir| { - ccteam_harness::execution::turns_mirror::read_all_turns( + ccteam_harness::execution::turns_mirror::digest( dir, &session_id, ) - .ok() - }) - .map(|turns| { - turns.iter().filter(|turn| turn.status.is_some()).count() as u64 }) + .map(|digest| digest.status_turns) .unwrap_or(completed_turns); completed_turns = durable.max(completed_turns).saturating_add(1); vendor_turn = completed_turns; @@ -8701,7 +9380,22 @@ impl Gateway { // turn count (status-bearing rows) came up short. // Empty `assistant`: it says nothing new, and every // reader of answers already skips such a row. - if released_mid_turn { + // + // A turn that ended having said NOTHING at all (a + // "no reply needed" line acknowledged silently) gets + // the same row: it is the durable fact that the turn + // ended, the boundary any request bound to it is + // settled by — live and after a restart alike. + // Without it such a request sat `executing` for good + // (F-GOV-233). Only a SETTLED end: a boundary the + // vendor will continue past answers nothing yet. + let silent_end = matches!( + &evt, + ThreadEvent::TurnCompleted { continuation, .. } + if continuation.is_settled() + ) && turn_last_answer.is_none() + && turn_said.is_empty(); + if released_mid_turn || silent_end { if let Some(dir) = project_dir.as_ref() { seq = seq.saturating_add(1); let record = ccteam_harness::execution::turns_mirror::TurnRecord { @@ -8729,16 +9423,27 @@ impl Gateway { _ => None, }, }; - if let Err(err) = ccteam_harness::execution::turns_mirror::append_turn( + match ccteam_harness::execution::turns_mirror::append_turn( dir, &session_id, &record, ) { - tracing::warn!( - session = %session_id, - error = %err, - "ccteam-im: failed to mirror a turn's closing row to turns.jsonl" - ); + // The silent turn's row IS its answer: the + // boundary below names it, and only once it + // is on disk (append → notify). + Ok(_) if silent_end => { + turn_covered.push(record.turn_id.clone()); + turn_last_answer = + Some((record.turn_id.clone(), String::new())); + } + Ok(_) => {} + Err(err) => { + tracing::warn!( + session = %session_id, + error = %err, + "ccteam-im: failed to mirror a turn's closing row to turns.jsonl" + ); + } } } } @@ -9319,7 +10024,22 @@ impl Gateway { // update / complete), independent of whether the // fold bucketed it — both fire. IM drops it (strict // no-op arm); web renders activity cards. - if let Some(activity) = crate::progress::activity_for(&evt) { + if let Some(mut activity) = crate::progress::activity_for(&evt) { + if step_turn != epoch { + step_turn = epoch; + step_inputs.clear(); + steps_recorded.clear(); + } + activity.step_id = + format!("{step_nonce}-{epoch}-{}", activity.item_id); + record_finished_step( + project_dir.as_deref(), + &session_id, + &evt, + &activity, + &mut step_inputs, + &mut steps_recorded, + ); if !emit_activity(&tx, &session, &session_id, epoch, activity) { break; } @@ -10059,6 +10779,7 @@ impl Gateway { .filter(|entry| { entry.item.status == crate::scheduled::ScheduledStatus::Pending && entry.item.send_at <= now + && !self.scheduled_firing.contains(&entry.item.id) }) .cloned() .collect::>(); @@ -10071,11 +10792,24 @@ impl Gateway { ); continue; } - let result = self.submit_scheduled_user_turn(&entry.item).await; + // In flight BEFORE the submit: whatever happens after this point, + // the row is not picked up again by a later pass. + self.scheduled_firing.insert(entry.item.id.clone()); + // One poison row must cost that row, not every later due row (a + // panic escaping here would restart the scheduler on the same + // sorted batch, forever). + let result = crate::supervise::contain_panic( + "scheduled scheduler", + &entry.item.id, + self.submit_scheduled_user_turn(&entry.item), + ) + .await; match result { - Ok(()) => self.complete_scheduled_fire(&entry), - Err(err) => self.fail_scheduled(&entry.item.id, format!("{err:#}")), + Some(Ok(())) => self.complete_scheduled_fire(&entry), + Some(Err(err)) => self.fail_scheduled(&entry.item.id, format!("{err:#}")), + None => self.fail_scheduled(&entry.item.id, "panicked while firing".to_string()), } + self.scheduled_firing.remove(&entry.item.id); } } @@ -10773,35 +11507,44 @@ impl Gateway { )?; return Ok(SubmitResult::Queued { receipt, id }); } - return Err(err); + return Err(undelivered_after_resume_failure(session_id, err)); } // v0.8.24 F5 — if the child is not live (starting/resuming/dead), // enqueue the user text (FIFO, file-backed) and revive; drain after // live so turns are not lost. Gateway remains the sole turns writer // (drain re-enters submit_resolved once live). Callers get the real // drained turn id when resume+submit succeeds. + // + // The same FIFO also holds lines that queued earlier and are still + // undelivered — behind a body from before a restart whose session + // then failed to come back. Every path that brings a session back + // ends here before the new line is submitted, so a backlog always + // goes out first and in order (docs-local issue #211). let not_live = match self.sessions.get(session_id) { Some(s) => !s.adapter.thread_is_live(&s.thread), None => { return Err(anyhow!("current session missing: {session_id}")); } }; - if not_live { - let (project, channel) = { - let s = self - .sessions - .get(session_id) - .ok_or_else(|| anyhow!("current session missing: {session_id}"))?; + let (project, channel) = { + let s = self + .sessions + .get(session_id) + .ok_or_else(|| anyhow!("current session missing: {session_id}"))?; + (s.project.clone(), chat.channel.clone()) + }; + let cwd = self + .projects + .get(&project) + .cloned() + .ok_or_else(|| anyhow!("unknown project for pending turn: {project}"))?; + let backlog = crate::pending_turns::pending_turn_count(&cwd, session_id) > 0; + if not_live || backlog { + if let Some(s) = self.sessions.get(session_id) { if let Ok(mut target) = s.reply_to.lock() { *target = chat.clone(); } - (s.project.clone(), chat.channel.clone()) - }; - let cwd = self - .projects - .get(&project) - .cloned() - .ok_or_else(|| anyhow!("unknown project for pending turn: {project}"))?; + } // No request identity on this path by construction: it is the // IM / web / notifier submit, and it drains within this same call — // the line never outlives it the way a detached-body queue does. @@ -10814,7 +11557,18 @@ impl Gateway { intent.pending(), None, )?; - self.resume_dead_session(session_id).await?; + if not_live { + if let Err(err) = self.resume_dead_session(session_id).await { + // Not delivered: take this line back so "send it again" + // cannot deliver it twice. An older backlog stays queued. + if let Err(error) = + crate::pending_turns::withdraw_last_pending_turn(&cwd, session_id, &payload) + { + tracing::warn!(sid = %session_id, %error, "pending_turns: could not withdraw an undelivered line"); + } + return Err(undelivered_after_resume_failure(session_id, err)); + } + } let drained_ids = self.drain_and_dispatch_pending_turns(session_id).await; let id = drained_ids .into_iter() @@ -13907,6 +14661,7 @@ impl Gateway { thread_ts, role: session.role.clone(), progress_path, + slug: session.project.clone(), }) } @@ -14486,30 +15241,38 @@ impl Gateway { } let mut released = Vec::new(); for row in ripe { - // Off the lock: the vendor's own answer to "is background work - // still running here". - if row - .adapter - .running_tasks(&row.thread) + // Per session: one release that panics must not skip every + // session after it on every pass. + let freed = crate::supervise::contain_panic("idle-release sweeper", &row.sid, async { + // Off the lock: the vendor's own answer to "is background work + // still running here". + if row + .adapter + .running_tasks(&row.thread) + .await + .iter() + .any(|task| task.outlives_turn()) + { + return false; + } + match Self::release_session_shared( + gateway, + &row.sid, + row.generation, + ReleaseReason::Idle, + ) .await - .iter() - .any(|task| task.outlives_turn()) - { - continue; - } - match Self::release_session_shared( - gateway, - &row.sid, - row.generation, - ReleaseReason::Idle, - ) - .await - { - Ok(true) => released.push(row.sid.clone()), - Ok(false) => {} - Err(error) => { - tracing::warn!(sid = %row.sid, %error, "ccteam-im: idle release failed"); + { + Ok(released) => released, + Err(error) => { + tracing::warn!(sid = %row.sid, %error, "ccteam-im: idle release failed"); + false + } } + }) + .await; + if freed == Some(true) { + released.push(row.sid.clone()); } } released @@ -14602,8 +15365,7 @@ impl Gateway { .as_ref() .map(|projection| { projection - .project_snapshot(slug) - .cost + .cost_summary(slug) .cost_24h_by_vendor .get(crate::delegation::vendor_key(vendor)) .copied() @@ -16064,18 +16826,46 @@ impl Gateway { "ccteam-im: redelivering a completion a previous life never finished" ); } + // Per request: is it carried by a notification at all? let mut deliveries = Vec::new(); for request in resolved { let claimed_inline = mirror.wait_claimed(&request); if claimed_inline { mirror.suppressed.insert(request.request_id.clone()); } - // The excerpt cap comes from the REQUEST's own mode: `brief` wakes - // the parent at the same boundary as `final`, with a quarter of - // the text. - let excerpt_cap = crate::delegation::notification_answer_max_chars(request.notify); - let notification = (request.notify != NotifyMode::Off && !claimed_inline).then(|| { - crate::delegation::build_notification_text_with_outcome( + let notified = request.notify != NotifyMode::Off && !claimed_inline; + deliveries.push(DelegationDelivery { request, notified }); + } + // Per parent: ONE notification naming every carried request, in + // dispatch order. The excerpt cap is the widest any of them asked for + // (`final` over `brief`): the parent reads the answer once. + let mut parents: Vec<&str> = Vec::new(); + for delivery in deliveries.iter().filter(|d| d.notified) { + if !parents.contains(&delivery.request.parent_sid.as_str()) { + parents.push(&delivery.request.parent_sid); + } + } + let notifications = parents + .into_iter() + .map(|parent| { + let carried: Vec<&ccteam_harness::DelegationRequest> = deliveries + .iter() + .filter(|d| d.notified && d.request.parent_sid == parent) + .map(|d| &d.request) + .collect(); + let answered: Vec> = carried + .iter() + .map(|request| crate::delegation::AnsweredRequest { + id: &request.request_id, + title: request.title.as_deref(), + }) + .collect(); + let excerpt_cap = carried + .iter() + .map(|request| crate::delegation::notification_answer_max_chars(request.notify)) + .max() + .unwrap_or(crate::delegation::NOTIFICATION_ANSWER_MAX_CHARS); + let text = crate::delegation::build_notification_text_with_outcome( &crate::delegation::DelegationSummary { sid: &signal.child_sid, vendor: vendor_str(signal.vendor), @@ -16093,24 +16883,25 @@ impl Gateway { cost_usd: None, answer: &signal.tail, conclusion: signal.conclusion.as_deref(), - request_id: Some(&request.request_id), - title: request.title.as_deref(), + requests: &answered, remaining_queued, }, excerpt_cap, - ) - }); - deliveries.push(DelegationDelivery { - request, - notification, - }); - } + ); + ParentNotification { + parent_sid: parent.to_string(), + request_ids: carried.iter().map(|r| r.request_id.clone()).collect(), + text, + } + }) + .collect(); Some(DelegationDeliveryPlan { signal, generation, dedup_key, slug, deliveries, + notifications, emitter, }) } @@ -16348,41 +17139,47 @@ impl Gateway { .await; } let mut failed: Vec = Vec::new(); - for delivery in &plan.deliveries { - let Some(text) = delivery.notification.as_ref() else { - continue; - }; + for notification in &plan.notifications { match Self::submit_to_sid_shared( Arc::clone(&gateway), - &delivery.request.parent_sid, - text.clone(), + ¬ification.parent_sid, + notification.text.clone(), GatewayDeadline::start(), ) .await { Ok(_) => { - plan.emitter - .emit(DelegationProgressRecord { - slug: plan.slug.clone(), - event: ccteam_harness::execution::progress_bridge::DELEGATION_NOTIFIED - .to_string(), - parent_sid: delivery.request.parent_sid.clone(), - child_sid: child.clone(), - vendor: plan.signal.vendor, - host: plan.signal.host.clone(), - turn: Some(plan.signal.turn_id.clone()), - title: delivery.request.title.clone(), - reason: None, - }) - .await; + // The ledger edge belongs to the request: one `notified` + // row per request the turn carried. + for delivery in plan + .deliveries + .iter() + .filter(|d| notification.request_ids.contains(&d.request.request_id)) + { + plan.emitter + .emit(DelegationProgressRecord { + slug: plan.slug.clone(), + event: + ccteam_harness::execution::progress_bridge::DELEGATION_NOTIFIED + .to_string(), + parent_sid: delivery.request.parent_sid.clone(), + child_sid: child.clone(), + vendor: plan.signal.vendor, + host: plan.signal.host.clone(), + turn: Some(plan.signal.turn_id.clone()), + title: delivery.request.title.clone(), + reason: None, + }) + .await; + } } Err(error) => { tracing::warn!( - parent = %delivery.request.parent_sid, %child, %error, - request = %delivery.request.request_id, - "ccteam-im: delegation notify failed; dropping that request" + parent = %notification.parent_sid, %child, %error, + requests = ?notification.request_ids, + "ccteam-im: delegation notify failed; dropping those requests" ); - failed.push(delivery.request.request_id.clone()); + failed.extend(notification.request_ids.iter().cloned()); } } } @@ -16431,7 +17228,7 @@ impl Gateway { request.state = terminal; request.queue_position = None; request.answered_turn = Some(plan.signal.turn_id.clone()); - request.notified = delivery.notification.is_some(); + request.notified = delivery.notified; } } if current.store.outstanding().count() == 0 { @@ -16478,73 +17275,102 @@ impl Gateway { .delegation_watch_set, ); while let Some(pulse) = rx.recv().await { - match pulse { - crate::delegation::DelegationPulse::TurnOpened { - child_sid, - exec_turn_id, - continues_from, - } => { - if !watch_set.read().unwrap().contains(&child_sid) { - continue; - } - // A store mutation: claimed like every other one, so it - // cannot interleave with a dispatch's accept/bind pair. - let claim = Self::claim_delegation_store(&gateway, &child_sid).await; - let (moved, changed) = { - let mut guard = - crate::latency::gateway_lock(&gateway, "notifier.executing").await; - // Move the bindings FIRST, so the executing mark that - // follows finds the requests already on this turn. Both - // under one lock hold: a reader must never catch a - // request bound to a turn that has ended while the one - // continuing it is already running. - let moved = match continues_from.as_deref() { - Some(from) => { - // The vendor DID come back for the work: the - // boundary held under `from` was a checkpoint - // after all, not the answer. - if let Some(mirror) = guard.delegations.get_mut(&child_sid) { - mirror.provisional.remove(from); - } - guard.rebind_delegation_continuation(&claim, from, &exec_turn_id) + let child = match &pulse { + crate::delegation::DelegationPulse::TurnOpened { child_sid, .. } => { + child_sid.clone() + } + crate::delegation::DelegationPulse::Signal(signal) => signal.child_sid.clone(), + }; + // The only notifier: a panic on one pulse must not end parent + // notification for every delegation (issue #210 failure class). + crate::supervise::contain_panic( + "delegation notifier", + &format!("child={child}"), + Self::handle_delegation_pulse(&gateway, &watch_set, pulse), + ) + .await; + } + } + + /// One pulse of [`Self::run_delegation_notifier`]. + async fn handle_delegation_pulse( + gateway: &Arc>, + watch_set: &std::sync::RwLock>, + pulse: crate::delegation::DelegationPulse, + ) { + match pulse { + crate::delegation::DelegationPulse::TurnOpened { + child_sid, + exec_turn_id, + continues_from, + } => { + if !watch_set + .read() + .unwrap_or_else(|err| err.into_inner()) + .contains(&child_sid) + { + return; + } + // A store mutation: claimed like every other one, so it + // cannot interleave with a dispatch's accept/bind pair. + let claim = Self::claim_delegation_store(gateway, &child_sid).await; + let (moved, changed) = { + let mut guard = + crate::latency::gateway_lock(gateway, "notifier.executing").await; + // Move the bindings FIRST, so the executing mark that + // follows finds the requests already on this turn. Both + // under one lock hold: a reader must never catch a + // request bound to a turn that has ended while the one + // continuing it is already running. + let moved = match continues_from.as_deref() { + Some(from) => { + // The vendor DID come back for the work: the + // boundary held under `from` was a checkpoint + // after all, not the answer. + if let Some(mirror) = guard.delegations.get_mut(&child_sid) { + mirror.provisional.remove(from); } - None => false, - }; - ( - moved, - moved | guard.mark_delegation_requests_executing(&claim, &exec_turn_id), - ) + guard.rebind_delegation_continuation(&claim, from, &exec_turn_id) + } + None => false, }; - if changed { - // The executing flag alone is a display fact the next - // write carries anyway; a REBINDING is not. A move that - // never reaches disk leaves the request pointing at a - // turn that has already ended, and a restart would then - // read that turn's row as this request's answer. - if let Err(error) = Self::persist_delegation_store(&gateway, &claim).await { - if moved { - tracing::warn!( - child = %child_sid, - turn = %exec_turn_id, - %error, - "ccteam-im: a continuation rebinding could not be made \ - durable; a restart may resolve it off the turn it left" - ); - } + ( + moved, + moved | guard.mark_delegation_requests_executing(&claim, &exec_turn_id), + ) + }; + if changed { + // The executing flag alone is a display fact the next + // write carries anyway; a REBINDING is not. A move that + // never reaches disk leaves the request pointing at a + // turn that has already ended, and a restart would then + // read that turn's row as this request's answer. + if let Err(error) = Self::persist_delegation_store(gateway, &claim).await { + if moved { + tracing::warn!( + child = %child_sid, + turn = %exec_turn_id, + %error, + "ccteam-im: a continuation rebinding could not be made \ + durable; a restart may resolve it off the turn it left" + ); } } } - crate::delegation::DelegationPulse::Signal(signal) => { - let watched = watch_set.read().unwrap().contains(&signal.child_sid); - // Unwatched fleet chatter and watched mid-turn narration - // create no gateway-lock traffic. The child pump already - // mirrored narration into its ledger; only a vendor turn - // boundary can notify a parent. - if !watched || !signal.boundary { - continue; - } - Self::deliver_delegation_signal_shared(Arc::clone(&gateway), signal).await; + } + crate::delegation::DelegationPulse::Signal(signal) => { + let watched = watch_set + .read() + .unwrap_or_else(|err| err.into_inner()) + .contains(&signal.child_sid); + // Unwatched fleet chatter and watched mid-turn narration + // create no gateway-lock traffic. The child pump already + // mirrored narration into its ledger; only a vendor turn + // boundary can notify a parent. + if !watched || !signal.boundary { + return; } + Self::deliver_delegation_signal_shared(Arc::clone(gateway), signal).await; } } } @@ -16692,16 +17518,16 @@ impl Gateway { gateway: Arc>, sid: &str, text: String, - is_admin: bool, + web_chat_id: &str, deadline: GatewayDeadline, ) -> Result { - if is_admin && text.trim_start().starts_with('/') { + if text.trim_start().starts_with('/') { let is_gateway_control = parse_session_directive(&text).is_none(); if is_gateway_control { return deadline .lock(&gateway) .await? - .submit_web_sid(sid, text, true) + .submit_web_sid(sid, text, web_chat_id) .await; } } @@ -17250,28 +18076,30 @@ impl Gateway { /// Regression fix: `/status` from the web console used to reach the vendor /// verbatim → "/status isn't available in this environment". /// - /// `is_admin` gates the control face: the fleet renders (`/status`, - /// `/sessions`) use the IM chat-level ACL (`chat_can_access` over the shared - /// `user:` pool), which is coarser than the web's per-tenant REST ACL - /// (`can_see_owner`) — so exposing it to a tenant could over-share another - /// tenant's sessions. It is therefore admin-only, matching the web Status / - /// 主机 / Settings nav (already `useMe().isAdmin`-gated). A non-admin caller - /// falls straight through to a turn/vendor-directive (today's behaviour). + /// Every web identity gets the same control face. `web_chat_id` is the + /// caller's own web frontend chat (`Identity::web_chat_id`: `web-api` for + /// the admin, the tenant id otherwise), so the commands run as THAT + /// principal — [`Self::principal`] resolves it to Operator or Tenant and the + /// fleet renders filter through the one shared visibility policy, exactly + /// as the tenant's own IM bot does. A tenant used to fall through to the + /// vendor here (Claude queued `/status` behind the running turn) on the + /// stale premise that the IM ACL was coarser than the web's. /// A2A `agent` keeps calling `submit_to_sid` directly, so /// agent→agent routing is deliberately never given the human control face. pub async fn submit_web_sid( &mut self, sid: &str, text: String, - is_admin: bool, + web_chat_id: &str, ) -> Result { - if is_admin && text.trim_start().starts_with('/') { - let chat = web_api_chat(); + if text.trim_start().starts_with('/') { + let chat = ChatKey::new("web", web_chat_id, web_chat_id); // Web navigates by URL, so the POST's `sid` is the authoritative - // focus: point the shared web chat at this session (+ its project) - // so `/status`/`/sessions` render THIS session in depth, matching - // the IM current-session semantics. - if let Some(project) = self.sessions.get(sid).map(|s| s.project.clone()) { + // focus: point the caller's web chat at this session (+ its + // project) so `/status`/`/sessions` render THIS session in depth, + // matching the IM current-session semantics. A released session + // is focused too — `/status` renders it from its persisted facts. + if let Some(project) = self.project_slug_for_sid(sid) { self.current_project.set(&chat, project); self.focus_session(&chat, sid); } @@ -17389,8 +18217,28 @@ impl Gateway { pub async fn stop_session_shared( gateway: &Arc>, sid: &str, + ) -> Result { + // The whole stop runs on its own task: a caller that goes away + // mid-kill (a web client disconnecting drops this future) must not + // leave a body ended but its session never stopped — marked + // `stopping` forever, with messages queued behind a dead process. + let gateway = Arc::clone(gateway); + let sid = sid.to_string(); + tokio::spawn(async move { Self::stop_session_owned(&gateway, &sid).await }) + .await + .unwrap_or_else(|join| Err(anyhow!("stop task failed: {join}"))) + } + + async fn stop_session_owned( + gateway: &Arc>, + sid: &str, ) -> Result { let claim = Self::claim_delegation_store(gateway, sid).await; + // A detached body is ended HERE, off the gateway lock (the kill can + // take the whole SIGTERM grace); the stop below then finds it gone. + if let Err(error) = Self::end_detached_body_shared(gateway, sid, "stopping").await { + tracing::warn!(session = %sid, %error, "ccteam-im: stop could not end the detached body off the lock; the stop retries it"); + } let stopped = { let mut guard = crate::latency::gateway_lock(gateway, "session.stop").await; guard.stop_session(sid).await @@ -17891,6 +18739,13 @@ impl Gateway { } let already_stopped = meta.stopped_at.is_some(); self.principals.forget(sid); + // A rebuild already spawning this sid off the lock (the watcher's, or + // an on-demand resume) must not install it after the stop: without + // its reservation, `apply_rebuilt_session` discards the thread. + self.rebuild_reservations.remove(sid); + if let Ok(mut map) = self.body_ends.lock() { + map.remove(sid); + } self.current_session.retain_values(|v| v != sid); self.record_explicit_stop(sid); if !already_stopped { @@ -17948,7 +18803,15 @@ impl Gateway { /// applies `chat_can_access` first) and the web `POST /// /sessions/{sid}/interrupt` route (which applies the project ACL via /// `gate_sid` first). Unknown sid → `Err` so the web edge can 404. + /// + /// A DETACHED sid (its body outlived the previous daemon) has no control + /// channel left to interrupt through, so the interrupt ends that body + /// instead and the session comes back by sid — see + /// [`Self::end_detached_body`]. pub async fn interrupt_session(&mut self, sid: &str) -> Result<()> { + if self.end_detached_body(sid) { + return Ok(()); + } let session = self .sessions .get(sid) @@ -18475,9 +19338,10 @@ fn refresh_session_activity_meta( return; }; meta.last_active = chrono::Utc::now().to_rfc3339(); - meta.turn_count = ccteam_harness::execution::turns_mirror::read_all_turns(project_dir, sid) - .map(|turns| turns.iter().filter(|turn| !turn.user.is_empty()).count() as u64) - .unwrap_or(meta.turn_count); + // An unreadable mirror keeps the stored count rather than writing 0. + if let Some(digest) = ccteam_harness::execution::turns_mirror::digest(project_dir, sid) { + meta.turn_count = digest.user_turns; + } if let Some(session) = projection.and_then(|projection| projection.session_snapshot(slug, sid)) { meta.cost_usd = session.pricing(vendor).cost_usd; @@ -18490,6 +19354,30 @@ fn refresh_session_activity_meta( } /// Admin-web fallback for an unowned legacy resource or an unknown sid. +/// The error a submit returns when the session it targets could not be +/// brought back: what failed, that THIS message was not delivered (so sending +/// it again is safe), and the way out when retrying keeps failing. The cause +/// stays downcastable for typed callers. +fn undelivered_after_resume_failure(sid: &str, err: anyhow::Error) -> anyhow::Error { + let advice = format!( + "{sid} could not be resumed: {err:#}. This message was not delivered — send it again \ + to retry; if it keeps failing, `/new` starts a fresh session." + ); + err.context(advice) +} + +/// How a recovered answer says the unobserved turn came to an end, by the +/// reason its body's exit was recorded under. +fn unobserved_end_phrase(reason: &str) -> &'static str { + match reason { + "interrupted" => "was working while ccteam restarted, until /interrupt ended that process", + "ended_idle" | "ended_stalled" => { + "was working while ccteam restarted, until a waiting message ended that process" + } + _ => "finished this turn while ccteam was restarting", + } +} + fn web_api_chat() -> ChatKey { ChatKey::new("web", "web-api", "web-api") } @@ -19275,6 +20163,49 @@ fn emit_progress( }) } +/// Keep a finished step for the web inspector: remember what a step started +/// with, and append the record once it completes. A tool step is written +/// once; a reasoning step again whenever its text has grown (an ACP turn +/// reports one thought that keeps growing), the newest record winning on +/// read. A write failure loses inspector detail only, so it is logged, never +/// raised. +fn record_finished_step( + project_dir: Option<&std::path::Path>, + session_id: &str, + evt: &ThreadEvent, + activity: &SessionActivity, + step_inputs: &mut HashMap, + steps_recorded: &mut HashMap, +) { + let Some(project_dir) = project_dir else { + return; + }; + if let ThreadEvent::ItemStarted { item } = evt { + if let Some(input) = crate::progress::step_input(&item.details) { + step_inputs.insert(item.id.clone(), input); + } + } + let recorded = steps_recorded.get(&activity.step_id).copied(); + if recorded.is_some() && activity.kind != ActivityKind::Thinking { + return; + } + let started = step_inputs.get(&activity.item_id).map(String::as_str); + let Some(record) = crate::progress::finished_step(evt, &activity.step_id, started) else { + return; + }; + let size = record.output.as_deref().map_or(0, str::len); + if recorded.is_some_and(|prev| size <= prev) { + return; + } + steps_recorded.insert(activity.step_id.clone(), size); + step_inputs.remove(&activity.item_id); + if let Err(err) = + ccteam_harness::execution::turns_mirror::append_step(project_dir, session_id, record) + { + tracing::warn!(sid = %session_id, %err, "recording a finished step failed"); + } +} + /// Emit one structured `Activity` gateway event (v0.8.19) for the given /// step, keyed to the same progress `status_key` as the turn's folded /// status. IM drops it (a strict no-op arm); web renders it. The `content` @@ -19385,23 +20316,6 @@ fn async_event_text(evt: &ThreadEvent) -> Option { } } -/// The highest `-` row id this pump family has written — where a new -/// pump resumes numbering. COUNTING rows was a proxy that held only while -/// every such row had text: a closing row (empty `assistant`, #209) is not -/// counted, so the next pump re-issued its id — and a turn id already in -/// `notified_turns` silently swallows the completion that reuses it. -fn last_answer_seq( - session_id: &str, - turns: &[ccteam_harness::execution::turns_mirror::TurnRecord], -) -> u64 { - let prefix = format!("{session_id}-"); - turns - .iter() - .filter_map(|turn| turn.turn_id.strip_prefix(&prefix)?.parse::().ok()) - .max() - .unwrap_or(0) -} - /// What woke the event pump's stream branch: the vendor, or the deadline of /// text it is holding (#209). enum PumpWake { @@ -19680,6 +20594,7 @@ mod open_work_items_tests { details: ThreadItemDetails::ToolCall { name: "Bash".into(), args: serde_json::json!({}), + output: None, }, }, }, @@ -19704,6 +20619,7 @@ mod open_work_items_tests { details: ThreadItemDetails::ToolCall { name: "Bash".into(), args: serde_json::json!({}), + output: None, }, }, }, @@ -19717,6 +20633,7 @@ mod open_work_items_tests { details: ThreadItemDetails::CommandExecution { cmd: "sleep 600".into(), status: "running".into(), + output: None, }, }, }, @@ -20577,6 +21494,15 @@ fn event_text(evt: &ThreadEvent) -> Option { #[cfg(test)] mod tests { + /// Body pids whose end the gateway must fail (`end_body_process`): the + /// only way to exercise a failed kill without a process ccteam cannot + /// signal. Keyed by pid so parallel tests never see each other's. + pub(super) static FAIL_BODY_END_PIDS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + /// Body pids whose end takes 300 ms, so a test can drop its caller mid-kill. + pub(super) static SLOW_BODY_END_PIDS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + use super::*; use ccteam_harness::{ ChoiceOption, ContextSource, ContextUsage, ExecutionMode, HarnessError, ThreadItem, @@ -20889,6 +21815,38 @@ mod tests { /// process, and — the load-bearing half — the chat that was driving it /// KEEPS pointing at it, so the next plain message resumes the SAME sid /// instead of silently opening a new session on a fresh context. + /// Issue #210 follow-up: one session whose release panics costs that + /// session's release, not every session after it on every pass. + #[tokio::test] + async fn idle_release_tick_releases_the_rest_when_one_row_panics() { + let tmp = tempfile::tempdir().unwrap(); + let alpha = tmp.path().join("alpha"); + std::fs::create_dir_all(&alpha).unwrap(); + let fake = Arc::new(FakeAdapter::default()); + let mut gateway = Gateway::new(fake.clone(), "alpha", alpha); + gateway.enable_persistence(tmp.path()).unwrap(); + gateway.set_sessions_config(ccteam_core::SessionsConfig { + idle_release_secs: 60, + ..Default::default() + }); + for chat in ["chat-1", "chat-2"] { + gateway + .handle_text("mock", chat, "alice", "hello") + .await + .unwrap(); + } + for sid in ["s1", "s2"] { + gateway.settle_turn_for_tests(sid); + gateway.backdate_residency_for_tests(sid, std::time::Duration::from_secs(120)); + } + let poison = gateway.sessions["s1"].thread.identity.clone(); + fake.panic_running_tasks_for.lock().unwrap().insert(poison); + + let gateway = Arc::new(tokio::sync::Mutex::new(gateway)); + assert_eq!(Gateway::idle_release_tick(&gateway).await, vec!["s2"]); + assert!(gateway.lock().await.is_session_live("s1")); + } + #[tokio::test] async fn idle_release_frees_the_process_and_the_next_message_resumes_the_same_sid() { let tmp = tempfile::tempdir().unwrap(); @@ -22086,6 +23044,846 @@ mod tests { assert_eq!(fake.starts.load(Ordering::SeqCst), 1); } + /// A body that never exits on its own (real `claude` after stdin EOF stays + /// alive while a background task it started runs) must not hold its sid + /// hostage: `/interrupt` ends that PROCESS and the SAME sid comes back on + /// its context — not stopped, the chat's focus kept, the turns queued + /// behind the body delivered (docs-local issue #211). + #[tokio::test] + async fn interrupt_ends_a_detached_body_and_resumes_the_same_sid() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let sid; + { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut gateway = Gateway::new(fake, "alpha", &project_dir); + gateway.enable_persistence(tmp.path()).unwrap(); + sid = spawn_managed(&mut gateway).await; + gateway.current_session.set(&chat, sid.clone()); + gateway.persist_routing().unwrap(); + } + let mut child = plant_body(&project_dir, &sid); + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut restarted = Gateway::new(fake.clone(), "alpha", &project_dir); + restarted.enable_persistence(tmp.path()).unwrap(); + restarted.reconcile_restored_sessions().await; + assert!(restarted.is_session_detached(&sid)); + let replies = restarted + .submit_to_current(&chat, "", "hello after restart".into()) + .await + .unwrap(); + assert!( + replies + .iter() + .any(|r| r.contains(&format!("/interrupt {sid}"))), + "the receipt names the way out that keeps the session: {replies:?}" + ); + + let replies = restarted + .handle_text("web", "web-api", "web-api", "/interrupt") + .await + .unwrap(); + assert!( + replies.iter().any(|r| r.contains("正在结束")), + "the bare /interrupt reached the detached focus: {replies:?}" + ); + // The kill runs on its own task, off the gateway lock: yield to it. + let started = std::time::Instant::now(); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "body not ended" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }; + assert!(status.code().is_none(), "signalled: {status:?}"); + + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + // The watcher waits for the kill task's hand-over before it rebuilds. + let started = std::time::Instant::now(); + while !gateway.lock().await.is_session_live(&sid) { + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "rebuilt once the kill handed over" + ); + Gateway::body_watch_tick(&gateway).await; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let g = gateway.lock().await; + assert!(g.is_session_live(&sid), "the same sid is live again"); + assert!(!g.is_session_detached(&sid)); + assert_eq!(fake.starts.load(Ordering::SeqCst), 1, "exactly one body"); + assert_eq!(g.current_session.get(&chat), Some(sid.clone())); + assert!( + read_session_meta(&project_dir, &sid) + .unwrap() + .stopped_at + .is_none(), + "an interrupt is not a stop" + ); + assert_eq!( + crate::pending_turns::pending_turn_count(&project_dir, &sid), + 0 + ); + let submitted: Vec = fake + .submissions + .lock() + .await + .iter() + .map(|(_, text)| text.clone()) + .collect(); + assert_eq!(submitted, vec!["hello after restart".to_string()]); + } + + /// Issue #210 follow-up: the post-mortem batch is taken off the gateway in + /// one go, so one panicking post-mortem must not drop the others. + #[tokio::test] + async fn body_watch_tick_runs_every_post_mortem_when_one_panics() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut gateway = Gateway::new(fake.clone(), "alpha", &project_dir); + gateway.enable_persistence(tmp.path()).unwrap(); + let first = spawn_managed(&mut gateway).await; + let second = spawn_managed(&mut gateway).await; + fake.panic_recover_for.lock().unwrap().insert(first.clone()); + let body = ccteam_harness::execution::session_body::SessionBody { + pid: 1, + fingerprint: String::new(), + adapter: String::new(), + recorded_at: String::new(), + }; + for sid in [&first, &second] { + gateway.post_mortem_pending.push(( + sid.clone(), + "alpha".into(), + project_dir.clone(), + body.clone(), + )); + } + let gateway = Arc::new(tokio::sync::Mutex::new(gateway)); + Gateway::body_watch_tick(&gateway).await; + assert_eq!( + *fake.recovered_for.lock().unwrap(), + vec![first, second], + "the post-mortem after the panicking one still ran" + ); + } + + /// Restart a gateway over `tmp` with `sid`'s body still running and the + /// chat focused on it: the shape every detached-body wait test starts from. + async fn restarted_with_live_body( + tmp: &std::path::Path, + project_dir: &std::path::Path, + chat: &ChatKey, + ) -> (String, std::process::Child, Arc, Gateway) { + let sid; + { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut gateway = Gateway::new(fake, "alpha", project_dir); + gateway.enable_persistence(tmp).unwrap(); + sid = spawn_managed(&mut gateway).await; + gateway.current_session.set(chat, sid.clone()); + gateway.persist_routing().unwrap(); + } + let child = plant_body(project_dir, &sid); + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut restarted = Gateway::new(fake.clone(), "alpha", project_dir); + restarted.enable_persistence(tmp).unwrap(); + restarted.reconcile_restored_sessions().await; + assert!(restarted.is_session_detached(&sid)); + (sid, child, fake, restarted) + } + + fn set_activity(fake: &FakeAdapter, turn_ended: bool, quiet_secs: u64) { + *fake.detached_activity.lock().unwrap() = Some(ccteam_harness::DetachedActivity { + turn_ended, + quiet_for: std::time::Duration::from_secs(quiet_secs), + }); + } + + /// The wait behind a body from before a restart is bounded without anyone + /// knowing `/interrupt` (docs-local issue #211): once a turn is queued and + /// the body's record says its last turn ended and it has gone quiet, the + /// watcher ends that PROCESS and the SAME sid resumes on its context with + /// the queued turn. Until then — nobody waiting, a turn still open, or not + /// quiet long enough — the body is left alone. + #[tokio::test] + async fn a_queued_turn_ends_an_idle_detached_body_and_resumes_the_same_sid() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, fake, restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + + // Idle, but nobody is waiting on it: its background work runs on. + set_activity(&fake, true, 600); + Gateway::body_watch_tick(&gateway).await; + assert!(gateway.lock().await.is_session_detached(&sid)); + + let id = gateway + .lock() + .await + .submit_to_sid(&sid, "hello after restart".into()) + .await + .unwrap(); + assert!(id.starts_with("queued-behind-body:"), "got {id}"); + // A turn still open, or an ended one not quiet long enough: wait. + set_activity(&fake, false, 600); + Gateway::body_watch_tick(&gateway).await; + set_activity(&fake, true, 5); + Gateway::body_watch_tick(&gateway).await; + assert!(gateway.lock().await.is_session_detached(&sid)); + assert_eq!(child.try_wait().unwrap(), None, "the body is untouched"); + + set_activity(&fake, true, 60); + Gateway::body_watch_tick(&gateway).await; + let status = child.wait().unwrap(); + assert!(status.code().is_none(), "signalled: {status:?}"); + let g = gateway.lock().await; + assert!(g.is_session_live(&sid), "the same sid is live again"); + assert!(!g.is_session_detached(&sid)); + assert_eq!(fake.starts.load(Ordering::SeqCst), 1, "exactly one body"); + assert_eq!(g.current_session.get(&chat), Some(sid.clone())); + assert!(read_session_meta(&project_dir, &sid) + .unwrap() + .stopped_at + .is_none()); + assert_eq!( + crate::pending_turns::pending_turn_count(&project_dir, &sid), + 0 + ); + let submitted: Vec = fake + .submissions + .lock() + .await + .iter() + .map(|(_, text)| text.clone()) + .collect(); + assert_eq!(submitted, vec!["hello after restart".to_string()]); + } + + /// A body that is still mid-turn keeps the wait — but not forever: a turn + /// whose record has been silent past the stall bound is treated as idle. + #[tokio::test] + async fn a_queued_turn_ends_a_stalled_detached_body() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + restarted + .submit_to_sid(&sid, "are you there".into()) + .await + .unwrap(); + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + set_activity(&fake, false, 29 * 60); + Gateway::body_watch_tick(&gateway).await; + assert!(gateway.lock().await.is_session_detached(&sid)); + + set_activity(&fake, false, 31 * 60); + Gateway::body_watch_tick(&gateway).await; + child.wait().unwrap(); + let g = gateway.lock().await; + assert!(g.is_session_live(&sid)); + assert_eq!( + crate::pending_turns::pending_turn_count(&project_dir, &sid), + 0 + ); + } + + fn progress_paths(tmp: &std::path::Path) -> ccteam_core::CcteamPaths { + ccteam_core::CcteamPaths { + root: tmp.join("home"), + projects_root: tmp.join("projects"), + } + } + + /// Poll the project's progress ledger until a line matches `needle`. + async fn ledger_has(paths: &ccteam_core::CcteamPaths, needle: &str) -> bool { + for _ in 0..100 { + let body = std::fs::read_to_string(paths.progress_jsonl("alpha")).unwrap_or_default(); + if body.lines().any(|l| l.contains(needle)) { + return true; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + false + } + + fn submitted_texts(fake: &FakeAdapter) -> Vec { + fake.submissions + .try_lock() + .unwrap() + .iter() + .map(|(_, text)| text.clone()) + .collect() + } + + /// The automatic path records itself honestly: the ledger says the + /// process was ended for a waiting turn (`ended_idle`), not that it + /// exited on its own. + #[tokio::test] + async fn an_auto_ended_body_is_recorded_as_such() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + let paths = progress_paths(tmp.path()); + restarted.enable_project_creation(paths.clone()); + restarted.submit_to_sid(&sid, "hi".into()).await.unwrap(); + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + set_activity(&fake, true, 60); + Gateway::body_watch_tick(&gateway).await; + child.wait().unwrap(); + assert!( + ledger_has(&paths, r#""reason":"ended_idle""#).await, + "the exit is recorded under ended_idle" + ); + let ledger = std::fs::read_to_string(paths.progress_jsonl("alpha")).unwrap(); + assert!( + !ledger + .lines() + .any(|l| l.contains("session_body_exited") && l.contains(r#""reason":"exited""#)), + "not recorded as a natural exit: {ledger}" + ); + assert!(gateway.lock().await.is_session_live(&sid)); + } + + /// Ending a body for a waiting turn never signals what it started: the + /// background child keeps running, and the user and the ledger are told + /// exactly which processes were left running unattended (checker A). + #[cfg(target_os = "linux")] + #[tokio::test] + async fn an_ended_body_reports_the_background_processes_it_left_running() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut planted, fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + // Swap the planted body for one that started a background process. + body_gone(&mut planted); + let pidfile = tmp.path().join("bg.pid"); + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg(format!("sleep 62 & echo $! > {}; wait", pidfile.display())) + .env(ccteam_harness::execution::session_body::BODY_SID_ENV, &sid) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + ccteam_harness::execution::session_body::record( + &project_dir, + &sid, + Some(child.id()), + "test", + ) + .unwrap(); + let bg = loop { + if let Some(pid) = std::fs::read_to_string(&pidfile) + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + break pid; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + }; + restarted.detached.get_mut(&sid).unwrap().body = + ccteam_harness::execution::session_body::read(&project_dir, &sid).unwrap(); + let paths = progress_paths(tmp.path()); + restarted.enable_project_creation(paths.clone()); + let mut events = restarted.subscribe_events(); + restarted.submit_to_sid(&sid, "hi".into()).await.unwrap(); + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + set_activity(&fake, true, 60); + Gateway::body_watch_tick(&gateway).await; + child.wait().unwrap(); + + assert!( + ccteam_harness::execution::session_body::process_exists(bg), + "the background process is never signalled" + ); + let mut told = false; + while let Ok(event) = events.try_recv() { + if event.content.contains("still running, unattended") + && event.content.contains(&format!("pid {bg} `sleep 62`")) + { + told = true; + } + } + assert!(told, "the reply target is told what is still running"); + assert!(ledger_has(&paths, r#""orphans":[{"cmd":"sleep 62","fingerprint":""#).await); + assert!(ledger_has(&paths, &format!(r#""pid":{bg}}}]"#)).await); + assert!(gateway.lock().await.is_session_live(&sid)); + unsafe { + libc::kill(bg as i32, libc::SIGKILL); + } + } + + /// The watcher decides off the lock, so it can reach a body a user's + /// `/stop` (or `/interrupt`) already marked. It must never relabel that + /// end: a stop it overwrote would be undone by the watcher's rebuild + /// (checker D1). Only a stop may take over an interrupt. + #[tokio::test] + async fn the_watcher_never_relabels_a_users_stop_or_interrupt() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + restarted.submit_to_sid(&sid, "hi".into()).await.unwrap(); + // A /stop is in flight: it marked the body and is ending it. + restarted.detached.get_mut(&sid).unwrap().ending = Some("stopping"); + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + set_activity(&fake, true, 600); + Gateway::body_watch_tick(&gateway).await; + assert_eq!( + child.try_wait().unwrap(), + None, + "the watcher did not end it" + ); + { + let g = gateway.lock().await; + assert_eq!(g.detached.get(&sid).unwrap().ending, Some("stopping")); + assert!( + !g.is_session_live(&sid), + "nothing was rebuilt over the stop" + ); + } + assert!( + !Gateway::end_detached_body_shared(&gateway, &sid, "ended_idle") + .await + .unwrap() + ); + // The body dies (the stop's kill): still the stop's to finish. + body_gone(&mut child); + Gateway::body_watch_tick(&gateway).await; + assert!(gateway.lock().await.is_session_detached(&sid)); + assert_eq!(fake.starts.load(Ordering::SeqCst), 0, "no rebuild"); + + // An interrupt in flight is not relabelled by the automatic path... + gateway.lock().await.detached.get_mut(&sid).unwrap().ending = Some("interrupted"); + assert!( + !Gateway::end_detached_body_shared(&gateway, &sid, "ended_idle") + .await + .unwrap() + ); + assert_eq!( + gateway.lock().await.detached.get(&sid).unwrap().ending, + Some("interrupted") + ); + // ...but a stop may take it over. + assert!( + Gateway::end_detached_body_shared(&gateway, &sid, "stopping") + .await + .unwrap() + ); + } + + /// An interrupt whose kill fails must not leave its mark behind: the next + /// `/interrupt` really ends the body instead of replying "正在结束" and + /// doing nothing (checker D2). + #[tokio::test] + async fn a_failed_interrupt_lifts_its_mark() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, _fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + let pid = child.id(); + FAIL_BODY_END_PIDS.lock().unwrap().push(pid); + assert!(restarted.end_detached_body(&sid)); + // Let the kill task run and fail. + for _ in 0..50 { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + if matches!( + restarted.body_ends.lock().unwrap().get(&sid), + Some(BodyEnd::Failed) + ) { + break; + } + } + FAIL_BODY_END_PIDS.lock().unwrap().retain(|p| *p != pid); + assert_eq!( + child.try_wait().unwrap(), + None, + "the failed kill left it alive" + ); + + assert!( + restarted.end_detached_body(&sid), + "a second interrupt is taken" + ); + let started = std::time::Instant::now(); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "the second interrupt must really end the body" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }; + assert!(status.code().is_none(), "signalled: {status:?}"); + } + + /// A body can be seen dead while the kill that ended it has not handed + /// over yet: the report waits for the hand-over instead of going out + /// without the orphans, and an orphan whose pid now belongs to another + /// process (start time changed) is never named (checker N1). + #[tokio::test] + async fn the_report_waits_for_the_kill_and_names_only_its_own_orphans() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + let mut events = restarted.subscribe_events(); + restarted.submit_to_sid(&sid, "hi".into()).await.unwrap(); + restarted.detached.get_mut(&sid).unwrap().ending = Some("interrupted"); + Gateway::start_body_end(&restarted.body_ends, &sid); + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + // The kill took the body, but has not handed over. + body_gone(&mut child); + Gateway::body_watch_tick(&gateway).await; + assert!(gateway.lock().await.is_session_detached(&sid), "deferred"); + assert_eq!(fake.starts.load(Ordering::SeqCst), 0); + + // Hand-over: one orphan whose pid is now somebody else (this test + // process, with a start time that does not match). + let reused = std::process::id(); + Gateway::set_body_end( + &gateway.lock().await.body_ends, + &sid, + BodyEnd::Ended(vec![ccteam_harness::execution::session_body::Orphan { + pid: reused, + cmd: "npm run dev".to_string(), + fingerprint: "0".to_string(), + }]), + ); + Gateway::body_watch_tick(&gateway).await; + assert!(gateway.lock().await.is_session_live(&sid)); + while let Ok(event) = events.try_recv() { + assert!( + !event.content.contains("still running, unattended"), + "a reused pid is not reported: {}", + event.content + ); + } + assert!(gateway.lock().await.body_ends.lock().unwrap().is_empty()); + } + + /// A caller that goes away mid-kill (a web client disconnecting drops the + /// REST future) must not strand the sid: the kill and its hand-over run + /// on their own task, the watcher still resumes the session and delivers + /// its queue (checker C1). A hand-over that never comes ages out. + #[tokio::test] + async fn a_dropped_interrupt_caller_does_not_strand_the_session() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + restarted.submit_to_sid(&sid, "hi".into()).await.unwrap(); + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + let pid = child.id(); + SLOW_BODY_END_PIDS.lock().unwrap().push(pid); + let dropped = tokio::time::timeout( + std::time::Duration::from_millis(50), + Gateway::interrupt_session_shared(&gateway, &sid), + ) + .await; + assert!(dropped.is_err(), "the caller went away mid-kill"); + let started = std::time::Instant::now(); + while !gateway.lock().await.is_session_live(&sid) { + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "the session must come back without its caller" + ); + let _ = child.try_wait(); + Gateway::body_watch_tick(&gateway).await; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + SLOW_BODY_END_PIDS.lock().unwrap().retain(|p| *p != pid); + assert_eq!(submitted_texts(&fake), vec!["hi"]); + + // A hand-over that never comes is settled as a failed end. + let mut g = gateway.lock().await; + g.detached.insert( + "s99".into(), + DetachedBody { + sid: "s99".into(), + slug: "alpha".into(), + cwd: project_dir.clone(), + body: ccteam_harness::execution::session_body::SessionBody { + pid: 0, + fingerprint: String::new(), + adapter: "fake".into(), + recorded_at: String::new(), + }, + since: chrono::Utc::now(), + reason: "daemon_restart", + ending: Some("interrupted"), + }, + ); + Gateway::set_body_end( + &g.body_ends, + "s99", + BodyEnd::InProgress { + token: 0, + since: Instant::now() + .checked_sub(Gateway::BODY_END_STALE) + .expect("a monotonic clock older than 30s"), + }, + ); + g.settle_body_ends(); + assert_eq!(g.detached.get("s99").unwrap().ending, None); + assert!(g.body_ends.lock().unwrap().get("s99").is_none()); + } + + /// End to end: a delegated task queued behind a body whose session then + /// fails to come back is withdrawn, its PARENT — a real session — receives + /// the "withdrawn, did not run, send it again" turn through the ordinary + /// completion path, the request is no longer outstanding, and a restart's + /// reconcile does not tell the parent a second time (checker item 4). + #[tokio::test] + async fn a_withdrawn_task_answers_its_parent_once_through_the_completion_path() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (child, mut body, fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + let parent = spawn_managed(&mut restarted).await; + let request_id = restarted + .accept_delegation_request( + &DelegationStoreClaim::for_test(&child), + &parent, + ccteam_harness::NotifyMode::Final, + Some("build the thing".into()), + TurnRouting::Queue, + None, + ) + .expect("accepted"); + crate::pending_turns::enqueue_pending_turn( + &project_dir, + &child, + "build the thing", + Some("mcp".into()), + false, + TurnIntent::from_origin(TurnOrigin::Internal).pending(), + Some(request_id.clone()), + ) + .unwrap(); + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + fake.fail_starts.store(1, Ordering::SeqCst); + Gateway::interrupt_session_shared(&gateway, &child) + .await + .unwrap(); + body.wait().unwrap(); + Gateway::body_watch_tick(&gateway).await; + + let told: Vec = submitted_texts(&fake) + .into_iter() + .filter(|text| text.contains("withdrawn and did not run")) + .collect(); + assert_eq!(told.len(), 1, "the parent is told once: {told:?}"); + assert!(told[0].contains(&request_id), "{}", told[0]); + { + let g = gateway.lock().await; + assert!( + g.outstanding_request_ids(&child, &parent).is_empty(), + "the request is settled" + ); + assert!(g + .delegation_request_state(&child, &request_id) + .is_some_and(|state| state.is_terminal())); + } + assert!(!submitted_texts(&fake) + .iter() + .any(|t| t == "build the thing")); + + // A restart reconciles the store and tells the parent nothing new. + let fake2 = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut again = Gateway::new(fake2.clone(), "alpha", &project_dir); + again.enable_persistence(tmp.path()).unwrap(); + let again = Arc::new(tokio::sync::Mutex::new(again)); + Gateway::reconcile_delegations(Arc::clone(&again)).await; + assert!( + !submitted_texts(&fake2) + .iter() + .any(|text| text.contains("withdrawn")), + "no second notification after a restart" + ); + assert!(again + .lock() + .await + .outstanding_request_ids(&child, &parent) + .is_empty()); + } + + /// A REST `/stop` whose client goes away mid-kill still finishes the + /// stop: the session ends up stopped, not detached with a `stopping` mark + /// nobody will ever lift (checker item a). + #[tokio::test] + async fn a_dropped_stop_caller_still_stops_the_session() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, _fake, restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + let pid = child.id(); + SLOW_BODY_END_PIDS.lock().unwrap().push(pid); + let dropped = tokio::time::timeout( + std::time::Duration::from_millis(50), + Gateway::stop_session_shared(&gateway, &sid), + ) + .await; + assert!(dropped.is_err(), "the caller went away mid-stop"); + let started = std::time::Instant::now(); + loop { + let _ = child.try_wait(); + let stopped = read_session_meta(&project_dir, &sid) + .unwrap() + .stopped_at + .is_some(); + if stopped && !gateway.lock().await.is_session_detached(&sid) { + break; + } + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "the stop must finish without its caller" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + SLOW_BODY_END_PIDS.lock().unwrap().retain(|p| *p != pid); + } + + /// When the session does not come back after its old process ended, the + /// people promised a resume are told why, the queued turns are kept, and + /// the next message delivers them FIRST, in order — never ahead of them, + /// never stranded (checker finding B, docs-local issue #211). + #[tokio::test] + async fn a_failed_rebuild_reports_and_the_queue_goes_out_first_on_the_next_message() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let chat = web_api_chat(); + let (sid, mut child, fake, mut restarted) = + restarted_with_live_body(tmp.path(), &project_dir, &chat).await; + let paths = progress_paths(tmp.path()); + restarted.enable_project_creation(paths.clone()); + let mut events = restarted.subscribe_events(); + restarted.submit_to_sid(&sid, "Q1".into()).await.unwrap(); + // A parent's delegated task, queued behind the same body: withdrawn + // when the parent is told the resume failed, so it never runs late. + crate::pending_turns::enqueue_pending_turn( + &project_dir, + &sid, + "TASK", + Some("mcp".into()), + false, + TurnIntent::from_origin(TurnOrigin::Internal).pending(), + Some("req-7".into()), + ) + .unwrap(); + restarted.submit_to_sid(&sid, "Q2".into()).await.unwrap(); + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + + fake.fail_starts.store(1, Ordering::SeqCst); + Gateway::interrupt_session_shared(&gateway, &sid) + .await + .expect("the REST door ends the body off the lock"); + child.wait().unwrap(); + Gateway::body_watch_tick(&gateway).await; + assert!(!gateway.lock().await.is_session_live(&sid)); + assert_eq!( + crate::pending_turns::pending_turn_count(&project_dir, &sid), + 2, + "the human lines are kept; the delegated task is withdrawn" + ); + let mut told = false; + while let Ok(event) = events.try_recv() { + if event.content.contains("could not be resumed") + && event.content.contains("2 queued message(s) are kept") + { + told = true; + } + } + assert!( + told, + "the reply target hears why the session did not come back" + ); + // The parent side is covered end to end by + // `a_withdrawn_task_answers_its_parent_once_through_the_completion_path`. + assert!(ledger_has(&paths, r#""reason":"interrupted""#).await); + + let id = gateway + .lock() + .await + .submit_to_sid(&sid, "M3".into()) + .await + .unwrap(); + assert!(!id.starts_with("queued-behind-body:"), "got {id}"); + assert_eq!(submitted_texts(&fake), vec!["Q1", "Q2", "M3"]); + assert_eq!( + crate::pending_turns::pending_turn_count(&project_dir, &sid), + 0 + ); + } + + /// A message enqueued for a dead in-map child whose resume then fails is + /// taken back: the error says it was NOT delivered (so sending it again + /// is safe) and names `/new`, and resending delivers it exactly once + /// (checker finding D). + #[tokio::test] + async fn a_failed_resume_takes_the_undelivered_message_back() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().join("alpha"); + std::fs::create_dir_all(&project_dir).unwrap(); + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut gateway = Gateway::new(fake.clone(), "alpha", &project_dir); + gateway.enable_persistence(tmp.path()).unwrap(); + let sid = spawn_managed(&mut gateway).await; + fake.live.store(false, Ordering::SeqCst); + fake.fail_starts.store(1, Ordering::SeqCst); + + let err = gateway + .submit_to_sid(&sid, "hello".into()) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("was not delivered"), "{err}"); + assert!(err.contains("`/new`"), "{err}"); + assert_eq!( + crate::pending_turns::pending_turn_count(&project_dir, &sid), + 0, + "the undelivered line is not left to go out later" + ); + gateway.submit_to_sid(&sid, "hello".into()).await.unwrap(); + assert_eq!(submitted_texts(&fake), vec!["hello"]); + } + /// A body record whose process is already GONE at restore time means the /// body finished (or died) while no daemon was watching. The session is /// rebuilt at once; the watcher's post-mortem then reports the unanswered @@ -22401,6 +24199,7 @@ mod tests { let sink = GatewayEventSink { mpsc: mtx, broadcast: btx, + closing: Arc::default(), }; assert!(sink.send(fake_event(Some("s1"))), "mpsc live ⇒ send ok"); assert_eq!(mrx.recv().await.unwrap().sid.as_deref(), Some("s1")); @@ -22416,11 +24215,74 @@ mod tests { let sink = GatewayEventSink { mpsc: mtx, broadcast: btx, + closing: Arc::default(), }; assert!(sink.send(fake_event(None)), "no SSE subscriber ⇒ still ok"); assert!(mrx.recv().await.is_some()); } + /// The log lines `f` emits on this thread, as `LEVEL message` text. + fn captured_logs(f: impl FnOnce()) -> String { + #[derive(Clone, Default)] + struct Buf(Arc>>); + impl std::io::Write for Buf { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let buf = Buf::default(); + let writer = buf.clone(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_ansi(false) + .with_writer(move || writer.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, f); + let bytes = buf.0.lock().unwrap().clone(); + String::from_utf8(bytes).unwrap() + } + + /// Issue #210: a closed delivery sink outside shutdown is an outage and + /// says so at ERROR, for every send shape; once the daemon has raised + /// the closing flag the same stop is quiet (no ERROR on a clean stop). + #[test] + fn closed_sink_logs_error_only_outside_shutdown() { + let (mtx, mrx) = tokio::sync::mpsc::unbounded_channel::(); + drop(mrx); + let (btx, _) = tokio::sync::broadcast::channel::(8); + let sink = GatewayEventSink { + mpsc: mtx, + broadcast: btx, + closing: Arc::default(), + }; + let send_all = || { + assert!(!sink.send(fake_event(Some("s7")))); + assert!(!sink.send_delivery_only(fake_event(Some("s7")))); + assert!(!sink.send_broadcast_only(fake_event(Some("s7")))); + }; + + let outage = captured_logs(send_all); + assert_eq!( + outage.matches("ERROR").count(), + 3, + "every send shape reports the outage: {outage}" + ); + assert!(outage.contains("s7"), "the log names the session: {outage}"); + + sink.closing + .store(true, std::sync::atomic::Ordering::SeqCst); + let shutdown = captured_logs(send_all); + assert!( + !shutdown.contains("ERROR"), + "clean stop is quiet: {shutdown}" + ); + assert!(shutdown.contains("DEBUG"), "{shutdown}"); + } + /// `subscribe_events` hands out a live receiver off the gateway's held /// broadcast, so an event published through the tee reaches an SSE /// subscriber. (The per-session SSE handler then filters by `sid`.) @@ -23633,6 +25495,12 @@ mod tests { /// sweeper never lets go of a session whose vendor is still carrying /// work. running_tasks: Arc>>, + /// Thread identities whose `running_tasks` probe panics, and sids + /// whose `recover_unobserved_turn` panics — one poison row in a + /// sweep. `recovered_for` records every recovery asked for. + panic_running_tasks_for: Arc>>, + panic_recover_for: Arc>>, + recovered_for: Arc>>, /// What `account_usage` reports (`None` = no usage channel, the /// trait default). Account-scoped: every session sharing this fake /// answers with the same windows, exactly like one vendor account. @@ -23652,6 +25520,10 @@ mod tests { /// tasks into a genuinely busy child and assert their queue positions /// without racing the pump. events_held: Arc, + /// How many upcoming `start_thread` calls fail (a rejected resume). + fail_starts: Arc, + /// What `detached_activity` reports (`None` = no readable record). + detached_activity: Arc>>, } /// One thread's turn FIFO inside [`FakeAdapter`]. @@ -23725,10 +25597,15 @@ mod tests { live: Arc::new(std::sync::atomic::AtomicBool::new(true)), closes: AtomicUsize::new(0), running_tasks: Arc::new(Mutex::new(Vec::new())), + panic_running_tasks_for: Arc::default(), + panic_recover_for: Arc::default(), + recovered_for: Arc::default(), account_usage: Arc::new(Mutex::new(None)), turn_seq: Arc::new(AtomicUsize::new(0)), turn_queues: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), events_held: Arc::new(std::sync::atomic::AtomicBool::new(false)), + detached_activity: Arc::new(std::sync::Mutex::new(None)), + fail_starts: Arc::new(AtomicUsize::new(0)), } } @@ -23776,6 +25653,11 @@ mod tests { .lock() .await .push((h.identity.clone(), text.clone())); + // A submit that panics AFTER the line was delivered — the worst + // case for anything that retries on failure. + if text.contains("PANIC_AFTER_DELIVERY") { + panic!("fake adapter panicked after delivering {text:?}"); + } let turn_id = turn_id.to_string(); if self.emit_turn_started { self.events.lock().await.push_back(( @@ -24016,6 +25898,13 @@ mod tests { #[async_trait::async_trait] impl HarnessAdapter for FakeAdapter { + async fn detached_activity( + &self, + _ctx: &UnobservedTurnCtx, + ) -> Option { + *self.detached_activity.lock().unwrap() + } + fn name(&self) -> &'static str { "fake-gateway" } @@ -24030,6 +25919,15 @@ mod tests { ctx: &SpawnCtx, ) -> Result { self.starts.fetch_add(1, Ordering::SeqCst); + if self + .fail_starts + .try_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) + .is_ok() + { + return Err(HarnessError::SpawnFailed( + "fake: claude --resume rejected".to_string(), + )); + } if let Some(barrier) = self.start_barrier.as_ref() { if barrier.armed.load(Ordering::SeqCst) { barrier.entered.fetch_add(1, Ordering::SeqCst); @@ -24232,10 +26130,29 @@ mod tests { Ok(()) } - async fn running_tasks(&self, _h: &ThreadHandle) -> Vec { + async fn running_tasks(&self, h: &ThreadHandle) -> Vec { + if self + .panic_running_tasks_for + .lock() + .unwrap() + .contains(&h.identity) + { + panic!("fake running_tasks panicked for {}", h.identity); + } self.running_tasks.lock().await.clone() } + async fn recover_unobserved_turn( + &self, + ctx: &ccteam_harness::UnobservedTurnCtx, + ) -> Option { + self.recovered_for.lock().unwrap().push(ctx.sid.clone()); + if self.panic_recover_for.lock().unwrap().contains(&ctx.sid) { + panic!("fake recover panicked for {}", ctx.sid); + } + None + } + async fn account_usage(&self, _h: &ThreadHandle) -> Option { self.account_usage.lock().await.clone() } @@ -25809,7 +27726,7 @@ mod tests { // Admin web console: `/status` is a gateway control command. let receipt = gateway - .submit_web_sid(&sid, "/status".into(), true) + .submit_web_sid(&sid, "/status".into(), "web-api") .await .unwrap(); assert_eq!(receipt, format!("command:{sid}")); @@ -25849,7 +27766,7 @@ mod tests { // Plain text → a real turn to the vendor. gateway - .submit_web_sid(&sid, "hello".into(), true) + .submit_web_sid(&sid, "hello".into(), "web-api") .await .unwrap(); assert_eq!( @@ -25860,25 +27777,153 @@ mod tests { // Vendor directive → recorded as a directive (not a gateway command). gateway - .submit_web_sid(&sid, "/model opus-x".into(), true) + .submit_web_sid(&sid, "/model opus-x".into(), "web-api") .await .unwrap(); let directives = fake.directives.lock().await; assert_eq!(directives.len(), 1); assert_eq!(directives[0].1.name, "model"); + } + + /// The web step inspector reads what the pump records: a tool's input + /// from its start (an ACP completion repeats none) with its output from the + /// completion, one record per step however often it is re-reported, and a + /// reasoning block's text — each under the `step_id` its activity frame + /// carries. + #[test] + fn the_pump_records_each_finished_step_once_under_its_frame_step_id() { + use ccteam_harness::execution::turns_mirror::find_step; + let dir = tempfile::TempDir::new().unwrap(); + let mut inputs = HashMap::new(); + let mut recorded = HashMap::new(); + let mut feed = |evt: ThreadEvent| { + let mut activity = crate::progress::activity_for(&evt).expect("a step"); + activity.step_id = format!("n-1-{}", activity.item_id); + record_finished_step( + Some(dir.path()), + "s7", + &evt, + &activity, + &mut inputs, + &mut recorded, + ); + activity.step_id + }; + let tool = |output: Option<&str>, args: serde_json::Value| ThreadItem { + id: "call-1".into(), + details: ThreadItemDetails::ToolCall { + name: "Bash".into(), + args, + output: output.map(str::to_string), + }, + }; + let started = feed(ThreadEvent::ItemStarted { + item: tool(None, serde_json::json!({"command": "ls"})), + }); + assert_eq!( + find_step(dir.path(), "s7", &started).unwrap(), + None, + "still running" + ); + let done = feed(ThreadEvent::ItemCompleted { + item: tool(Some("a.txt"), serde_json::Value::Null), + }); + assert_eq!(done, started, "start and completion are one step"); + feed(ThreadEvent::ItemCompleted { + item: tool(Some("re-reported"), serde_json::Value::Null), + }); + let step = find_step(dir.path(), "s7", &done) + .unwrap() + .expect("recorded"); + assert_eq!(step.kind, "tool_call"); + assert_eq!(step.input.as_deref(), Some("{\n \"command\": \"ls\"\n}")); + assert_eq!( + step.output.as_deref(), + Some("a.txt"), + "recorded once, first finish" + ); + + let thought = feed(ThreadEvent::ItemUpdated { + item: ThreadItem { + id: "r-1".into(), + details: ThreadItemDetails::Reasoning("weighing two fixes".into()), + }, + }); + let step = find_step(dir.path(), "s7", &thought) + .unwrap() + .expect("recorded"); + assert_eq!(step.kind, "thinking"); + assert_eq!(step.output.as_deref(), Some("weighing two fixes")); + // A thought that keeps growing under one item (ACP) is re-recorded; + // the newest record is the one the inspector reads. + feed(ThreadEvent::ItemUpdated { + item: ThreadItem { + id: "r-1".into(), + details: ThreadItemDetails::Reasoning("weighing two fixes, picking B".into()), + }, + }); + let step = find_step(dir.path(), "s7", &thought) + .unwrap() + .expect("recorded"); + assert_eq!( + step.output.as_deref(), + Some("weighing two fixes, picking B") + ); + } + + /// Admin/tenant parity: a tenant's web `/status` gets the SAME gateway + /// control face as the admin's — never queued to the vendor as literal + /// text — and it renders as the tenant's principal, so the admin's + /// session stays out of the tenant's fleet view. + #[tokio::test] + async fn submit_web_sid_gives_a_tenant_the_same_control_face() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", proj.path()); + let admin_sid = gateway + .create_session_api( + "alpha".into(), + "reviewer".into(), + AgentVendor::Claude, + ccteam_harness::PermissionMode::Skip, + ) + .await + .unwrap(); + let tenant_sid = gateway + .create_session_api_proto( + "alpha".into(), + "writer".into(), + AgentVendor::Claude, + ccteam_harness::PermissionMode::Skip, + SessionProtocol::StreamJson, + "alice".into(), + ) + .await + .unwrap(); + let mut rx = gateway.subscribe_events(); - // A non-admin caller never gets the gateway control face: `/status` - // falls through to `submit_to_sid` (today's behaviour; fleet renders - // stay admin-only), so the receipt is NOT a gateway `command:` marker. - drop(directives); let receipt = gateway - .submit_web_sid(&sid, "/status".into(), false) + .submit_web_sid(&tenant_sid, "/status".into(), "alice") .await .unwrap(); - assert_ne!( - receipt, - format!("command:{sid}"), - "non-admin /status must NOT be handled by the gateway control face" + assert_eq!(receipt, format!("command:{tenant_sid}")); + assert!( + fake.submissions.lock().await.is_empty() && fake.directives.lock().await.is_empty(), + "a tenant's /status is a gateway command, never vendor input" + ); + let ev = rx.try_recv().expect("status render emitted to SSE"); + assert_eq!(ev.sid.as_deref(), Some(tenant_sid.as_str())); + assert!( + ev.content.contains(tenant_sid.as_str()), + "renders the tenant's own session: {}", + ev.content + ); + // The admin's session shares project `alpha`; were it visible, the card + // would count it as a same-project sibling and point at `/sessions`. + assert!( + !ev.content.contains("/sessions"), + "the admin's {admin_sid} must not reach a tenant's /status: {}", + ev.content ); } @@ -26733,7 +28778,7 @@ mod tests { // Same session, same lack of focus — but now a human asked. gateway - .submit_web_sid("s1", "are you still there?".into(), false) + .submit_web_sid("s1", "are you still there?".into(), "alice") .await .unwrap(); let delivered = tokio::time::timeout(std::time::Duration::from_secs(5), async { @@ -27688,7 +29733,7 @@ mod tests { .unwrap() .sid; gateway - .submit_web_sid(&sid, "human typed this".into(), true) + .submit_web_sid(&sid, "human typed this".into(), "web-api") .await .unwrap(); @@ -28661,8 +30706,18 @@ mod tests { row("interrupted:sj-1", "cut"), row("s70-9", "another session's prefix"), ]; - assert_eq!(last_answer_seq("s7", &rows), 3); - assert_eq!(last_answer_seq("s7", &[]), 0); + let project = tempfile::TempDir::new().unwrap(); + let digest = |sid: &str| { + ccteam_harness::execution::turns_mirror::digest(project.path(), sid) + .unwrap() + .max_answer_seq + }; + assert_eq!(digest("s7"), 0); + for row in &rows { + ccteam_harness::execution::turns_mirror::append_turn(project.path(), "s7", row) + .unwrap(); + } + assert_eq!(digest("s7"), 3); } /// #209 — the parent hears the WHOLE turn: a long turn reaches the ledger @@ -28795,6 +30850,7 @@ mod tests { details: ThreadItemDetails::ToolCall { name: "Bash".into(), args: serde_json::json!({"command": "make test"}), + output: None, }, }; if done { @@ -36285,27 +38341,29 @@ mod tests { ) .await; - // The grace runs out with no continuation: the parent is woken by the - // boundary the ping joined, once per request it resolved. - let notes = await_notifications(&project_dir, &parent, 2).await; + // The grace runs out with no continuation: the parent is woken ONCE by + // the boundary the ping joined, the one notification naming both + // requests it resolved (F-GOV-233). + let notes = await_notifications(&project_dir, &parent, 1).await; + let rows = await_request_state(&gateway, &child, "answered").await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let _ = notes; + let notes = ccteam_notification_turns(&project_dir, &parent); + assert_eq!(notes.len(), 1, "one wake-up for the turn: {notes:?}"); + let note = ¬es[0].user; + assert!( + note.contains("compose unified"), + "the joined turn's answer is what is delivered: {note}" + ); assert_eq!( - notes.len(), - 2, - "one wake-up per resolved request: {notes:?}" + note.matches("compose unified").count(), + 1, + "the answer is told once: {note}" ); - for note in ¬es { - assert!( - note.user.contains("compose unified"), - "the joined turn's answer is what is delivered: {}", - note.user - ); - } assert!( - notes.iter().any(|n| n.user.contains(&request_id)) - && notes.iter().any(|n| n.user.contains(&ping_id)), - "each request is named in its own notification: {notes:?}" + note.contains(&request_id) && note.contains(&ping_id), + "the notification names every request it settles: {note}" ); - let rows = await_request_state(&gateway, &child, "answered").await; assert!( rows.iter() .all(|row| row["state"] == serde_json::json!("answered")), @@ -36327,6 +38385,158 @@ mod tests { ); } + /// F-GOV-233 (excore s1390 → s1403, 2026-10-19) — the parent steered + /// sixteen lines into one long running turn; when it ended, every request + /// bound to it got its own copy of the same answer: sixteen identical + /// notifications in one second. A turn has ONE answer, so a parent is told + /// once, the notification naming every request of its that the turn + /// settles. Each request is still resolved and recorded on its own. + #[tokio::test(flavor = "multi_thread", worker_threads = 3)] + async fn steers_into_one_turn_are_answered_by_one_notification() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().to_path_buf(); + let paths = ccteam_core::CcteamPaths { + root: tmp.path().join("home"), + projects_root: tmp.path().join("projects"), + }; + let (gateway, fake, parent, child, request_id, identity, exec_a) = + scripted_child(&project_dir, paths).await; + let mut ids = vec![request_id]; + { + let mut gw = gateway.lock().await; + for n in 0..15 { + let id = gw + .accept_delegation_request( + &DelegationStoreClaim::for_test(&child), + &parent, + if n % 2 == 0 { + ccteam_harness::NotifyMode::Brief + } else { + ccteam_harness::NotifyMode::Final + }, + None, + TurnRouting::Inject, + None, + ) + .expect("the child accepts the steer"); + gw.bind_delegation_request_for_test( + &DelegationStoreClaim::for_test(&child), + &id, + &exec_a, + ); + ids.push(id); + } + } + + play_turn( + &fake, + &identity, + &exec_a, + ccteam_harness::TurnOpening::Submitted, + "HANDOFF written: runbook, inventory, proofs", + ccteam_harness::TurnContinuation::Settled, + ) + .await; + + let _ = await_notifications(&project_dir, &parent, 1).await; + let _ = await_request_state(&gateway, &child, "answered").await; + let rows = gateway.lock().await.delegation_request_rows(&child, 100); + assert_eq!(rows.len(), 16, "{rows:?}"); + assert!( + rows.iter() + .all(|row| row["state"] == serde_json::json!("answered")), + "every steer is settled on its own: {rows:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + let notes = ccteam_notification_turns(&project_dir, &parent); + assert_eq!(notes.len(), 1, "one notification for one turn: {notes:?}"); + let note = ¬es[0].user; + assert_eq!(note.matches("HANDOFF written").count(), 1, "{note}"); + assert!(note.contains("answers 16 requests"), "{note}"); + for id in &ids { + assert!(note.contains(id.as_str()), "{id} is named: {note}"); + } + assert!( + !note.contains("still queued"), + "nothing is left owed: {note}" + ); + } + + /// F-GOV-233, second half — a turn that ends WITHOUT saying anything (a + /// "no reply needed" note codex acknowledged silently) still ended: the + /// request bound to it is settled, and the parent hears "(no reply)" + /// instead of that request sitting `executing` for good, counted as + /// "still queued" in every later notification. + #[tokio::test(flavor = "multi_thread", worker_threads = 3)] + async fn a_turn_that_says_nothing_still_settles_its_request() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().to_path_buf(); + let paths = ccteam_core::CcteamPaths { + root: tmp.path().join("home"), + projects_root: tmp.path().join("projects"), + }; + let (gateway, fake, parent, child, _first, identity, exec_a) = + scripted_child(&project_dir, paths).await; + // The first task's turn answers normally — it also settles whatever + // answer the dispatch left pending, so the silent turn below starts + // from nothing, as a live child's does. + play_turn( + &fake, + &identity, + &exec_a, + ccteam_harness::TurnOpening::Submitted, + "README ready", + ccteam_harness::TurnContinuation::Settled, + ) + .await; + let _ = await_notifications(&project_dir, &parent, 1).await; + + let exec_b = "exec-silent"; + let note_id = { + let mut gw = gateway.lock().await; + let id = gw + .accept_delegation_request( + &DelegationStoreClaim::for_test(&child), + &parent, + ccteam_harness::NotifyMode::Brief, + Some("no reply needed".into()), + TurnRouting::Inject, + None, + ) + .expect("the child accepts the note"); + gw.bind_delegation_request_for_test( + &DelegationStoreClaim::for_test(&child), + &id, + exec_b, + ); + id + }; + play_turn( + &fake, + &identity, + exec_b, + ccteam_harness::TurnOpening::Submitted, + "", + ccteam_harness::TurnContinuation::Settled, + ) + .await; + + let notes = await_notifications(&project_dir, &parent, 2).await; + let note = ¬es.last().expect("the silent turn's notification").user; + assert!(note.contains(¬e_id), "{note}"); + assert!(note.contains("(no reply)"), "{note}"); + assert!( + !note.contains("README ready"), + "never another turn's answer: {note}" + ); + let rows = gateway.lock().await.delegation_request_rows(&child, 10); + assert!( + rows.iter() + .all(|row| row["state"] == serde_json::json!("answered")), + "nothing is left executing: {rows:?}" + ); + } + /// The other half of the same contract: when claude DOES re-run the /// injected line, the replay turn opens within the grace and takes the /// work with it. The held boundary is dropped unspoken, and the replay @@ -36648,15 +38858,19 @@ mod tests { opening, }, )); - queue.push_back(( - identity.to_string(), - ThreadEvent::ItemCompleted { - item: ccteam_harness::ThreadItem { - id: format!("{turn_id}-msg"), - details: ThreadItemDetails::AgentMessage(text.to_string()), + // An empty `text` is a turn that said nothing at all: no answer + // item, as a vendor that acknowledges silently reports it. + if !text.is_empty() { + queue.push_back(( + identity.to_string(), + ThreadEvent::ItemCompleted { + item: ccteam_harness::ThreadItem { + id: format!("{turn_id}-msg"), + details: ThreadItemDetails::AgentMessage(text.to_string()), + }, }, - }, - )); + )); + } queue.push_back(( identity.to_string(), ThreadEvent::TurnCompleted { @@ -39170,6 +41384,7 @@ mod tests { }, since: chrono::Utc::now(), reason: "test", + ending: None, }, ); @@ -39370,6 +41585,91 @@ mod tests { ); } + /// F-GOV-233 — the silent turn's closing row is what a restart settles its + /// request by: a child that ended a turn WITHOUT any text while the daemon + /// was down still answers the request bound to it, with "(no reply)", + /// instead of leaving it `executing` for good. + #[tokio::test(flavor = "multi_thread", worker_threads = 3)] + async fn a_restart_settles_a_request_whose_turn_said_nothing() { + use ccteam_harness::execution::turns_mirror::{append_turn, read_all_turns, TurnRecord}; + let tmp = tempfile::TempDir::new().unwrap(); + let project_dir = tmp.path().to_path_buf(); + let gateway = delegation_gateway(&project_dir).await; + let parent_sid = { + let mut gw = gateway.lock().await; + gw.create_session_api( + "alpha".into(), + String::new(), + AgentVendor::Codex, + PermissionMode::Skip, + ) + .await + .unwrap() + .sid + }; + let child_sid = "s98"; + append_turn( + &project_dir, + child_sid, + &TurnRecord { + exec_turn_id: Some("exec-silent".into()), + turn_id: format!("{child_sid}-5"), + ts: chrono::Utc::now(), + vendor: "codex".into(), + role: String::new(), + user: String::new(), + assistant: String::new(), + usage: serde_json::Value::Null, + status: Some(ccteam_harness::TurnStatus { + model: None, + context: None, + turn: 2, + cost_usd: None, + tokens_total: None, + }), + tool_calls: vec![], + attachments: vec![], + outcome: None, + error_kind: None, + error: None, + conclusion: None, + continues_exec_turn: None, + }, + ) + .unwrap(); + Gateway::seed_delegation_store_for_test( + &project_dir, + &DelegationStoreClaim::for_test(child_sid), + &test_delegation_store( + &parent_sid, + ccteam_harness::NotifyMode::Brief, + Some("no reply needed".into()), + Some("exec-silent".into()), + ), + ) + .unwrap(); + + Gateway::reconcile_delegations(Arc::clone(&gateway)).await; + let mut note = None; + for _ in 0..200 { + note = read_all_turns(&project_dir, &parent_sid) + .unwrap_or_default() + .into_iter() + .find(|turn| turn.user.contains(" done · ")); + if note.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let note = note.expect("the silent turn's request is settled after a restart"); + assert!(note.user.contains("(no reply)"), "{}", note.user); + let store = ccteam_harness::read_delegation_requests(&project_dir, child_sid).unwrap(); + assert!( + store.outstanding().next().is_none(), + "nothing is left executing: {store:?}" + ); + } + /// v0.9.5 feedback fix — a restart reconcile FOLDS a chatty child's missed /// backlog (N un-notified turns) into ONE idle-marked notification carrying /// the latest text, instead of replaying the narration turn by turn; a @@ -39806,6 +42106,69 @@ mod tests { assert!(gateway.scheduled_items_for_sid(&sid).unwrap().is_empty()); } + /// Issue #210 follow-up: a scheduled row whose submit panics after the + /// line was delivered fires exactly once (it is failed, not retried), and + /// the rows due after it in the same batch still fire. + #[tokio::test] + async fn scheduled_fire_that_panics_fires_once_and_later_rows_still_fire() { + let tmp = tempfile::TempDir::new().unwrap(); + seed_role(tmp.path(), "reviewer"); + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let mut gateway = Gateway::new(fake.clone(), "alpha", tmp.path()); + let mut sids = Vec::new(); + for _ in 0..2 { + sids.push( + gateway + .create_session_api( + "alpha".into(), + "reviewer".into(), + AgentVendor::Claude, + PermissionMode::Skip, + ) + .await + .unwrap() + .sid, + ); + } + let mut ids = Vec::new(); + for (sid, text, ago) in [ + (&sids[0], "PANIC_AFTER_DELIVERY", 20), + (&sids[1], "second", 10), + ] { + let item = gateway + .create_scheduled_message( + sid, + text.into(), + chrono::Utc::now() + chrono::Duration::minutes(5), + "user:web-api".into(), + None, + ) + .unwrap(); + gateway + .scheduled_items + .get_mut(&item.id) + .unwrap() + .item + .send_at = chrono::Utc::now() - chrono::Duration::seconds(ago); + ids.push(item.id); + } + + gateway.fire_due_scheduled(chrono::Utc::now()).await; + gateway.fire_due_scheduled(chrono::Utc::now()).await; + + let submissions = fake.submissions.lock().await.clone(); + let sent = |needle: &str| submissions.iter().filter(|(_, t)| t == needle).count(); + assert_eq!(sent("PANIC_AFTER_DELIVERY"), 1, "fired exactly once"); + assert_eq!(sent("second"), 1, "the later row still fired"); + let poison = &gateway.scheduled_items[&ids[0]].item; + assert_eq!(poison.status, crate::scheduled::ScheduledStatus::Failed); + assert_eq!(poison.fail_reason.as_deref(), Some("panicked while firing")); + assert!( + !gateway.scheduled_items.contains_key(&ids[1]), + "second completed" + ); + } + #[tokio::test] async fn scheduled_id_counter_survives_cancel_and_daemon_restart() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/ccteam-im/src/hitl.rs b/crates/ccteam-im/src/hitl.rs index 9361fb08c..a29b7940b 100644 --- a/crates/ccteam-im/src/hitl.rs +++ b/crates/ccteam-im/src/hitl.rs @@ -407,7 +407,7 @@ async fn ask_external_choice( options: message_options, status: None, sid: Some(sid.to_string()), - slug: None, + slug: Some(ctx.slug.clone()), }) .is_err() { @@ -967,6 +967,7 @@ mod tests { thread_ts: None, role: "alice".to_string(), progress_path: None, + slug: "alpha".to_string(), } } @@ -1092,6 +1093,8 @@ mod tests { assert_eq!(answer, PermissionAnswer::Timeout); let evt = rx.recv().await.expect("prompt event sent"); assert_eq!(evt.thread_ts.as_deref(), Some("1700000000.000100")); + // Attributed to its project, so a tenant's fleet stream keeps it. + assert_eq!(evt.slug.as_deref(), Some("alpha")); } /// A closed sink (event consumer gone) fails safe to `Unavailable` diff --git a/crates/ccteam-im/src/lib.rs b/crates/ccteam-im/src/lib.rs index 0bc9a9240..5503fed4f 100644 --- a/crates/ccteam-im/src/lib.rs +++ b/crates/ccteam-im/src/lib.rs @@ -74,6 +74,7 @@ pub mod router; pub mod sanitize; pub mod scheduled; mod session_catalog; +pub mod supervise; pub mod three_layer_sec; pub mod transport; /// Wire shape for vendor-account usage, shared by the MCP `status` body and diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index 5b706256c..9af2d6502 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -2445,8 +2445,7 @@ async fn pre_agent_policy_gate( children: facts.children, delegated: facts.delegated, cost_24h_usd: facts.projection.as_ref().and_then(|projection| { - (!facts.slug.is_empty()) - .then(|| projection.project_snapshot(&facts.slug).cost.cost_24h_usd) + (!facts.slug.is_empty()).then(|| projection.cost_summary(&facts.slug).cost_24h_usd) }), }, } @@ -4065,6 +4064,10 @@ async fn finish_dispatch_wait( let answer_turn_id = record .map(|turn| turn.turn_id.as_str()) .unwrap_or(&receipt.turn_id); + let answered: Vec> = request_id + .map(|id| crate::delegation::AnsweredRequest { id, title: None }) + .into_iter() + .collect(); DelegationSummary { sid: child_sid, vendor: &vendor, @@ -4085,11 +4088,10 @@ async fn finish_dispatch_wait( .map(|turn| turn.assistant.as_str()) .unwrap_or_default(), conclusion: record.and_then(|turn| turn.conclusion.as_deref()), - request_id, // The header of an inline result names the task the caller is holding; // it asked for this one by id, so the label adds nothing and the queue // count belongs to the push path. - title: None, + requests: &answered, remaining_queued: 0, } .inline_result() @@ -4785,10 +4787,8 @@ async fn read_wait_for_turn_boundary( /// The turn_id of the newest row in a session's durable mirror. fn last_mirrored_turn_id(resolved: &crate::gateway::SessionResolve) -> Option { - ccteam_harness::execution::turns_mirror::read_all_turns(&resolved.project_dir, &resolved.sid) - .ok()? - .pop() - .map(|turn| turn.turn_id) + ccteam_harness::execution::turns_mirror::digest(&resolved.project_dir, &resolved.sid)? + .last_turn_id } /// Wait (briefly, bounded) for the notifier to record which of `waiting_on` diff --git a/crates/ccteam-im/src/mcp/protocol.rs b/crates/ccteam-im/src/mcp/protocol.rs index 52c77d56c..a0f8d0fb7 100644 --- a/crates/ccteam-im/src/mcp/protocol.rs +++ b/crates/ccteam-im/src/mcp/protocol.rs @@ -406,7 +406,7 @@ pub(crate) fn status_project_rows( .iter() .filter(|project| visible(&project.state)) .map(|p| { - let cost = projection.project_snapshot(&p.state.slug).cost; + let cost = projection.cost_summary(&p.state.slug); json!({ "slug": p.state.slug, "cost_24h_usd": cost.cost_24h_usd, diff --git a/crates/ccteam-im/src/mcp/vendor_panel.rs b/crates/ccteam-im/src/mcp/vendor_panel.rs index 5e8ebb25e..07313465e 100644 --- a/crates/ccteam-im/src/mcp/vendor_panel.rs +++ b/crates/ccteam-im/src/mcp/vendor_panel.rs @@ -685,8 +685,7 @@ pub(crate) fn gather_status_panel(paths: &CcteamPaths, slug: Option<&str>) -> St let cost_24h_usd = slug .map(|slug| { crate::progress_projection::ProgressProjection::new(paths.clone()) - .project_snapshot(slug) - .cost + .cost_summary(slug) .cost_24h_usd }) .unwrap_or(0.0); @@ -720,8 +719,7 @@ fn build_project_panel(paths: &CcteamPaths, slug: &str) -> (PanelHeader, Vec Result Result { + let path = pending_path(project_dir, sid); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(error).with_context(|| format!("read pending_turns {}", path.display())) + } + }; + let mut lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect(); + let Some(index) = lines.iter().rposition(|line| { + serde_json::from_str::(line).is_ok_and(|turn| turn.text == text) + }) else { + return Ok(false); + }; + lines.remove(index); + if lines.is_empty() { + std::fs::remove_file(&path) + .with_context(|| format!("retire pending input {}", path.display()))?; + return Ok(true); + } + let tmp = path.with_extension("jsonl.tmp"); + std::fs::write(&tmp, lines.join("\n") + "\n") + .with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?; + Ok(true) +} + +/// Take every DELEGATED line (one carrying a request id) out of the queue, +/// keeping the rest in order; returns the request ids taken. Used when the +/// session the lines wait for could not be brought back and their parents +/// were told so: a parent that resends must not get the old line run too. +pub fn withdraw_delegated_turns(project_dir: &Path, sid: &str) -> Result> { + let path = pending_path(project_dir, sid); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error).with_context(|| format!("read pending_turns {}", path.display())) + } + }; + let mut taken = Vec::new(); + let mut kept: Vec<&str> = Vec::new(); + for line in raw.lines().filter(|l| !l.trim().is_empty()) { + match serde_json::from_str::(line) { + Ok(PendingTurn { + request_id: Some(id), + .. + }) => taken.push(id), + _ => kept.push(line), + } + } + if taken.is_empty() { + return Ok(taken); + } + if kept.is_empty() { + std::fs::remove_file(&path) + .with_context(|| format!("retire pending input {}", path.display()))?; + } else { + let tmp = path.with_extension("jsonl.tmp"); + std::fs::write(&tmp, kept.join("\n") + "\n") + .with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?; + } + Ok(taken) +} + /// Count pending turns without draining. pub fn pending_turn_count(project_dir: &Path, sid: &str) -> usize { let path = pending_path(project_dir, sid); diff --git a/crates/ccteam-im/src/progress.rs b/crates/ccteam-im/src/progress.rs index 95889a063..efc3d6397 100644 --- a/crates/ccteam-im/src/progress.rs +++ b/crates/ccteam-im/src/progress.rs @@ -21,6 +21,7 @@ use std::collections::HashSet; +use ccteam_harness::execution::turns_mirror::StepRecord; use ccteam_harness::{ThreadEvent, ThreadItemDetails}; use serde_json::Value; @@ -155,12 +156,13 @@ pub(crate) fn activity_for(evt: &ThreadEvent) -> Option { _ => return None, }; match &item.details { - ThreadItemDetails::ToolCall { name, args } => Some(SessionActivity { + ThreadItemDetails::ToolCall { name, args, .. } => Some(SessionActivity { kind: ActivityKind::ToolCall, name: name.clone(), summary: format!("{name}({})", preview_args(args)), status, item_id: item.id.clone(), + step_id: item.id.clone(), }), ThreadItemDetails::CommandExecution { cmd, .. } => Some(SessionActivity { kind: ActivityKind::CommandExec, @@ -168,6 +170,7 @@ pub(crate) fn activity_for(evt: &ThreadEvent) -> Option { summary: format!("$ {}", truncate_for_preview(cmd)), status, item_id: item.id.clone(), + step_id: item.id.clone(), }), ThreadItemDetails::FileChange { path, kind } => Some(SessionActivity { kind: ActivityKind::FileChange, @@ -175,6 +178,7 @@ pub(crate) fn activity_for(evt: &ThreadEvent) -> Option { summary: format!("{kind} {}", path.display()), status, item_id: item.id.clone(), + step_id: item.id.clone(), }), ThreadItemDetails::WebSearch { query } => Some(SessionActivity { kind: ActivityKind::WebSearch, @@ -182,6 +186,7 @@ pub(crate) fn activity_for(evt: &ThreadEvent) -> Option { summary: truncate_for_preview(query), status, item_id: item.id.clone(), + step_id: item.id.clone(), }), ThreadItemDetails::Reasoning(text) => Some(SessionActivity { kind: ActivityKind::Thinking, @@ -189,12 +194,94 @@ pub(crate) fn activity_for(evt: &ThreadEvent) -> Option { summary: truncate_for_preview(text), status: ActivityStatus::Update, item_id: item.id.clone(), + step_id: item.id.clone(), }), // The answer + errors are not activity (the pump routes them). ThreadItemDetails::AgentMessage(_) | ThreadItemDetails::Error(_) => None, } } +/// A step's input as the inspector shows it: a tool's arguments as pretty +/// JSON (a bare string as is), a command line, a changed path, a query. +/// `None` for reasoning and for a call that reported no input. +pub(crate) fn step_input(details: &ThreadItemDetails) -> Option { + match details { + ThreadItemDetails::ToolCall { args, .. } => match args { + Value::Null => None, + Value::String(s) => Some(s.clone()), + other => serde_json::to_string_pretty(other).ok(), + }, + ThreadItemDetails::CommandExecution { cmd, .. } => Some(cmd.clone()), + ThreadItemDetails::FileChange { path, kind } => Some(format!("{kind} {}", path.display())), + ThreadItemDetails::WebSearch { query } => Some(query.clone()), + ThreadItemDetails::Reasoning(_) + | ThreadItemDetails::AgentMessage(_) + | ThreadItemDetails::Error(_) => None, + } +} + +/// The step this event FINISHES, as the record `chat//steps.jsonl` keeps +/// for the web inspector: a completed tool / command / edit / search with its +/// input and output, or a reasoning block with its text. `started_input` is +/// the input the step's start carried, for a channel whose completion does not +/// repeat it. `None` for a start, an answer, an error, an empty reasoning +/// pulse, or a non-item event. +pub(crate) fn finished_step( + evt: &ThreadEvent, + step_id: &str, + started_input: Option<&str>, +) -> Option { + let record = |kind: &str, name: &str, input: Option, output: Option| { + Some(StepRecord { + step_id: step_id.to_string(), + ts: chrono::Utc::now(), + kind: kind.to_string(), + name: name.to_string(), + input, + output, + truncated: false, + }) + }; + match evt { + ThreadEvent::ItemStarted { item } + | ThreadEvent::ItemUpdated { item } + | ThreadEvent::ItemCompleted { item } => { + if let ThreadItemDetails::Reasoning(text) = &item.details { + return if text.trim().is_empty() { + None + } else { + record("thinking", "", None, Some(text.clone())) + }; + } + let ThreadEvent::ItemCompleted { item } = evt else { + return None; + }; + // The call's own input as it started wins: a completion may not + // repeat it (ACP) or may report a different shape (pi completes a + // tool call as a command named after the tool). + let input = started_input + .map(str::to_string) + .or_else(|| step_input(&item.details)); + match &item.details { + ThreadItemDetails::ToolCall { name, output, .. } => { + record("tool_call", name, input, output.clone()) + } + ThreadItemDetails::CommandExecution { output, .. } => { + record("command_exec", "bash", input, output.clone()) + } + ThreadItemDetails::FileChange { kind, .. } => { + record("file_change", kind, input, None) + } + ThreadItemDetails::WebSearch { .. } => record("web_search", "web", input, None), + ThreadItemDetails::Reasoning(_) + | ThreadItemDetails::AgentMessage(_) + | ThreadItemDetails::Error(_) => None, + } + } + _ => None, + } +} + /// One folded count bucket, kept in first-seen order for stable render. struct Bucket { emoji: String, @@ -303,7 +390,7 @@ impl ProgressFold { match evt { ThreadEvent::ItemStarted { item } | ThreadEvent::ItemCompleted { item } => { match &item.details { - ThreadItemDetails::ToolCall { name, args } => { + ThreadItemDetails::ToolCall { name, args, .. } => { let preview = format!("{name}({})", preview_args(args)); self.count_tool(&item.id, tool_category(name), name, preview) } @@ -425,6 +512,7 @@ mod tests { details: ThreadItemDetails::ToolCall { name: name.to_string(), args, + output: None, }, }, } @@ -437,6 +525,7 @@ mod tests { details: ThreadItemDetails::ToolCall { name: name.to_string(), args: json!("result"), + output: None, }, }, } @@ -517,6 +606,7 @@ mod tests { details: ThreadItemDetails::CommandExecution { cmd: "cargo build".into(), status: "ok".into(), + output: None, }, }, }); @@ -600,6 +690,7 @@ mod tests { details: ThreadItemDetails::CommandExecution { cmd: "cargo build".into(), status: "ok".into(), + output: None, }, }, }) diff --git a/crates/ccteam-im/src/progress_projection.rs b/crates/ccteam-im/src/progress_projection.rs index 623d81aef..52e3ac89d 100644 --- a/crates/ccteam-im/src/progress_projection.rs +++ b/crates/ccteam-im/src/progress_projection.rs @@ -362,6 +362,17 @@ impl ProgressProjection { snapshot(&state, (self.clock)()) } + /// Query the project's cost aggregate alone. Budget gates and fleet + /// headers ask for this on every `agent` call; a full + /// [`Self::project_snapshot`] deep-copies every session the journal ever + /// named (issue #213) just to read one number. + pub fn cost_summary(&self, slug: &str) -> CostSummary { + if let Err(error) = self.catch_up_for_query(slug) { + tracing::warn!(slug, %error, "progress projection cost catch-up failed"); + } + cost_summary(&read_lock(&self.slug(slug).state), (self.clock)()) + } + /// Query one session accumulator without cloning the project's tail or /// sibling session map. pub fn session_snapshot(&self, slug: &str, sid: &str) -> Option { @@ -677,7 +688,7 @@ fn fold_delegation(state: &mut SlugState, kind: &str, event: &Value, now: DateTi } } -fn snapshot(state: &SlugState, now: DateTime) -> ProjectProjectionSnapshot { +fn cost_summary(state: &SlugState, now: DateTime) -> CostSummary { let oldest = minute(now).saturating_sub(MINUTES_24H); let mut cost = CostSummary { cost_total_usd: state.lifetime_cost, @@ -691,6 +702,12 @@ fn snapshot(state: &SlugState, now: DateTime) -> ProjectProjectionSnapshot *cost.cost_24h_by_vendor.entry(vendor.clone()).or_insert(0.0) += value; } } + cost +} + +fn snapshot(state: &SlugState, now: DateTime) -> ProjectProjectionSnapshot { + let oldest = minute(now).saturating_sub(MINUTES_24H); + let cost = cost_summary(state, now); let mut delegations = state.delegations; for bucket in state .minute_delegations diff --git a/crates/ccteam-im/src/supervise.rs b/crates/ccteam-im/src/supervise.rs new file mode 100644 index 000000000..f8fb4a744 --- /dev/null +++ b/crates/ccteam-im/src/supervise.rs @@ -0,0 +1,188 @@ +//! Panic containment for the daemon's long-lived, single-instance tasks. +//! +//! A task of which there is exactly one — the gateway event consumer, the +//! inbound consumer, the delegation notifier, the body watcher, the +//! schedulers and sweepers — fails GLOBALLY when it dies: every session +//! loses what it does, permanently, and a panic message can be lost with it +//! (issue #210: the event consumer died while the log disk was full and no +//! reply was delivered again). Two shapes keep such a task alive: +//! +//! - [`contain_panic`]: a queue consumer runs each item's work through it, so +//! one bad item costs that item and the loop keeps its state and receiver. +//! - [`supervise_loop`]: a loop with no state worth keeping across a panic +//! (a periodic tick) is restarted after a short backoff. + +use std::future::Future; +use std::time::Duration; + +use futures::FutureExt; + +/// How a panicked loop is restarted: after `initial`, doubling for each +/// panic in a row up to `max`, so a loop that panics on every pass neither +/// spins nor floods the log (#210 began with a full log disk). A run that +/// lasted `healthy` before panicking starts the doubling over. +#[derive(Debug, Clone, Copy)] +struct Backoff { + initial: Duration, + max: Duration, + healthy: Duration, +} + +const RESTART_BACKOFF: Backoff = Backoff { + initial: Duration::from_secs(1), + max: Duration::from_secs(60), + healthy: Duration::from_secs(300), +}; + +/// Run one item of `task`'s work; a panic is logged at error (with `item` +/// naming what was being processed) and swallowed. `None` = it panicked. +pub async fn contain_panic(task: &str, item: &str, work: F) -> Option { + match std::panic::AssertUnwindSafe(work).catch_unwind().await { + Ok(output) => Some(output), + Err(panic) => { + tracing::error!( + task, + item, + panic = panic_message(panic.as_ref()), + "ccteam-im: a long-lived task panicked on one item; item dropped, task continues" + ); + None + } + } +} + +/// Run the loop `make` builds until it returns, restarting it after a panic. +/// A loop that returns on its own is logged at error: every caller's loop is +/// meant to live as long as the daemon, and ends only by being aborted. +pub async fn supervise_loop(task: &'static str, make: F) +where + F: FnMut() -> Fut, + Fut: Future, +{ + supervise_loop_with_backoff(task, RESTART_BACKOFF, make).await; +} + +async fn supervise_loop_with_backoff(task: &'static str, policy: Backoff, mut make: F) +where + F: FnMut() -> Fut, + Fut: Future, +{ + let mut backoff = policy.initial; + loop { + let started = tokio::time::Instant::now(); + match std::panic::AssertUnwindSafe(make()).catch_unwind().await { + Ok(()) => { + tracing::error!(task, "ccteam-im: a long-lived task exited"); + return; + } + Err(panic) => { + if started.elapsed() >= policy.healthy { + backoff = policy.initial; + } + tracing::error!( + task, + panic = panic_message(panic.as_ref()), + restart_in_ms = backoff.as_millis() as u64, + "ccteam-im: a long-lived task panicked; restarting it" + ); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(policy.max); + } + } + } +} + +/// The text of a panic payload (`panic!` with a literal or a formatted +/// message), for the log line. +pub fn panic_message(panic: &(dyn std::any::Any + Send)) -> &str { + panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + #[tokio::test] + async fn contain_panic_returns_the_output_or_none() { + assert_eq!(contain_panic("t", "i", async { 7 }).await, Some(7)); + let panicked: Option<()> = contain_panic("t", "i", async { panic!("boom") }).await; + assert_eq!(panicked, None); + } + + const FAST: Backoff = Backoff { + initial: Duration::from_millis(1), + max: Duration::from_millis(1), + healthy: Duration::from_secs(300), + }; + + /// A loop that panics is run again (here: twice), and the supervisor + /// returns once the loop itself returns. + #[tokio::test] + async fn supervise_loop_restarts_a_panicked_loop() { + let runs = Arc::new(AtomicUsize::new(0)); + let counted = Arc::clone(&runs); + supervise_loop_with_backoff("t", FAST, move || { + let counted = Arc::clone(&counted); + async move { + if counted.fetch_add(1, Ordering::SeqCst) < 2 { + panic!("loop pass {}", counted.load(Ordering::SeqCst)); + } + } + }) + .await; + assert_eq!(runs.load(Ordering::SeqCst), 3); + } + + /// The pauses between restarts of the production policy: doubling from + /// 1s, capped at 60s, and back to 1s after a run that stayed up for the + /// healthy window. Paused clock: the sleeps cost no wall time. + #[tokio::test(start_paused = true)] + async fn restart_backoff_doubles_to_a_cap_and_resets_after_a_healthy_run() { + let starts = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = Arc::clone(&starts); + let healthy = RESTART_BACKOFF.healthy; + supervise_loop_with_backoff("t", RESTART_BACKOFF, move || { + let recorded = Arc::clone(&recorded); + async move { + let run = { + let mut starts = recorded.lock().unwrap(); + starts.push(tokio::time::Instant::now()); + starts.len() + }; + match run { + // Run 9 stays up for the healthy window, then panics. + 9 => { + tokio::time::sleep(healthy).await; + panic!("late") + } + 11 => {} + _ => panic!("pass {run}"), + } + } + }) + .await; + let starts = starts.lock().unwrap(); + let gaps: Vec = starts + .windows(2) + .map(|pair| (pair[1] - pair[0]).as_secs()) + .collect(); + let up = healthy.as_secs(); + assert_eq!(gaps, vec![1, 2, 4, 8, 16, 32, 60, 60, up + 1, 2]); + } + + #[test] + fn panic_message_reads_str_and_string_payloads() { + let lit: Box = Box::new("lit"); + let owned: Box = Box::new(String::from("owned")); + let other: Box = Box::new(3u8); + assert_eq!(panic_message(lit.as_ref()), "lit"); + assert_eq!(panic_message(owned.as_ref()), "owned"); + assert_eq!(panic_message(other.as_ref()), ""); + } +} diff --git a/crates/ccteam-im/tests/im_progress_test.rs b/crates/ccteam-im/tests/im_progress_test.rs index 31d84f532..d83a437cd 100644 --- a/crates/ccteam-im/tests/im_progress_test.rs +++ b/crates/ccteam-im/tests/im_progress_test.rs @@ -54,6 +54,9 @@ fn isolate_home() -> TempDir { #[derive(Debug)] struct ScriptedAdapter { script: Vec, + /// Events the stream yields when the daemon detaches the session at + /// shutdown — a busy session still talking while the daemon stops. + on_detach: Vec, events: Arc>>, starts: AtomicUsize, submits: AtomicUsize, @@ -63,6 +66,7 @@ impl ScriptedAdapter { fn new(script: Vec) -> Self { Self { script, + on_detach: Vec::new(), events: Arc::new(tokio::sync::Mutex::new(VecDeque::new())), starts: AtomicUsize::new(0), submits: AtomicUsize::new(0), @@ -153,6 +157,21 @@ impl HarnessAdapter for ScriptedAdapter { Ok(()) } + async fn detach_thread( + &self, + _h: &ThreadHandle, + ) -> Result { + if !self.on_detach.is_empty() { + self.events + .lock() + .await + .extend(self.on_detach.iter().cloned()); + // Give the pump time to read them before the daemon aborts it. + tokio::time::sleep(Duration::from_millis(300)).await; + } + Ok(ccteam_harness::DetachOutcome::NotApplicable) + } + async fn handle_directive( &self, _h: &ThreadHandle, @@ -175,6 +194,7 @@ fn tool_started(id: &str, name: &str, args: serde_json::Value) -> ThreadEvent { details: ThreadItemDetails::ToolCall { name: name.into(), args, + output: None, }, }, } @@ -187,6 +207,7 @@ fn tool_completed(id: &str, name: &str) -> ThreadEvent { details: ThreadItemDetails::ToolCall { name: name.into(), args: serde_json::json!("result"), + output: None, }, }, } @@ -226,6 +247,10 @@ fn is_status(text: &str) -> bool { /// Run the daemon over one `/new` + one trigger message with `script` as /// the turn's events. Returns the mock channel for outbox/edits asserts. async fn run_scripted(script: Vec) -> Arc { + run_adapter(ScriptedAdapter::new(script)).await +} + +async fn run_adapter(adapter: ScriptedAdapter) -> Arc { let home = isolate_home(); let projects_root = home.path().join("projects"); std::fs::create_dir_all(&projects_root).unwrap(); @@ -253,7 +278,7 @@ async fn run_scripted(script: Vec) -> Arc { mock.clone() as Arc, ); - let adapter = Arc::new(ScriptedAdapter::new(script)); + let adapter = Arc::new(adapter); let adapter_factory: AdapterFactory = { let cloned = adapter.clone(); Arc::new(move |_, _| cloned.clone() as Arc) @@ -407,3 +432,74 @@ async fn codex_streaming_delta_not_sent_as_answer() { std::env::remove_var("CCTEAM_IM_PROGRESS"); std::env::remove_var("CCTEAM_IM_PROGRESS_THROTTLE_MS"); } + +/// Every log line the process emits from here on (installed once, global: +/// the daemon's tasks run on runtime worker threads). +fn captured_logs() -> Arc>> { + use std::sync::{Mutex, OnceLock}; + static BUF: OnceLock>>> = OnceLock::new(); + BUF.get_or_init(|| { + #[derive(Clone)] + struct Buf(Arc>>); + impl std::io::Write for Buf { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let buf = Arc::new(Mutex::new(Vec::new())); + let writer = Buf(Arc::clone(&buf)); + let subscriber = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new("ccteam_im=debug")) + .with_ansi(false) + .with_writer(move || writer.clone()) + .finish(); + tracing::subscriber::set_global_default(subscriber).expect("one global subscriber"); + buf + }) + .clone() +} + +/// Issue #210 follow-up: a session still emitting events while the daemon +/// stops meets the already-aborted delivery consumer. That is the stop, not +/// a delivery outage — a clean stop logs no ERROR for it. +#[allow(clippy::await_holding_lock)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clean_stop_with_a_busy_session_logs_no_delivery_error() { + let _g = env_lock(); + std::env::set_var("CCTEAM_IM_PROGRESS", "on"); + std::env::set_var("CCTEAM_IM_PROGRESS_THROTTLE_MS", "0"); + let logs = captured_logs(); + + let mut adapter = ScriptedAdapter::new(vec![tool_started( + "t1", + "Bash", + serde_json::json!({"command": "sleep 600"}), + )]); + adapter.on_detach = vec![ + tool_completed("t1", "Bash"), + tool_started("t2", "Read", serde_json::json!({"file_path": "/a"})), + ]; + let _mock = run_adapter(adapter).await; + + let text = + String::from_utf8_lossy(&logs.lock().unwrap_or_else(|e| e.into_inner())).into_owned(); + let errors: Vec<&str> = text + .lines() + .filter(|line| line.contains("ERROR") && line.contains("consumer is gone")) + .collect(); + assert!( + errors.is_empty(), + "a clean stop logged delivery errors: {errors:#?}" + ); + assert!( + text.contains("delivery closed for shutdown"), + "the busy session's pump must have met the closed sink during the stop:\n{text}" + ); +} diff --git a/crates/ccteam-im/tests/inbound_wiring_test.rs b/crates/ccteam-im/tests/inbound_wiring_test.rs index 0df567cbc..5664a5db1 100644 --- a/crates/ccteam-im/tests/inbound_wiring_test.rs +++ b/crates/ccteam-im/tests/inbound_wiring_test.rs @@ -1282,6 +1282,11 @@ async fn daemon_replays_queued_durable_outbound_idempotently_once() { first_outbox[0].content, "queued exactly once across restarts" ); + let sent_count = read_durable_outbound_rows() + .iter() + .filter(|row| row["id"] == "replay-idem-1" && row["state"] == "sent") + .count(); + assert_eq!(sent_count, 1, "replay must append exactly one sent row"); let second_mock = Arc::new(MockChannel::new()); let mut second_channels: ChannelMap = std::collections::HashMap::new(); @@ -1315,12 +1320,14 @@ async fn daemon_replays_queued_durable_outbound_idempotently_once() { second_mock.outbox().await.is_empty(), "a durable row whose latest state is sent must not replay again" ); - let rows = read_durable_outbound_rows(); - let sent_count = rows - .iter() - .filter(|row| row["id"] == "replay-idem-1" && row["state"] == "sent") - .count(); - assert_eq!(sent_count, 1, "replay must append exactly one sent row"); + // The second start compacted the settled row away (issue #213): nothing + // is owed for the id, so nothing of it is left to replay. + assert!( + read_durable_outbound_rows() + .iter() + .all(|row| row["id"] != "replay-idem-1"), + "a delivered row is compacted out at the next start" + ); } #[allow(clippy::await_holding_lock)] @@ -2161,6 +2168,9 @@ async fn daemon_restart_preserves_ws_gateway_session() { let _ = first_stop_tx.send(()); first_daemon.await.unwrap(); assert_eq!(adapter.starts.load(Ordering::SeqCst), 1); + // Read before the restart: the second start compacts delivered rows + // out of the ledger (issue #213). + let mut sent_contents = sent_durable_contents(); let second_ws = Arc::new(WsChannel::bind_on_listen(ws_addr)); let (second_stop_tx, second_daemon) = @@ -2197,12 +2207,7 @@ async fn daemon_restart_preserves_ws_gateway_session() { &["before restart".to_string(), "after restart".to_string()] ); - let rows = read_durable_outbound_rows(); - let sent_contents: Vec = rows - .iter() - .filter(|row| row["state"] == "sent") - .filter_map(|row| row["message"]["content"].as_str().map(str::to_string)) - .collect(); + sent_contents.extend(sent_durable_contents()); // Cross-check against the ACTUAL delivered content observed above // (already prefix-verified) rather than a hand-computed guess, so the // optional auto-title tag can't desync this from the two assertions @@ -3222,6 +3227,15 @@ async fn daemon_delivers_gateway_event_attachment_to_channel() { ); } +/// Contents of the ledger's `sent` rows, in ledger order. +fn sent_durable_contents() -> Vec { + read_durable_outbound_rows() + .iter() + .filter(|row| row["state"] == "sent") + .filter_map(|row| row["message"]["content"].as_str().map(str::to_string)) + .collect() +} + fn read_durable_outbound_rows() -> Vec { let path = dirs::home_dir() .unwrap() @@ -3243,8 +3257,15 @@ fn write_durable_outbound_row(id: &str, channel: &str, state: &str, content: &st .join("im") .join("outbound.jsonl"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + // A current timestamp: replay gives up on a row queued past its age + // bound (issue #213), and this row stands for one queued just before a + // restart. + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; let row = serde_json::json!({ - "ts_ms": 1, + "ts_ms": now_ms, "id": id, "inbound_id": format!("{id}-in"), "channel": channel, diff --git a/crates/ccteam-web/Cargo.toml b/crates/ccteam-web/Cargo.toml index 099f99321..cc58429a4 100644 --- a/crates/ccteam-web/Cargo.toml +++ b/crates/ccteam-web/Cargo.toml @@ -114,7 +114,12 @@ libc = "0.2" # V0.3.2 F53 — embed the vite-built SPA bundle (`web/dist/`) into the # binary so `ccteam web` ships the React UI without a separate static # files step. Asset routes live in `routes/assets.rs`. -rust-embed = "8" +# `debug-embed`: without it a debug build does NOT embed — it reads +# `/web/dist/` from disk on every request, so a debug binary +# copied out of a worktree serves the SPA only until that worktree is removed, +# then every `/app/*` 500s (#215). Embedding in every profile keeps any built +# binary self-contained. +rust-embed = { version = "8", features = ["debug-embed"] } mime_guess = "2" # v0.8.7 W5 (Item E) — OpenAPI auto-docs. `utoipa` derive + diff --git a/crates/ccteam-web/src/lib.rs b/crates/ccteam-web/src/lib.rs index 84926103f..6a7de6488 100644 --- a/crates/ccteam-web/src/lib.rs +++ b/crates/ccteam-web/src/lib.rs @@ -101,6 +101,12 @@ pub struct ServeOpts { /// `$CCTEAM_HOME` but is not the daemon, and letting it publish would /// point every `daemon status` at the wrong server. pub publish_endpoint: bool, + /// Fired (`notify_one`) once every listener is bound and the bound + /// addresses are published (`run/mcp-url`, `run/daemon-endpoint.json`). + /// The daemon body holds its `mcp.sock` — the socket `ccteam start` + /// probes for readiness — until this fires, so "started" never means + /// "the advertised `/mcp` URL still refuses connections". + pub bound: Option>, } impl Default for ServeOpts { @@ -123,6 +129,7 @@ impl Default for ServeOpts { no_auth_grace_secs: Some(5), build: None, publish_endpoint: false, + bound: None, } } } @@ -370,6 +377,9 @@ where tracing::warn!(error = %err, "could not re-record the daemon MCP URL from the bound address"); } } + if let Some(bound) = &opts.bound { + bound.notify_one(); + } let state = build_state(paths, auth_state) .with_dsh_web(std::sync::Arc::clone(&supervisor)) .with_health_identity(health); @@ -522,6 +532,7 @@ mod tests { no_auth_grace_secs: Some(5), build: Some("abc1234".to_string()), publish_endpoint: false, + bound: None, }; assert_eq!(opts.build.as_deref(), Some("abc1234")); assert!(!opts.no_auth); diff --git a/crates/ccteam-web/src/ring.rs b/crates/ccteam-web/src/ring.rs index 6bba3ccb3..812828ec9 100644 --- a/crates/ccteam-web/src/ring.rs +++ b/crates/ccteam-web/src/ring.rs @@ -32,7 +32,7 @@ //! `PendingInteractions`, not from ring contents. use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, PoisonError}; use ccteam_im::gateway::{Gateway, GatewayEvent, GatewayEventKind}; use tokio::sync::broadcast; @@ -41,6 +41,13 @@ use tokio::sync::broadcast; /// without growing unbounded memory for a long-lived chatty session. pub(crate) const RING_CAPACITY: usize = 64; +/// Sessions whose backlog is kept. A daemon outlives thousands of sessions, +/// and every one that ever emitted an event used to keep its 64 frames +/// (answers, progress, attachments) for the life of the process (issue #213). +/// Past this, the backlog of the session that recorded least recently is +/// dropped — its `next_seq` is kept, so seqs still never restart. +const MAX_BUFFERED_SESSIONS: usize = 128; + /// Fan-out channel capacity for the ring's live tap. Generous relative to /// how often ANY one session emits events, so an idle-ish per-connection /// SSE consumer is very unlikely to `Lagged` (the existing @@ -59,21 +66,32 @@ pub(crate) struct RingEntry { struct RingState { next_seq: u64, buf: VecDeque, + /// [`SessionEventRing`]'s record clock at this ring's last record. + last_record: u64, } /// Shared per-sid replay ring + live tap. Cheap to clone via `Arc`; the /// internal bookkeeping mutex is a plain `std::sync::Mutex` since every /// operation is O(capacity) and never holds across an `.await`. pub(crate) struct SessionEventRing { - rings: Mutex>, + rings: Mutex, tap: broadcast::Sender, } +#[derive(Default)] +struct Rings { + by_sid: HashMap, + /// Monotonic record counter — the LRU clock for [`MAX_BUFFERED_SESSIONS`]. + clock: u64, + /// How many `by_sid` entries hold a non-empty `buf`. + buffered: usize, +} + impl SessionEventRing { pub(crate) fn new() -> Self { let (tap, _rx) = broadcast::channel(TAP_CAPACITY); Self { - rings: Mutex::new(HashMap::new()), + rings: Mutex::new(Rings::default()), tap, } } @@ -93,10 +111,14 @@ impl SessionEventRing { /// itself is still the durable side of this record. pub(crate) fn record(&self, sid: &str, event: GatewayEvent) -> u64 { let seq = { - let mut guard = self.rings.lock().unwrap(); - let state = guard.entry(sid.to_string()).or_default(); + let mut guard = self.rings.lock().unwrap_or_else(PoisonError::into_inner); + let rings = &mut *guard; + rings.clock += 1; + let state = rings.by_sid.entry(sid.to_string()).or_default(); state.next_seq += 1; + state.last_record = rings.clock; let seq = state.next_seq; + let newly_buffered = state.buf.is_empty(); state.buf.push_back(RingEntry { seq, event: event.clone(), @@ -104,6 +126,21 @@ impl SessionEventRing { while state.buf.len() > RING_CAPACITY { state.buf.pop_front(); } + if newly_buffered { + rings.buffered += 1; + if rings.buffered > MAX_BUFFERED_SESSIONS { + if let Some(oldest) = rings + .by_sid + .values_mut() + .filter(|state| !state.buf.is_empty()) + .min_by_key(|state| state.last_record) + { + // Frees the frames; `next_seq` stays (8 bytes/sid). + oldest.buf = VecDeque::new(); + rings.buffered -= 1; + } + } + } seq }; let _ = self.tap.send(RingEntry { seq, event }); @@ -115,8 +152,9 @@ impl SessionEventRing { /// (the gap outran the ring's capacity), this simply returns everything /// left — the closest available approximation of "what you missed". pub(crate) fn replay_since(&self, sid: &str, since: u64) -> Vec { - let guard = self.rings.lock().unwrap(); + let guard = self.rings.lock().unwrap_or_else(PoisonError::into_inner); guard + .by_sid .get(sid) .map(|s| s.buf.iter().filter(|e| e.seq > since).cloned().collect()) .unwrap_or_default() @@ -169,7 +207,7 @@ impl GlobalEventRing { /// Returns the assigned seq. pub(crate) fn record(&self, event: GatewayEvent) -> u64 { let seq = { - let mut guard = self.state.lock().unwrap(); + let mut guard = self.state.lock().unwrap_or_else(PoisonError::into_inner); guard.next_seq += 1; let seq = guard.next_seq; guard.buf.push_back(RingEntry { @@ -188,7 +226,7 @@ impl GlobalEventRing { /// Every buffered entry with `seq > since`, oldest first — the same /// best-effort catch-up contract as [`SessionEventRing::replay_since`]. pub(crate) fn replay_since(&self, since: u64) -> Vec { - let guard = self.state.lock().unwrap(); + let guard = self.state.lock().unwrap_or_else(PoisonError::into_inner); guard .buf .iter() @@ -206,21 +244,30 @@ pub(crate) fn spawn_global_ring_feeder( gateway: Arc>, ring: Arc, ) { - tokio::spawn(async move { - let mut rx = gateway.lock().await.subscribe_events(); - loop { - match rx.recv().await { - Ok(ev) => { - if is_im_only_event(&ev) { - continue; + // The one feeder: supervised, so a panic re-subscribes instead of + // leaving the ring silently empty for the daemon's life. + tokio::spawn(ccteam_im::supervise::supervise_loop( + "global ring feeder", + move || { + let gateway = Arc::clone(&gateway); + let ring = Arc::clone(&ring); + async move { + let mut rx = gateway.lock().await.subscribe_events(); + loop { + match rx.recv().await { + Ok(ev) => { + if is_im_only_event(&ev) { + continue; + } + ring.record(ev); + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, } - ring.record(ev); } - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, } - } - }); + }, + )); } /// Spawn the ONE persistent ring-feeder task for this gateway — called once @@ -237,23 +284,32 @@ pub(crate) fn spawn_ring_feeder( gateway: Arc>, ring: Arc, ) { - tokio::spawn(async move { - let mut rx = gateway.lock().await.subscribe_events(); - loop { - match rx.recv().await { - Ok(ev) => { - if is_im_only_event(&ev) { - continue; - } - if let Some(sid) = ev.sid.clone() { - ring.record(&sid, ev); + // The one feeder: supervised, so a panic re-subscribes instead of + // leaving every session's replay ring silently empty for the daemon's life. + tokio::spawn(ccteam_im::supervise::supervise_loop( + "session ring feeder", + move || { + let gateway = Arc::clone(&gateway); + let ring = Arc::clone(&ring); + async move { + let mut rx = gateway.lock().await.subscribe_events(); + loop { + match rx.recv().await { + Ok(ev) => { + if is_im_only_event(&ev) { + continue; + } + if let Some(sid) = ev.sid.clone() { + ring.record(&sid, ev); + } + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, } } - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, } - } - }); + }, + )); } #[cfg(test)] @@ -277,6 +333,29 @@ mod tests { } } + /// One panic while a ring lock is held must not turn every later record + /// and replay into a panic too (the feeders run for the daemon's life). + #[test] + fn rings_keep_working_after_a_panic_poisoned_their_lock() { + let session = Arc::new(SessionEventRing::new()); + let global = Arc::new(GlobalEventRing::new()); + session.record("s1", ev("a", "s1")); + global.record(ev("a", "s1")); + let (s, g) = (Arc::clone(&session), Arc::clone(&global)); + let _ = std::thread::spawn(move || { + let _rings = s.rings.lock().unwrap(); + let _state = g.state.lock().unwrap(); + panic!("poison both ring locks"); + }) + .join(); + assert!(session.rings.is_poisoned() && global.state.is_poisoned()); + + assert_eq!(session.record("s1", ev("b", "s1")), 2); + assert_eq!(session.replay_since("s1", 0).len(), 2); + assert_eq!(global.record(ev("b", "s1")), 2); + assert_eq!(global.replay_since(0).len(), 2); + } + #[test] fn record_assigns_monotonic_seq_per_sid() { let ring = SessionEventRing::new(); @@ -320,6 +399,26 @@ mod tests { assert_eq!(replayed[0].event.id, "e10"); } + /// Issue #213: only the most recently active sessions keep a backlog, + /// and a session whose backlog was dropped keeps counting seqs from where + /// it was (a reconnect's `Last-Event-ID` is never confused by a restart). + #[test] + fn only_recent_sessions_keep_a_backlog_and_seqs_never_restart() { + let ring = SessionEventRing::new(); + ring.record("s0", ev("first", "s0")); + for i in 1..=MAX_BUFFERED_SESSIONS { + let sid = format!("s{i}"); + ring.record(&sid, ev("x", &sid)); + } + assert!( + ring.replay_since("s0", 0).is_empty(), + "least recent dropped" + ); + assert_eq!(ring.replay_since("s1", 0).len(), 1); + assert_eq!(ring.rings.lock().unwrap().buffered, MAX_BUFFERED_SESSIONS); + assert_eq!(ring.record("s0", ev("second", "s0")), 2); + } + #[test] fn replay_since_unknown_sid_is_empty() { let ring = SessionEventRing::new(); diff --git a/crates/ccteam-web/src/routes/assets.rs b/crates/ccteam-web/src/routes/assets.rs index 857a6e529..3ff46da47 100644 --- a/crates/ccteam-web/src/routes/assets.rs +++ b/crates/ccteam-web/src/routes/assets.rs @@ -158,3 +158,23 @@ fn serve_embedded( ) .into_response() } + +#[cfg(test)] +mod tests { + use super::*; + use std::borrow::Cow; + + /// The bundle must be compiled into the binary in every profile. A + /// disk-backed lookup returns `Cow::Owned` (read from the build + /// worktree's `web/dist/` at request time), which 500s once that + /// worktree is gone (#215); an embedded one is `Cow::Borrowed` static + /// data. Fails in a debug test build if `debug-embed` is dropped. + #[test] + fn spa_index_is_embedded_not_read_from_the_build_tree() { + let index = SpaAssets::get("index.html").expect("index.html in bundle"); + assert!( + matches!(index.data, Cow::Borrowed(_)), + "SPA index.html is read from disk at runtime, not embedded" + ); + } +} diff --git a/crates/ccteam-web/src/routes/mcp.rs b/crates/ccteam-web/src/routes/mcp.rs index bdb3e9dea..712126aa8 100644 --- a/crates/ccteam-web/src/routes/mcp.rs +++ b/crates/ccteam-web/src/routes/mcp.rs @@ -1242,19 +1242,30 @@ pub(crate) fn spawn_binding_reaper( principals: Arc, bindings: Arc, ) { - tokio::spawn(async move { - let mut tick = tokio::time::interval(BINDING_SWEEP_PERIOD); - tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - tick.tick().await; - let closed = bindings.sweep_idle(chrono::Duration::seconds(BINDING_MAX_IDLE_SECS)); - for sid in &closed { - tracing::info!( - %sid, idle_secs = BINDING_MAX_IDLE_SECS, - "ccteam-web: reaping an idle enrolled client's ledger node" - ); + tokio::spawn(ccteam_im::supervise::supervise_loop( + "binding reaper", + move || { + let (gateway, principals, bindings) = ( + Arc::clone(&gateway), + Arc::clone(&principals), + Arc::clone(&bindings), + ); + async move { + let mut tick = tokio::time::interval(BINDING_SWEEP_PERIOD); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tick.tick().await; + let closed = + bindings.sweep_idle(chrono::Duration::seconds(BINDING_MAX_IDLE_SECS)); + for sid in &closed { + tracing::info!( + %sid, idle_secs = BINDING_MAX_IDLE_SECS, + "ccteam-web: reaping an idle enrolled client's ledger node" + ); + } + retire_node(Some(&gateway), Some(&principals), &closed).await; + } } - retire_node(Some(&gateway), Some(&principals), &closed).await; - } - }); + }, + )); } diff --git a/crates/ccteam-web/src/routes/openapi.rs b/crates/ccteam-web/src/routes/openapi.rs index eb604f836..295ae161f 100644 --- a/crates/ccteam-web/src/routes/openapi.rs +++ b/crates/ccteam-web/src/routes/openapi.rs @@ -276,7 +276,9 @@ fn build_api_v1() -> OpenApiRouter { super::sessions_api::handle_patch_session )) .routes(routes!(super::sessions_api::handle_session_status)) + .routes(routes!(super::sessions_api::handle_session_context)) .routes(routes!(super::sessions_api::handle_session_turn)) + .routes(routes!(super::sessions_api::handle_session_step)) .routes(routes!( super::sessions_api::handle_list_scheduled, super::sessions_api::handle_create_scheduled diff --git a/crates/ccteam-web/src/routes/projects.rs b/crates/ccteam-web/src/routes/projects.rs index 2c4d99a9d..04dc53fd7 100644 --- a/crates/ccteam-web/src/routes/projects.rs +++ b/crates/ccteam-web/src/routes/projects.rs @@ -562,13 +562,24 @@ pub(crate) async fn handle_delete_project( // alone is the meaningful effect there. let mut stopped: Vec = Vec::new(); if let Some(gw) = app.gateway.as_ref() { - let mut guard = gw.lock().await; - let sids: Vec = guard + let sids: Vec = gw + .lock() + .await .session_views() .into_iter() .filter(|v| v.project == slug) .map(|v| v.sid) .collect(); + // A body left over from a restart is ended off the gateway lock (the + // kill can take the whole SIGTERM grace); the stop below finds it gone. + for sid in &sids { + if let Err(err) = + ccteam_im::gateway::Gateway::end_detached_body_shared(gw, sid, "stopping").await + { + tracing::warn!(%slug, %sid, %err, "ending a detached body during project delete failed; the stop retries it"); + } + } + let mut guard = gw.lock().await; for sid in sids { // The project is being deregistered wholesale, so the stop's // own record of what it cut short has nowhere left to be read. diff --git a/crates/ccteam-web/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index b22f25d96..87396869a 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -17,6 +17,7 @@ //! - `GET /api/v1/sessions/{sid}/events` → SSE (filtered by `sid`) //! - `POST /api/v1/sessions/{sid}/stop` → stop → 200 `{stopped:true}` //! - `POST /api/v1/sessions/{sid}/interrupt` → interrupt running turn → 200 `{interrupted:true}` +//! - `GET /api/v1/sessions/{sid}/context` → `{sid, live, breakdown}` context composition //! //! **No-gateway contract (locked W5b)**: the standalone "internal web" //! path runs without a daemon gateway ([`AppState::gateway`] = `None`). @@ -824,6 +825,69 @@ pub(crate) async fn handle_session_status( .into_response() } +/// `GET /api/v1/sessions/{sid}/context` +/// +/// What the session's context window is made of (issue #21) — the harness's +/// own per-category accounting (system prompt, tools, memory files, skills, +/// MCP, the conversation split by tool and attachment), for the SPA's context +/// sidebar. A resident session is asked right now (`live: true`; Claude answers +/// without running a turn); otherwise — or if the live ask fails — the last +/// turn-end snapshot is served (`live: false`, its `captured_at` says how +/// old). `breakdown: null` = this harness has no breakdown channel and no +/// snapshot exists. Same lock-drop discipline as the status endpoint: resolve +/// under the gateway lock, await the harness after dropping it. +#[utoipa::path( + get, + path = "/api/v1/sessions/{sid}/context", + tag = "sessions", + params(("sid" = String, Path, description = "Gateway session id (`s{n}`)")), + responses( + (status = 200, description = "`{sid, live, breakdown}`; `breakdown` = `{vendor, model, captured_at, used_tokens, window_tokens, auto_compact_threshold, categories:[{id, name, tokens, kind: used|deferred|buffer|free, items:[{name, source, detail, tokens, loaded}]}], messages:{tool_call_tokens, tool_result_tokens, attachment_tokens, assistant_tokens, user_tokens, other_tokens, tools:[{name, call_tokens, result_tokens}], attachments:[{name, tokens}]}, last_request:{input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens}}` or null", body = serde_json::Value), + (status = 404, description = "Unknown session"), + (status = 503, description = "No live gateway (standalone web)"), + ), +)] +pub(crate) async fn handle_session_context( + State(app): State, + Extension(identity): Extension, + Path(sid): Path, +) -> Response { + if let Some(deny) = gate_sid(&app, &identity, &sid).await { + return deny; + } + let Some(gw) = app.gateway.as_ref() else { + return no_gateway(); + }; + let (resident, resolved) = { + let guard = gw.lock().await; + ( + guard.session_status_handle(&sid), + guard.session_resolve_any(&sid), + ) + }; + let Some(resolved) = resolved else { + return unknown_session(&sid); + }; + let fresh = match resident { + Some((adapter, thread)) => adapter.context_breakdown(&thread).await, + None => None, + }; + let live = fresh.is_some(); + let breakdown = match fresh { + Some(b) => Some(b), + None => { + let (dir, snapshot_sid) = (resolved.project_dir, sid.clone()); + tokio::task::spawn_blocking(move || { + ccteam_harness::execution::session_status::read_context_file(&dir, &snapshot_sid) + }) + .await + .ok() + .flatten() + } + }; + Json(json!({ "sid": sid, "live": live, "breakdown": breakdown })).into_response() +} + /// POST body for a turn submission — `text` (required unless `attachments` /// is non-empty). Form or JSON; `attachments` rides the JSON shape only. #[derive(Debug, Deserialize, ToSchema)] @@ -1164,7 +1228,7 @@ pub(crate) async fn handle_session_turn( Arc::clone(gw), &sid, text, - identity.is_admin, + &identity.web_chat_id(), deadline, ) .await; @@ -1741,6 +1805,8 @@ pub(crate) async fn handle_session_stop( /// follow-up. The spine's [`Gateway::interrupt_session`] reaches the adapter /// OUT-OF-BAND (stream-json `interrupt` control_request / TUI ESC / codex /// `turn/interrupt`), so the interrupt is NOT queued behind the running turn. +/// On a `detached` sid (a process left over from before a daemon restart) it +/// ends that process and the same sid resumes with its queued turns. /// 200 `{interrupted:true}`. 404 for an unknown sid. 503 with no gateway. Same /// auth + project ACL (`gate_sid`) as the stop route. #[utoipa::path( @@ -1765,19 +1831,89 @@ pub(crate) async fn handle_session_interrupt( let Some(gw) = app.gateway.as_ref() else { return no_gateway(); }; - let result = { - let mut guard = gw.lock().await; - guard.interrupt_session(&sid).await - }; - match result { + match ccteam_im::gateway::Gateway::interrupt_session_shared(gw, &sid).await { Ok(()) => Json(json!({"interrupted": true})).into_response(), + Err(err) if err.to_string().starts_with("unknown session") => unknown_session(&sid), Err(err) => { tracing::warn!(%sid, %err, "interrupt_session failed"); - unknown_session(&sid) + ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("interrupt of {sid} failed: {err:#}")})), + ) + .into_response() } } } +/// `GET /api/v1/sessions/{sid}/steps/{step_id}` +/// +/// One finished step of the session in full — a tool call's input and +/// output, or a reasoning block's text — for the web step inspector. The live +/// activity frame names the step (`activity.step_id`) but carries only a +/// one-line summary; the detail is read on demand from the session's +/// `/.ccteam/chat//steps.jsonl`, so megabytes of tool +/// output never ride the SSE stream. 404 for a step that has not finished +/// (or never existed). Gated like every other `/sessions/{sid}/*` route. +#[utoipa::path( + get, + path = "/api/v1/sessions/{sid}/steps/{step_id}", + tag = "sessions", + params( + ("sid" = String, Path, description = "Gateway session id (`s{n}`)"), + ("step_id" = String, Path, description = "The `activity.step_id` of a live activity frame") + ), + responses( + (status = 200, description = "The step `{step_id, ts, kind, name, input?, output?, truncated}`", body = serde_json::Value), + (status = 404, description = "Unknown session, or a step not (yet) recorded"), + (status = 503, description = "No live gateway (standalone web)"), + ), +)] +pub(crate) async fn handle_session_step( + State(app): State, + Extension(identity): Extension, + Path((sid, step_id)): Path<(String, String)>, +) -> Response { + if let Some(deny) = gate_sid(&app, &identity, &sid).await { + return deny; + } + let Some(gw) = app.gateway.as_ref() else { + return no_gateway(); + }; + let resolved = { + let guard = ccteam_im::latency::gateway_lock(gw, "web.sessions.step").await; + guard.session_resolve_any(&sid) + }; + let Some(resolved) = resolved else { + return unknown_session(&sid); + }; + let found = tokio::task::spawn_blocking(move || { + ccteam_harness::execution::turns_mirror::find_step( + &resolved.project_dir, + &resolved.sid, + &step_id, + ) + }) + .await; + match found { + Ok(Ok(Some(record))) => Json(record).into_response(), + Ok(Ok(None)) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "step not recorded (still running, or unknown)"})), + ) + .into_response(), + Ok(Err(err)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("reading steps of {sid} failed: {err:#}")})), + ) + .into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("step lookup panicked: {err}")})), + ) + .into_response(), + } +} + // ── v0.8.21 history / resume / external-import ─────────────────────────────── /// `GET /api/v1/projects/{slug}/sessions/history` @@ -2673,6 +2809,7 @@ mod tests { summary: "Bash(ls -la)".into(), status: ActivityStatus::Started, item_id: "t1".into(), + step_id: "9f-1-t1".into(), }, }; let payload = session_event_payload(&ev); @@ -2689,6 +2826,8 @@ mod tests { assert_eq!(act["summary"], "Bash(ls -la)"); assert_eq!(act["status"], "started"); assert_eq!(act["item_id"], "t1"); + // What the step inspector fetches the full record by. + assert_eq!(act["step_id"], "9f-1-t1"); } /// v0.8.7 review-fix (R-H1) — an approval ChoicePrompt event serializes its diff --git a/crates/ccteam-web/tests/openapi_test.rs b/crates/ccteam-web/tests/openapi_test.rs index eeb1f3588..280d6141c 100644 --- a/crates/ccteam-web/tests/openapi_test.rs +++ b/crates/ccteam-web/tests/openapi_test.rs @@ -150,7 +150,10 @@ fn expected_operations() -> BTreeSet<(&'static str, &'static str)> { ("PATCH", "/api/v1/sessions/{sid}"), // live statusline (model + context-window usage) for the SPA top bar. ("GET", "/api/v1/sessions/{sid}/status"), + // issue #21 — context composition for the session's context sidebar. + ("GET", "/api/v1/sessions/{sid}/context"), ("POST", "/api/v1/sessions/{sid}/turn"), + ("GET", "/api/v1/sessions/{sid}/steps/{step_id}"), // v0.8.7 review-fix (R-H1) — web HITL token-resolve endpoint. ("POST", "/api/v1/sessions/{sid}/resolve"), ("GET", "/api/v1/sessions/{sid}/events"), diff --git a/crates/ccteam-web/tests/sessions_api_test.rs b/crates/ccteam-web/tests/sessions_api_test.rs index 8539141c4..d24658de8 100644 --- a/crates/ccteam-web/tests/sessions_api_test.rs +++ b/crates/ccteam-web/tests/sessions_api_test.rs @@ -123,6 +123,32 @@ impl HarnessAdapter for FakeAdapter { async fn thread_status(&self, _h: &ThreadHandle) -> Result { Ok(ThreadStatus::default()) } + + /// Only the claude double has a breakdown channel — the codex one models a + /// harness that cannot itemize its window. + async fn context_breakdown( + &self, + _h: &ThreadHandle, + ) -> Option { + (self.vendor == AgentVendor::Claude).then(|| breakdown(42)) + } +} + +fn breakdown(used: u64) -> ccteam_harness::ContextBreakdown { + ccteam_harness::ContextBreakdown { + vendor: "claude".into(), + captured_at: chrono::Utc::now(), + used_tokens: used, + window_tokens: 100, + categories: vec![ccteam_harness::ContextCategory { + id: "messages".into(), + name: "Messages".into(), + tokens: used, + kind: ccteam_harness::ContextCategoryKind::Used, + items: Vec::new(), + }], + ..Default::default() + } } fn seed_role_with_model(project_dir: &std::path::Path, role: &str, model: Option<&str>) { @@ -639,6 +665,93 @@ async fn session_rows_carry_context_pct_once_observed() { assert_eq!(after[0]["context_pct"], 63, "{after}"); } +/// Issue #21 — the context sidebar's source: a resident session is asked +/// live; once it is gone the turn-end snapshot answers (`live: false`); a +/// harness with no breakdown and no snapshot answers `null`, not an error. +#[tokio::test] +async fn session_context_is_live_then_falls_back_to_the_snapshot() { + let tmp = TempDir::new().unwrap(); + let paths = fake_paths(tmp.path()); + let project_dir = paths.projects_root.join("demo"); + std::fs::create_dir_all(&project_dir).unwrap(); + let factory = Arc::new(|vendor, _protocol| { + Arc::new(FakeAdapter { vendor }) as Arc + }); + let gateway = + ccteam_im::gateway::Gateway::new_with_factory(factory, "demo", project_dir.clone()); + let addr = spawn_server(AppState::new(paths).with_gateway_owned(gateway)).await; + let client = reqwest::Client::new(); + let base = format!("http://{addr}/api/v1"); + let create = |vendor: &'static str| { + let (client, base) = (client.clone(), base.clone()); + async move { + client + .post(format!("{base}/projects/demo/sessions")) + .json(&serde_json::json!({"role": "", "vendor": vendor})) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["sid"] + .as_str() + .unwrap() + .to_string() + } + }; + let context = |sid: String| { + let (client, base) = (client.clone(), base.clone()); + async move { + let r = client + .get(format!("{base}/sessions/{sid}/context")) + .send() + .await + .unwrap(); + ( + r.status().as_u16(), + r.json::().await.unwrap_or(Value::Null), + ) + } + }; + + let sid = create("claude").await; + let (code, live) = context(sid.clone()).await; + assert_eq!(code, 200, "{live}"); + assert_eq!(live["sid"], sid.as_str()); + assert_eq!(live["live"], true); + assert_eq!(live["breakdown"]["used_tokens"], 42); + assert_eq!(live["breakdown"]["categories"][0]["kind"], "used"); + + let stopped = client + .post(format!("{base}/sessions/{sid}/stop")) + .send() + .await + .unwrap(); + assert_eq!(stopped.status(), 200); + let (code, gone) = context(sid.clone()).await; + assert_eq!(code, 200, "a stopped session is still readable: {gone}"); + assert_eq!(gone["live"], false); + assert!(gone["breakdown"].is_null(), "no snapshot yet: {gone}"); + + ccteam_harness::execution::session_status::write_context_file( + &project_dir, + &sid, + &breakdown(7), + ); + let (_, snap) = context(sid.clone()).await; + assert_eq!(snap["live"], false); + assert_eq!(snap["breakdown"]["used_tokens"], 7); + + let codex = create("codex").await; + let (code, none) = context(codex).await; + assert_eq!(code, 200); + assert_eq!(none["live"], false); + assert!(none["breakdown"].is_null(), "{none}"); + + let (code, _) = context("s999".into()).await; + assert_eq!(code, 404); +} + /// End-to-end user flow over a real router + real gateway: create a session /// (meta.json lands on disk), stop it (meta.json SURVIVES the stop), the live /// list drops it while the history list now shows it, then resume puts it back @@ -1203,6 +1316,7 @@ async fn rename_session_denies_cross_tenant_project() { for (method, path) in [ ("GET", format!("{base}/sessions/{sid}")), ("GET", format!("{base}/sessions/{sid}/status")), + ("GET", format!("{base}/sessions/{sid}/context")), ("GET", format!("{base}/projects/demo/sessions")), ] { let r = client @@ -1852,3 +1966,69 @@ async fn upload_endpoint_guards_project_body_and_name() { assert!(path.ends_with("evil.sh"), "basename kept: {path}"); assert!(!project_dir.parent().unwrap().join("evil.sh").exists()); } + +/// The step inspector's read: a recorded step comes back whole by the +/// `step_id` its activity frame carried; one not (yet) recorded is a 404. +#[tokio::test] +async fn a_recorded_step_is_served_whole_by_its_step_id() { + ccteam_core::disable_tool_surface_bootstrap_for_tests(); + let tmp = TempDir::new().unwrap(); + let paths = fake_paths(tmp.path()); + let project_dir = paths.projects_root.join("demo"); + std::fs::create_dir_all(&project_dir).unwrap(); + + let factory = Arc::new(|vendor, _protocol| { + Arc::new(FakeAdapter { vendor }) as Arc + }); + let gateway = + ccteam_im::gateway::Gateway::new_with_factory(factory, "demo", project_dir.clone()); + let addr = spawn_server(AppState::new(paths).with_gateway_owned(gateway)).await; + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let base = format!("http://{addr}/api/v1"); + + let created = client + .post(format!("{base}/projects/demo/sessions")) + .json(&serde_json::json!({"role": "", "vendor": "claude"})) + .send() + .await + .unwrap(); + assert_eq!(created.status(), 201); + let sid = created.json::().await.unwrap()["sid"] + .as_str() + .unwrap() + .to_string(); + + let output = "line\n".repeat(10_000); + ccteam_harness::execution::turns_mirror::append_step( + &project_dir, + &sid, + ccteam_harness::execution::turns_mirror::StepRecord { + step_id: "1a2b-3-sj-t1-2".into(), + ts: chrono::Utc::now(), + kind: "tool_call".into(), + name: "Bash".into(), + input: Some("{\"command\": \"seq 10000\"}".into()), + output: Some(output.clone()), + truncated: false, + }, + ) + .unwrap(); + + let step = client + .get(format!("{base}/sessions/{sid}/steps/1a2b-3-sj-t1-2")) + .send() + .await + .unwrap(); + assert_eq!(step.status(), 200); + let step: Value = step.json().await.unwrap(); + assert_eq!(step["name"], "Bash"); + assert_eq!(step["output"].as_str().unwrap(), output, "served whole"); + assert_eq!(step["truncated"], false); + + let running = client + .get(format!("{base}/sessions/{sid}/steps/1a2b-3-sj-t1-9")) + .send() + .await + .unwrap(); + assert_eq!(running.status(), 404); +} diff --git a/crates/ccteam-web/web/src/components/ContextPanel.test.tsx b/crates/ccteam-web/web/src/components/ContextPanel.test.tsx new file mode 100644 index 000000000..5dbd8364a --- /dev/null +++ b/crates/ccteam-web/web/src/components/ContextPanel.test.tsx @@ -0,0 +1,419 @@ +// @vitest-environment jsdom +// The context panel mounted for real (jsdom + mocked fetch): the header line, +// the stacked bar (deferred excluded), category order + expand, the messages +// efficiency tables, the cache line, the honest `breakdown:null` copy, the +// snapshot badge, Refresh → re-fetch, a shown failure, turn-end re-fetch, and +// Escape / × closing. + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionContextResponse } from "../lib/contextApi"; +import { ContextPanel } from "./ContextPanel"; + +const BREAKDOWN: SessionContextResponse = { + sid: "s12", + live: true, + breakdown: { + vendor: "claude", + model: "claude-sonnet-5-5", + captured_at: "2026-10-21T08:00:00Z", + used_tokens: 253_770, + window_tokens: 1_000_000, + auto_compact_threshold: 967_000, + categories: [ + { id: "system_prompt", name: "System prompt", tokens: 2247, kind: "used", items: [] }, + { id: "system_tools", name: "System tools", tokens: 6714, kind: "used", items: [] }, + { + id: "mcp_tools", + name: "MCP tools", + tokens: 941, + kind: "used", + items: [{ name: "mcp__ccteam__agent", source: "ccteam", detail: null, tokens: 941, loaded: true }], + }, + { + id: "mcp_tools_deferred", + name: "MCP tools (deferred)", + tokens: 1290, + kind: "deferred", + items: [{ name: "mcp__ccteam__agent_read", source: "ccteam", detail: null, tokens: 649, loaded: false }], + }, + { id: "system_tools_deferred", name: "System tools (deferred)", tokens: 12_011, kind: "deferred", items: [] }, + { + id: "custom_agents", + name: "Custom agents", + tokens: 145, + kind: "used", + items: [{ name: "cto", source: "projectSettings", detail: "/repo/.claude/agents/cto.md", tokens: 145, loaded: null }], + }, + { + id: "memory_files", + name: "Memory files", + tokens: 15_169, + kind: "used", + items: [ + { name: "/home/u/.claude/CLAUDE.md", source: "User", detail: null, tokens: 103, loaded: null }, + { name: "/repo/CLAUDE.md", source: "Project", detail: null, tokens: 11_489, loaded: null }, + ], + }, + { id: "messages", name: "Messages", tokens: 237_146, kind: "used", items: [] }, + { id: "autocompact_buffer", name: "Autocompact buffer", tokens: 33_000, kind: "buffer", items: [] }, + { id: "free_space", name: "Free space", tokens: 713_230, kind: "free", items: [] }, + ], + messages: { + tool_call_tokens: 36_149, + tool_result_tokens: 62_805, + attachment_tokens: 69_528, + assistant_tokens: 67_354, + user_tokens: 1732, + other_tokens: 0, + tools: [ + { name: "mcp__ccteam__agent", call_tokens: 1220, result_tokens: 84 }, + { name: "Bash", call_tokens: 34_420, result_tokens: 61_749 }, + ], + attachments: [ + { name: "async_hook_response", tokens: 17_151 }, + { name: "prompt_snapshot", tokens: 26_483 }, + ], + }, + last_request: { input_tokens: 2, output_tokens: 1259, cache_creation_tokens: 1022, cache_read_tokens: 252_179 }, + }, +}; + +const realFetch = globalThis.fetch; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** A fetch that answers every call with a FRESH response (a Response body + * reads once, so a shared mockResolvedValue would break the second call). */ +function fetchAnswering(body: unknown, status = 200) { + return vi.fn(() => Promise.resolve(jsonResponse(status, body))); +} + +describe("ContextPanel", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + globalThis.fetch = realFetch; + vi.restoreAllMocks(); + }); + + async function mount(ui: React.ReactNode) { + await act(async () => { + root = createRoot(container); + root.render(ui); + }); + await flush(); + } + /** Let the fetch → json → setState chain settle. */ + async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + const $ = (selector: string) => container.querySelector(selector); + const $$ = (selector: string) => Array.from(container.querySelectorAll(selector)); + const text = (selector: string) => $(selector)?.textContent ?? ""; + const panel = (over: Partial> = {}) => ( + {}} refreshKey={undefined} {...over} /> + ); + + it("fetches on mount and renders the header: model, used / window · %, live badge", async () => { + globalThis.fetch = fetchAnswering(BREAKDOWN); + await mount(panel()); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]![0])).toBe("/api/v1/sessions/s12/context"); + expect($('[data-testid="context-panel"]')).not.toBeNull(); + expect(text('[data-testid="ctx-model"]')).toBe("claude-sonnet-5-5"); + expect(text('[data-testid="ctx-usage"]')).toBe("253.8k / 1M · 25.4%"); + expect($('[data-testid="ctx-usage"]')!.getAttribute("title")).toContain("253,770 / 1,000,000"); + expect($('[data-testid="ctx-live"]')).not.toBeNull(); + expect($('[data-testid="ctx-snapshot"]')).toBeNull(); + expect($('[data-testid="ctx-loading"]')).toBeNull(); + }); + + it("stacks used (desc) → buffer → free in the bar; deferred is NOT a segment", async () => { + globalThis.fetch = fetchAnswering(BREAKDOWN); + await mount(panel()); + const ids = $$('[data-testid="ctx-meter"] .ctx-seg').map((el) => el.getAttribute("data-testid")); + expect(ids).toEqual([ + "ctx-seg-messages", + "ctx-seg-memory_files", + "ctx-seg-system_tools", + "ctx-seg-system_prompt", + "ctx-seg-mcp_tools", + "ctx-seg-custom_agents", + "ctx-seg-autocompact_buffer", + "ctx-seg-free_space", + ]); + expect($('[data-testid="ctx-seg-mcp_tools_deferred"]')).toBeNull(); + expect($('[data-testid="ctx-seg-system_tools_deferred"]')).toBeNull(); + const messages = $('[data-testid="ctx-seg-messages"]') as HTMLElement; + expect(messages.style.flexBasis).toBe("23.7146%"); + expect(messages.style.background).toContain("--ctx-1"); + expect(messages.getAttribute("title")).toBe("Messages · 237,146 · 23.7%"); + // The auto-compact threshold is marked on the bar. + expect(($('[data-testid="ctx-threshold"]') as HTMLElement).style.left).toBe("96.7%"); + }); + + it("lists categories by tokens desc (used first), deferred apart, muted, uncounted", async () => { + globalThis.fetch = fetchAnswering(BREAKDOWN); + await mount(panel()); + const rows = $$('[data-testid="ctx-categories"] tr.ctx-cat').map((el) => el.getAttribute("data-testid")); + expect(rows).toEqual([ + "ctx-cat-messages", + "ctx-cat-memory_files", + "ctx-cat-system_tools", + "ctx-cat-system_prompt", + "ctx-cat-mcp_tools", + "ctx-cat-custom_agents", + "ctx-cat-autocompact_buffer", + "ctx-cat-free_space", + ]); + const messagesRow = $('[data-testid="ctx-cat-messages"]')!; + expect(messagesRow.textContent).toContain("237.1k"); + expect(messagesRow.textContent).toContain("23.7%"); + // Deferred: its own muted section, named as not counted, % column a dash. + const deferred = $('[data-testid="ctx-deferred"]')!; + expect(deferred.className).toContain("ctx-muted"); + expect(deferred.textContent).toContain("Deferred tools (not counted)"); + const deferredRows = $$('[data-testid="ctx-deferred-table"] tr.ctx-cat').map((el) => el.getAttribute("data-testid")); + expect(deferredRows).toEqual(["ctx-cat-system_tools_deferred", "ctx-cat-mcp_tools_deferred"]); + expect($('[data-testid="ctx-cat-mcp_tools_deferred"] td.num:last-child')!.textContent).toBe("—"); + expect($('[data-testid="ctx-categories"] [data-testid="ctx-cat-mcp_tools_deferred"]')).toBeNull(); + }); + + it("expands a category into its items (largest first), marks not-loaded ones", async () => { + globalThis.fetch = fetchAnswering(BREAKDOWN); + await mount(panel()); + expect($$('[data-testid="ctx-item"]')).toHaveLength(0); + const expand = $('[data-testid="ctx-expand-memory_files"]') as HTMLButtonElement; + expect(expand.getAttribute("aria-expanded")).toBe("false"); + await act(async () => expand.click()); + expect(expand.getAttribute("aria-expanded")).toBe("true"); + const items = $$('[data-testid="ctx-categories"] [data-testid="ctx-item"]'); + expect(items).toHaveLength(2); + expect(items[0]!.textContent).toContain("/repo/CLAUDE.md"); + expect(items[0]!.textContent).toContain("Project"); + expect(items[0]!.textContent).toContain("11.5k"); + expect(items[1]!.textContent).toContain("/home/u/.claude/CLAUDE.md"); + // A plain row (no items) has no expander. + expect($('[data-testid="ctx-expand-messages"]')).toBeNull(); + // Detail rides as a secondary line + tooltip. + await act(async () => ($('[data-testid="ctx-expand-custom_agents"]') as HTMLButtonElement).click()); + const cto = $$('[data-testid="ctx-item"]').find((el) => el.textContent?.includes("cto"))!; + expect(cto.textContent).toContain("/repo/.claude/agents/cto.md"); + expect(cto.querySelector("td.name")!.getAttribute("title")).toContain("/repo/.claude/agents/cto.md"); + // Deferred items carry the not-loaded mark. + await act(async () => ($('[data-testid="ctx-expand-mcp_tools_deferred"]') as HTMLButtonElement).click()); + const deferredItem = $('[data-testid="ctx-deferred-table"] [data-testid="ctx-item"]')!; + expect(deferredItem.querySelector('[data-testid="ctx-not-loaded"]')!.textContent).toBe("not loaded"); + // Collapse memory files again — its two items go, the still-open custom + // agents row keeps its one. + await act(async () => expand.click()); + expect(expand.getAttribute("aria-expanded")).toBe("false"); + const remaining = $$('[data-testid="ctx-categories"] [data-testid="ctx-item"]'); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.textContent).toContain("cto"); + }); + + it("messages: split by bucket, per-tool table by total desc, attachments desc", async () => { + globalThis.fetch = fetchAnswering(BREAKDOWN); + await mount(panel()); + const split = $$('[data-testid="ctx-split"] tr').map((el) => el.getAttribute("data-testid")); + expect(split).toEqual([ + "ctx-split-attachment", + "ctx-split-assistant", + "ctx-split-tool_result", + "ctx-split-tool_call", + "ctx-split-user", + ]); + expect(text('[data-testid="ctx-split-attachment"]')).toContain("Attachments"); + expect(text('[data-testid="ctx-split-attachment"]')).toContain("69.5k"); + const tools = $$('[data-testid="ctx-tool-row"]').map((el) => el.getAttribute("data-tool")); + expect(tools).toEqual(["Bash", "mcp__ccteam__agent"]); + const bash = $('[data-testid="ctx-tool-row"][data-tool="Bash"]')!; + expect(Array.from(bash.querySelectorAll("td")).map((td) => td.textContent)).toEqual([ + "Bash", + "34.4k", + "61.7k", + "96.2k", + ]); + const attachments = $$('[data-testid="ctx-attachment-row"]').map((el) => el.getAttribute("data-attachment")); + expect(attachments).toEqual(["prompt_snapshot", "async_hook_response"]); + }); + + it("last request: cache hit ratio = read / (read + write + uncached)", async () => { + globalThis.fetch = fetchAnswering(BREAKDOWN); + await mount(panel()); + expect(text('[data-testid="ctx-cache-hit"]')).toBe("cache hit 99.6%"); + expect(text('[data-testid="ctx-last-request"]')).toContain("cache read 252.2k"); + expect(text('[data-testid="ctx-last-request"]')).toContain("cache write 1k"); + expect(text('[data-testid="ctx-last-request"]')).toContain("uncached input 2"); + expect(text('[data-testid="ctx-last-request"]')).toContain("output 1.3k"); + }); + + it("degrades per section: no messages / last_request / threshold → those parts are absent", async () => { + globalThis.fetch = fetchAnswering({ + sid: "s12", + live: true, + breakdown: { + used_tokens: 10, + window_tokens: 100, + categories: [{ id: "messages", name: "Messages", tokens: 10, kind: "used" }], + messages: null, + last_request: null, + unknown_extra: { ignored: true }, + }, + }); + await mount(panel()); + expect($('[data-testid="ctx-messages"]')).toBeNull(); + expect($('[data-testid="ctx-last-request"]')).toBeNull(); + expect($('[data-testid="ctx-threshold"]')).toBeNull(); + expect($('[data-testid="ctx-deferred"]')).toBeNull(); + expect($('[data-testid="ctx-model"]')).toBeNull(); + expect(text('[data-testid="ctx-usage"]')).toBe("10 / 100 · 10%"); + expect($$('[data-testid="ctx-meter"] .ctx-seg')).toHaveLength(1); + }); + + it("breakdown:null → an honest explanation, no bar, no error", async () => { + globalThis.fetch = fetchAnswering({ sid: "s12", live: false, breakdown: null }); + await mount(panel()); + expect(text('[data-testid="ctx-none"]')).toContain("No context breakdown for this session yet."); + expect(text('[data-testid="ctx-none"]')).toContain("Only Claude stream-json sessions on this host"); + expect($('[data-testid="ctx-meter"]')).toBeNull(); + expect($('[data-testid="ctx-error"]')).toBeNull(); + expect($('[data-testid="ctx-meta"]')).toBeNull(); + }); + + it("zh copy says harness, never vendor", async () => { + globalThis.fetch = fetchAnswering({ sid: "s12", live: false, breakdown: null }); + await mount(panel({ lang: "zh" })); + const copy = text('[data-testid="ctx-none"]'); + expect(copy).toContain("harness"); + expect(copy.toLowerCase()).not.toContain("vendor"); + }); + + it("live:false → a snapshot badge with the capture time, not a live badge", async () => { + globalThis.fetch = fetchAnswering({ + ...BREAKDOWN, + live: false, + breakdown: { ...BREAKDOWN.breakdown, captured_at: "2025-01-01T08:00:00Z" }, + }); + await mount(panel()); + expect($('[data-testid="ctx-live"]')).toBeNull(); + const badge = $('[data-testid="ctx-snapshot"]')!; + expect(badge.textContent).toMatch(/^snapshot · \d+ d ago$/); + expect(badge.getAttribute("title")).toContain("captured"); + }); + + it("Refresh re-fetches, shows a busy state while pending, then the new numbers", async () => { + let release: (() => void) | null = null; + const first = Promise.resolve(jsonResponse(200, BREAKDOWN)); + const second = new Promise((resolve) => { + release = () => + resolve( + jsonResponse(200, { + ...BREAKDOWN, + breakdown: { ...BREAKDOWN.breakdown, used_tokens: 300_000 }, + }), + ); + }); + const fetchMock = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second); + globalThis.fetch = fetchMock; + await mount(panel()); + expect(fetchMock).toHaveBeenCalledTimes(1); + const refresh = $('[data-testid="ctx-refresh"]') as HTMLButtonElement; + expect(refresh.disabled).toBe(false); + await act(async () => refresh.click()); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(refresh.disabled).toBe(true); + expect(refresh.getAttribute("aria-busy")).toBe("true"); + expect(refresh.getAttribute("aria-label")).toBe("Refreshing…"); + // The old numbers stay on screen while the refresh is pending. + expect(text('[data-testid="ctx-usage"]')).toBe("253.8k / 1M · 25.4%"); + await act(async () => release!()); + await flush(); + expect(refresh.disabled).toBe(false); + expect(text('[data-testid="ctx-usage"]')).toBe("300k / 1M · 30%"); + }); + + it("a failed fetch is shown inline (never silent), and Retry re-fetches", async () => { + const fetchMock = vi + .fn() + .mockReturnValueOnce(Promise.resolve(jsonResponse(503, { error: "no gateway (standalone web)" }))) + .mockReturnValueOnce(Promise.resolve(jsonResponse(200, BREAKDOWN))); + globalThis.fetch = fetchMock; + await mount(panel()); + const error = $('[data-testid="ctx-error"]')!; + expect(error.getAttribute("role")).toBe("alert"); + expect(error.textContent).toContain("Context load failed: HTTP 503: no gateway (standalone web)"); + expect($('[data-testid="ctx-loading"]')).toBeNull(); + await act(async () => (error.querySelector("button") as HTMLButtonElement).click()); + await flush(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect($('[data-testid="ctx-error"]')).toBeNull(); + expect($('[data-testid="ctx-live"]')).not.toBeNull(); + }); + + it("a failed Refresh keeps the last good data on screen next to the error", async () => { + const fetchMock = vi + .fn() + .mockReturnValueOnce(Promise.resolve(jsonResponse(200, BREAKDOWN))) + // Rejected lazily — an eager Promise.reject counts as unhandled until fetch consumes it. + .mockImplementationOnce(() => Promise.reject(new TypeError("Failed to fetch"))); + globalThis.fetch = fetchMock; + await mount(panel()); + await act(async () => ($('[data-testid="ctx-refresh"]') as HTMLButtonElement).click()); + await flush(); + expect(text('[data-testid="ctx-error"]')).toContain("network: Failed to fetch"); + expect(text('[data-testid="ctx-usage"]')).toBe("253.8k / 1M · 25.4%"); + }); + + it("re-fetches when a turn ends (refreshKey changes) — no polling", async () => { + globalThis.fetch = fetchAnswering(BREAKDOWN); + await mount(panel({ refreshKey: undefined })); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + await act(async () => { + root.render(panel({ refreshKey: "evt-41" })); + }); + await flush(); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + // Same key again (an unrelated re-render) does not re-fetch. + await act(async () => { + root.render(panel({ refreshKey: "evt-41", lang: "en" })); + }); + await flush(); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it("Escape inside the panel and the × button both close it; focus lands in the panel when asked", async () => { + globalThis.fetch = fetchAnswering(BREAKDOWN); + const onClose = vi.fn(); + await mount(panel({ onClose, focusOnMount: true })); + const aside = $('[data-testid="context-panel"]') as HTMLElement; + expect(document.activeElement).toBe(aside); + await act(async () => { + aside.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + expect(onClose).toHaveBeenCalledTimes(1); + await act(async () => ($('[data-testid="ctx-close"]') as HTMLButtonElement).click()); + expect(onClose).toHaveBeenCalledTimes(2); + await act(async () => ($('[data-testid="ctx-backdrop"]') as HTMLButtonElement).click()); + expect(onClose).toHaveBeenCalledTimes(3); + }); +}); diff --git a/crates/ccteam-web/web/src/components/ContextPanel.tsx b/crates/ccteam-web/web/src/components/ContextPanel.tsx new file mode 100644 index 000000000..cf28cc2ad --- /dev/null +++ b/crates/ccteam-web/web/src/components/ContextPanel.tsx @@ -0,0 +1,650 @@ +// The per-session CONTEXT panel — a right sidebar docked beside the chat in +// the conversation view (SessionView renders it inside `.conv-body`, next to +// `.conv-main`; ≤820px it becomes a right-edge overlay drawer via CSS). It +// shows what the session's context window is made of, mechanically, from +// `GET /api/v1/sessions/{sid}/context` — no model is asked. +// +// Lifecycle: mounted only while open (SessionView owns the open flag and +// remembers it per browser), so mount = fetch. Re-fetches when a turn ends on +// this session (`turnEnds` increments off the page's own SSE fold — no polling +// loop) and on the Refresh button (spinner + inline failure, never a silent +// no-op). Escape inside the panel closes it; focus moves into the panel only +// when the user opened it (not when the docked state is restored on load). +// +// Honest states: `live:false` is labelled a snapshot with its age; +// `breakdown:null` explains which sessions have no breakdown instead of +// erroring; every sub-section renders only when the server sent it. + +import { Fragment, useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { ChevronDown, ChevronRight, RefreshCw, X } from "lucide-react"; +import { + getSessionContext, + type ContextBreakdown, + type ContextCategory, + type ContextItem, + type ContextLastRequest, + type ContextMessages, + type SessionContextResponse, +} from "../lib/contextApi"; +import { + attachmentRows, + barSegments, + cacheHitRatio, + categoryLabel, + categorySlot, + formatPct, + formatTokensCompact, + formatTokensExact, + hasItems, + messageSplit, + messagesTotal, + pctOf, + relativeTime, + sortedItems, + splitCategories, + tableRows, + thresholdPct, + toolRows, + truncateMiddle, + usedPct, + type MessageSplitKey, +} from "../lib/contextBreakdown"; +import { makeT, type Lang } from "../lib/i18n"; + +type T = (key: string) => string; + +export interface ContextPanelProps { + sid: string; + lang: Lang; + onClose: () => void; + /** Identity of the newest turn-ending frame on this session; every change + * re-fetches the breakdown (the window changes at turn edges). */ + refreshKey?: string | number | null; + /** Move keyboard focus into the panel on mount — true when the user just + * opened it, false when the docked state was restored from storage. */ + focusOnMount?: boolean; +} + +export function ContextPanel({ sid, lang, onClose, refreshKey, focusOnMount = false }: ContextPanelProps) { + const t = makeT(lang); + const [data, setData] = useState(null); + // Mount = a fetch is already in flight, so the first paint is the loading + // state; later fetches set this from their own event handler. + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [fetchedAt, setFetchedAt] = useState(0); + const [expanded, setExpanded] = useState>({}); + const requestRef = useRef(0); + const panelRef = useRef(null); + + // One fetch path for mount, turn ends and the Refresh button. A superseded + // request (newer one started) never writes; a failure is SHOWN next to + // whatever data is already on screen. + const load = useCallback(() => { + const request = ++requestRef.current; + getSessionContext(sid) + .then((response) => { + if (request !== requestRef.current) return; + setData(response); + setFetchedAt(Date.now()); + setError(null); + setLoading(false); + }) + .catch((e) => { + if (request !== requestRef.current) return; + setError(e instanceof Error ? e.message : String(e)); + setLoading(false); + }); + }, [sid]); + + useEffect(() => { + load(); + }, [load, refreshKey]); + + // Abandon any in-flight request on unmount. + useEffect( + () => () => { + requestRef.current += 1; + }, + [], + ); + + useEffect(() => { + if (focusOnMount) panelRef.current?.focus(); + }, [focusOnMount]); + + const refresh = useCallback(() => { + setLoading(true); + load(); + }, [load]); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + e.preventDefault(); + e.stopPropagation(); + onClose(); + }, + [onClose], + ); + + const toggle = useCallback((id: string) => { + setExpanded((current) => ({ ...current, [id]: !current[id] })); + }, []); + + return ( + <> + + + +
+ {error ? ( +
+ + {t("ctxLoadFailed")}: {error} + + +
+ ) : null} + {loading && !data && !error ? ( +
+ {t("ctxLoading")} +
+ ) : null} + {data ? ( + data.breakdown ? ( + + ) : ( +
+

{t("ctxNoBreakdown")}

+

{t("ctxNoBreakdownWhy")}

+
+ ) + ) : null} +
+ + + ); +} + +function Breakdown({ + breakdown, + live, + fetchedAt, + lang, + t, + expanded, + onToggle, +}: { + breakdown: ContextBreakdown; + live: boolean; + fetchedAt: number; + lang: Lang; + t: T; + expanded: Record; + onToggle: (id: string) => void; +}) { + const { deferred } = splitCategories(breakdown); + const rows = tableRows(breakdown); + return ( + <> +
+ + {rows.length > 0 ? ( +
+

{t("ctxCategories")}

+ +
+ ) : null} + {deferred.length > 0 ? ( +
+

{t("ctxDeferred")}

+

{t("ctxDeferredNote")}

+ +
+ ) : null} + {breakdown.messages ? : null} + {breakdown.last_request ? : null} + + ); +} + +function Header({ + breakdown, + live, + fetchedAt, + lang, + t, +}: { + breakdown: ContextBreakdown; + live: boolean; + fetchedAt: number; + lang: Lang; + t: T; +}) { + const pct = usedPct(breakdown); + const locale = lang === "en" ? "en-US" : "zh-CN"; + const captured = breakdown.captured_at ? new Date(breakdown.captured_at) : null; + const capturedText = + captured && !Number.isNaN(captured.getTime()) ? captured.toLocaleString(locale) : null; + const age = live ? null : relativeTime(breakdown.captured_at, fetchedAt, lang); + return ( +
+ {breakdown.model ? ( + + {breakdown.model} + + ) : null} + + {formatTokensCompact(breakdown.used_tokens)} / {formatTokensCompact(breakdown.window_tokens)} + {pct !== null ? ` · ${formatPct(pct)}` : ""} + + {live ? ( + + {t("ctxLive")} + + ) : ( + + {t("ctxSnapshot")} + {age ? ` · ${age}` : ""} + + )} +
+ ); +} + +function WindowBar({ breakdown, lang, t }: { breakdown: ContextBreakdown; lang: Lang; t: T }) { + const segments = barSegments(breakdown); + if (segments.length === 0) return null; + const threshold = thresholdPct(breakdown); + return ( +
+
+ {segments.map((segment) => { + const label = categoryLabel(segment, lang); + const style = + segment.kind === "free" + ? undefined + : { + flexBasis: `${segment.pct}%`, + ...(segment.kind === "used" && segment.slot + ? { background: `var(--ctx-${segment.slot})` } + : {}), + }; + return ( + + ); + })} +
+ {threshold !== null ? ( + + ) : null} +
+ ); +} + +function Swatch({ slot, kind }: { slot: number | null; kind: string }) { + if (kind === "buffer") return ; + if (kind === "free") return ; + if (kind !== "used") return ; + return ( + + ); +} + +function CategoryTable({ + rows, + windowTokens, + counted, + lang, + t, + expanded, + onToggle, + testId, +}: { + rows: ContextCategory[]; + windowTokens: number | null | undefined; + /** false = these do not occupy the window (deferred): the % column is a dash. */ + counted: boolean; + lang: Lang; + t: T; + expanded: Record; + onToggle: (id: string) => void; + testId: string; +}) { + return ( + + + + + + + + + + {rows.map((category) => { + const expandable = hasItems(category); + const isOpen = expanded[category.id] === true; + const slot = category.kind === "used" ? categorySlot(category.id) : null; + const label = categoryLabel(category, lang); + const share = counted ? formatPct(pctOf(category.tokens, windowTokens)) : "—"; + return ( + + + + + + + {expandable && isOpen + ? sortedItems(category).map((item, index) => ( + + )) + : null} + + ); + })} + +
{t("ctxColName")}{t("ctxColTokens")}{t("ctxColPct")}
+ {expandable ? ( + + ) : ( + + + + {label} + + + )} + + {formatTokensCompact(category.tokens)} + {share}
+ ); +} + +function ItemRow({ + item, + windowTokens, + counted, + t, +}: { + item: ContextItem; + windowTokens: number | null | undefined; + counted: boolean; + t: T; +}) { + const notLoaded = item.loaded === false; + const tooltip = [item.name, item.detail].filter((part): part is string => !!part).join("\n"); + return ( + + + {truncateMiddle(item.name)} + {item.source ? {item.source} : null} + {notLoaded ? ( + + {t("ctxNotLoaded")} + + ) : null} + {item.detail ?
{truncateMiddle(item.detail, 56)}
: null} + + + {formatTokensCompact(item.tokens)} + + {counted && !notLoaded ? formatPct(pctOf(item.tokens, windowTokens)) : "—"} + + ); +} + +const SPLIT_LABEL_KEY: Record = { + tool_call: "ctxSplitToolCalls", + tool_result: "ctxSplitToolResults", + attachment: "ctxSplitAttachments", + assistant: "ctxSplitAssistant", + user: "ctxSplitUser", + other: "ctxSplitOther", +}; + +function MessagesSection({ messages, t }: { messages: ContextMessages; t: T }) { + const split = messageSplit(messages); + const total = messagesTotal(messages); + const tools = toolRows(messages); + const attachments = attachmentRows(messages); + if (split.length === 0 && tools.length === 0 && attachments.length === 0) return null; + return ( +
+

+ {t("ctxMessages")} + {total > 0 ? ( + + {formatTokensCompact(total)} + + ) : null} +

+ {split.length > 0 ? ( + + + {split.map((row) => { + const share = pctOf(row.tokens, total); + return ( + + + + + + ); + })} + +
+ {t(SPLIT_LABEL_KEY[row.key])} + + + + + {formatTokensCompact(row.tokens)} + {formatPct(share)}
+ ) : null} + {tools.length > 0 ? ( + <> +
{t("ctxTools")}
+ + + + + + + + + + + {tools.map((tool) => ( + + + + + + + ))} + +
{t("ctxColTool")}{t("ctxColCall")}{t("ctxColResult")}{t("ctxColTotal")}
+ {tool.name} + + {formatTokensCompact(tool.call)} + + {formatTokensCompact(tool.result)} + + {formatTokensCompact(tool.total)} +
+ + ) : null} + {attachments.length > 0 ? ( + <> +
{t("ctxAttachments")}
+ + + + + + + + + + {attachments.map((attachment) => ( + + + + + + ))} + +
{t("ctxColType")}{t("ctxColTokens")}{t("ctxColOfMessages")}
+ {attachment.name} + + {formatTokensCompact(attachment.tokens)} + {formatPct(pctOf(attachment.tokens, total))}
+ + ) : null} +
+ ); +} + +function LastRequest({ last, t }: { last: ContextLastRequest; t: T }) { + const ratio = cacheHitRatio(last); + return ( +
+

{t("ctxLastRequest")}

+
+ {ratio !== null ? ( +
+ {t("ctxCacheHit")} {formatPct(ratio * 100)} +
+ ) : null} +
+ + {t("ctxCacheRead")} {formatTokensCompact(last.cache_read_tokens)} + + {" · "} + + {t("ctxCacheWrite")} {formatTokensCompact(last.cache_creation_tokens)} + + {" · "} + + {t("ctxUncached")} {formatTokensCompact(last.input_tokens)} + + {" · "} + + {t("ctxOutput")} {formatTokensCompact(last.output_tokens)} + +
+
+
+ ); +} diff --git a/crates/ccteam-web/web/src/components/StepInspector.tsx b/crates/ccteam-web/web/src/components/StepInspector.tsx new file mode 100644 index 000000000..2c5e7b6f8 --- /dev/null +++ b/crates/ccteam-web/web/src/components/StepInspector.tsx @@ -0,0 +1,129 @@ +// Step inspector — the second layer of the web activity view. The folded +// activity row expands into its step list (layer one, in the transcript); +// picking a step opens this panel with the step in FULL: a tool call's input +// and output, or a reasoning block's text. The full record is read on demand +// from the session's step log (`GET /api/v1/sessions/{sid}/steps/{step_id}`), +// so megabytes of tool output never ride the SSE stream or localStorage. +// Wide screens dock it as a right column; narrow ones overlay it (CSS). + +import { useEffect, useState } from "react"; +import { X } from "lucide-react"; +import { makeT, type Lang } from "../lib/i18n"; +import { getStep } from "../lib/sessionsApi"; +import { failedLoad, initialLoad, shouldRetry, STEP_RETRY_MS, type StepLoad } from "../lib/stepLoad"; +import { stepIcon, type FoldStep } from "../pages/chatTranscript"; + +export function StepInspector({ + sid, + step, + lang, + onClose, +}: { + sid: string; + step: FoldStep; + lang: Lang; + onClose: () => void; +}) { + const t = makeT(lang); + // The fetched result is kept with the key it answers, so a step switch or + // a running step finishing reads as "loading" until its own answer lands — + // derived, never reset from inside the effect. A step the server has not + // recorded yet is asked for again every few seconds while the panel is + // open: the local list can be stale (a reload mid-tool missed the + // completion frame), so the server's answer is the one that counts. + const key = `${sid}|${step.step_id}|${step.done}`; + const early = initialLoad(step); + const [fetched, setFetched] = useState<{ key: string; load: StepLoad } | null>(null); + useEffect(() => { + if (!step.step_id) return; + const ctrl = new AbortController(); + let timer: ReturnType | undefined; + const ask = () => { + getStep(sid, step.step_id, { signal: ctrl.signal }) + .then((record) => setFetched({ key, load: { state: "ready", record } })) + .catch((err: unknown) => { + if (ctrl.signal.aborted) return; + const load = failedLoad(err, step.done); + setFetched({ key, load }); + if (shouldRetry(load)) timer = setTimeout(ask, STEP_RETRY_MS); + }); + }; + ask(); + return () => { + ctrl.abort(); + if (timer) clearTimeout(timer); + }; + }, [key, sid, step.step_id, step.done]); + const load: StepLoad = + early ?? (fetched?.key === key ? fetched.load : { state: "loading" }); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const thinking = step.kind === "thinking"; + const title = thinking ? t("stepThinking") : step.name || step.kind; + + return ( + + ); +} diff --git a/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts b/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts index d75cb4209..9df29d368 100644 --- a/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts +++ b/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts @@ -119,6 +119,7 @@ export function parseAgentsEvent(raw: string): AgentsEvent | null { summary: typeof a.summary === "string" ? a.summary : "", status: typeof a.status === "string" ? a.status : "", item_id: typeof a.item_id === "string" ? a.item_id : "", + step_id: typeof a.step_id === "string" ? a.step_id : "", }; } if (typeof obj.relation === "string") event.relation = obj.relation; diff --git a/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts b/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts index e2d53212f..b716b1ca2 100644 --- a/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts +++ b/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts @@ -313,6 +313,7 @@ describe("parseSessionEvent (W2 payload shape)", () => { summary: "Bash(ls -la)", status: "started", item_id: "t1", + step_id: "n-1-t1", }, }), ); @@ -324,6 +325,7 @@ describe("parseSessionEvent (W2 payload shape)", () => { summary: "Bash(ls -la)", status: "started", item_id: "t1", + step_id: "n-1-t1", }); }); @@ -346,6 +348,7 @@ describe("parseSessionEvent (W2 payload shape)", () => { summary: "", status: "", item_id: "", + step_id: "", }); }); diff --git a/crates/ccteam-web/web/src/hooks/useSessionEvents.ts b/crates/ccteam-web/web/src/hooks/useSessionEvents.ts index 66bad78c0..382575771 100644 --- a/crates/ccteam-web/web/src/hooks/useSessionEvents.ts +++ b/crates/ccteam-web/web/src/hooks/useSessionEvents.ts @@ -40,7 +40,8 @@ export interface SessionEventOption { * `web_search` / `tool_result`); `name` is the tool/category name (empty * for thinking); `summary` is the one-line preview; `status` is the * lifecycle phase (`started` / `completed` / `update`); `item_id` lets the - * UI dedup/merge a start↔complete pair. Mirrors the Rust `SessionActivity` + * UI dedup/merge a start↔complete pair; `step_id` names its full record. + * Mirrors the Rust `SessionActivity` * serde shape. */ export interface SessionActivity { kind: string; @@ -48,6 +49,11 @@ export interface SessionActivity { summary: string; status: string; item_id: string; + /** The step's id in the session's step log — what the step inspector + * fetches the full input / output / reasoning by + * (`GET /api/v1/sessions/{sid}/steps/{step_id}`). Empty from an older + * daemon. */ + step_id: string; } /** One event line off the per-session SSE stream (the W2 payload shape). @@ -262,6 +268,7 @@ export function parseSessionEvent(raw: string): SessionEvent | null { summary: typeof a.summary === "string" ? a.summary : "", status: typeof a.status === "string" ? a.status : "", item_id: typeof a.item_id === "string" ? a.item_id : "", + step_id: typeof a.step_id === "string" ? a.step_id : "", }; } const options = parseEventOptions(obj.options); diff --git a/crates/ccteam-web/web/src/index.css b/crates/ccteam-web/web/src/index.css index c8e666d22..07163d9b8 100644 --- a/crates/ccteam-web/web/src/index.css +++ b/crates/ccteam-web/web/src/index.css @@ -692,6 +692,46 @@ html.dark .dot.off { background: #4b4e57; } font-family: var(--font-mono); font-size: 11.5px; color: var(--text-faint); background: transparent; border-radius: 0; } +/* Step view, layer one: a folded activity row expands into its step list. */ +.msg.activity .fold-toggle { + display: flex; align-items: baseline; gap: 6px; width: 100%; text-align: left; + cursor: pointer; border: none; border-left: 2px solid var(--border); +} +.msg.activity .fold-toggle:hover { color: var(--text-muted); border-left-color: var(--text-faint); } +.fold-caret { font-size: 10px; width: 10px; flex: none; } +.fold-steps { list-style: none; margin: 2px 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 1px; } +.fold-step { + display: flex; align-items: baseline; gap: 8px; width: 100%; text-align: left; + background: none; border: none; border-radius: 6px; padding: 3px 8px; cursor: pointer; + font-family: var(--font-mono); font-size: 11.5px; color: var(--text-muted); +} +.fold-step:hover { background: var(--bg-soft); } +.fold-step.selected { background: var(--bg-soft); color: var(--text); } +.fold-step.thinking { color: var(--text-faint); font-style: italic; } +.fold-step-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fold-step-state { flex: none; color: var(--text-faint); } +/* Step view, layer two: the inspector docks right on wide screens and + overlays the transcript on narrow ones. */ +.step-inspector { + width: min(460px, 42%); flex: none; display: flex; flex-direction: column; min-height: 0; + border-left: 1px solid var(--border); background: var(--bg-card); +} +.step-inspector-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); } +.step-inspector-head .step-title { flex: 1; min-width: 0; font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.step-inspector-head .step-state { font-family: var(--font-mono); font-size: 12px; color: var(--text-faint); } +.step-inspector-head .step-state.done { color: var(--green-text); } +.step-inspector-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 14px; } +.step-inspector-body h3 { margin: 0 0 6px; font-size: 11.5px; font-weight: 600; color: var(--text-faint); text-transform: uppercase; letter-spacing: .04em; } +.step-inspector-body pre { + margin: 0; padding: 10px; border-radius: 8px; background: var(--bg-soft); + font-family: var(--font-mono); font-size: 12px; line-height: 1.55; + white-space: pre-wrap; word-break: break-word; max-height: 60vh; overflow: auto; +} +.step-inspector-body pre.thinking { font-style: italic; color: var(--text-muted); } +.step-inspector-body .step-note { margin: 0; font-size: 12px; color: var(--text-faint); } +@media (max-width: 900px) { + .step-inspector { position: absolute; inset: 0; width: auto; z-index: 5; border-left: none; } +} .msg.system { align-self: center; max-width: 100%; } .msg.system .bubble { background: transparent; color: var(--text-faint); font-size: 12px; text-align: center; padding: 2px 8px; } .msg.approval .bubble { @@ -1436,3 +1476,123 @@ select:focus-visible, .dsh-empty code { font-family: var(--font-mono); font-size: 12px; background: var(--bg-hover); padding: 3px 9px; border-radius: 6px; color: var(--text); } .dsh-spin { width: 30px; height: 30px; border: 3px solid var(--border); border-top-color: var(--dsh); border-radius: 50%; animation: att-spin .8s linear infinite; } .dsh-error-tail { font-family: var(--font-mono); font-size: 11.5px; color: var(--red-text); background: var(--red-soft); border-radius: 8px; padding: 9px 12px; max-width: 540px; white-space: pre-wrap; text-align: left; max-height: 150px; overflow: auto; margin: 0; } + +/* ============ CONTEXT PANEL — per-session right sidebar ============ + Docked beside the chat (`.conv-body` = [`.conv-main` | `.ctx-panel`]); + ≤820px it becomes a right-edge overlay drawer with a backdrop. Skin follows + conv-head / agents-panel. The eight categorical slots are the validated + dataviz default palette (adjacent-pair CVD ΔE ≥ 8 in both modes; the dark + column is the same hues re-stepped for the dark surface, not a flip); a + category's slot is fixed by id in `lib/contextBreakdown.ts`. */ +:root { + --ctx-1: #2a78d6; --ctx-2: #eb6834; --ctx-3: #1baf7a; --ctx-4: #eda100; + --ctx-5: #e87ba4; --ctx-6: #008300; --ctx-7: #6250d6; --ctx-8: #e34948; +} +html.dark { + --ctx-1: #3987e5; --ctx-2: #d95926; --ctx-3: #199e70; --ctx-4: #c98500; + --ctx-5: #d55181; --ctx-6: #008300; --ctx-7: #9085e9; --ctx-8: #e66767; +} +.conv-body { flex: 1; min-height: 0; display: flex; flex-direction: row; position: relative; } +.conv-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } +.ctx-panel { + width: 360px; flex: none; min-height: 0; display: flex; flex-direction: column; + border-left: 1px solid var(--border-soft); background: var(--bg-side); outline: none; +} +.ctx-head { + display: flex; align-items: center; gap: 4px; flex: none; + padding: 9px 10px 9px 16px; border-bottom: 1px solid var(--border-soft); +} +.ctx-head .ctx-title { font-size: 13px; font-weight: 600; margin-right: auto; } +.ctx-body { + flex: 1; min-height: 0; overflow-y: auto; padding: 14px 16px 22px; + display: flex; flex-direction: column; gap: 16px; font-size: 12.5px; +} +.ctx-backdrop { display: none; } +/* The header chip doubles as the entry point; the dedicated toggle lives next + to the cost pill and shows the docked state. */ +.chip-btn { background: none; cursor: pointer; line-height: inherit; } +.chip-btn:hover, .chip-btn:focus-visible { background: var(--bg-soft); color: var(--text); } +.ctx-toggle.on { background: var(--bg-soft); color: var(--text); } +/* header line: model · used / window · live|snapshot */ +.ctx-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; } +.ctx-usage { font: 12px var(--font-mono); color: var(--text-muted); white-space: nowrap; } +.ctx-badge { + font-size: 10.5px; padding: 1px 7px; border-radius: 999px; + border: 1px solid var(--border); color: var(--text-muted); white-space: nowrap; +} +.ctx-badge.live { color: var(--green-text); background: var(--green-soft); border-color: transparent; } +/* the stacked window bar: 2px surface gaps between fills, free space = the track */ +.ctx-meter-wrap { position: relative; padding: 3px 0; } +.ctx-meter { display: flex; gap: 2px; height: 14px; border-radius: 4px; overflow: hidden; background: var(--bg-soft); } +.ctx-seg { flex: 0 1 auto; min-width: 3px; height: 100%; border-radius: 2px; } +.ctx-seg.other { background: var(--text-faint); } +.ctx-seg.buffer { background: repeating-linear-gradient(135deg, var(--text-faint) 0 2px, transparent 2px 6px); opacity: .75; } +.ctx-seg.free { flex: 1 1 0; min-width: 0; background: transparent; } +.ctx-threshold { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: var(--ink); opacity: .55; } +/* sections */ +.ctx-sec h4 { + display: flex; align-items: baseline; gap: 8px; margin: 0 0 6px; + font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: .6px; +} +.ctx-sec h4 .ctx-h-num { font: 11px var(--font-mono); color: var(--text-muted); text-transform: none; letter-spacing: 0; } +.ctx-sec h5 { margin: 12px 0 4px; font-size: 11.5px; font-weight: 500; color: var(--text-muted); } +.ctx-note { margin: 0 0 6px; font-size: 11.5px; line-height: 1.5; color: var(--text-faint); } +.ctx-table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.ctx-table th { + padding: 2px 4px; text-align: left; font-size: 10.5px; font-weight: 500; + color: var(--text-faint); border-bottom: 1px solid var(--border-soft); +} +.ctx-table td { padding: 4px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; color: var(--text); } +.ctx-table .num { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; width: 60px; } +.ctx-table .total { font-weight: 600; } +.ctx-table .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ctx-table .name.mono { font-family: var(--font-mono); font-size: 11.5px; } +.ctx-tools .num { width: 52px; } +.ctx-sw { display: inline-block; flex: none; width: 9px; height: 9px; border-radius: 2px; margin-right: 7px; vertical-align: -1px; } +.ctx-sw.other { background: var(--text-faint); } +.ctx-sw.buffer { background: repeating-linear-gradient(135deg, var(--text-faint) 0 2px, transparent 2px 4px); } +.ctx-sw.free { border: 1px solid var(--border); background: var(--bg-soft); } +.ctx-sw.none { border: 1px dashed var(--border); } +.ctx-row-btn, .ctx-row-static { display: flex; align-items: center; gap: 2px; width: 100%; min-width: 0; } +.ctx-row-btn { background: none; border: none; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.ctx-row-btn svg { width: 12px; height: 12px; flex: none; color: var(--text-faint); margin-left: -2px; } +.ctx-row-btn:hover .name, .ctx-row-btn:focus-visible .name { text-decoration: underline; text-underline-offset: 2px; } +.ctx-row-btn .name, .ctx-row-static .name { flex: 1; } +.ctx-row-static { padding-left: 12px; } +.ctx-count { font: 10.5px var(--font-mono); color: var(--text-faint); flex: none; } +.ctx-cat.buffer td, .ctx-cat.free td { color: var(--text-muted); } +.ctx-item td { padding-left: 26px; color: var(--text-muted); font-size: 12px; background: var(--bg-soft); } +.ctx-item td.num { padding-left: 4px; } +.ctx-item .ctx-item-name { font-family: var(--font-mono); font-size: 11.5px; } +.ctx-item .ctx-item-detail { font: 10.5px var(--font-mono); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ctx-tag { + display: inline-block; margin-left: 6px; padding: 0 5px; border-radius: 4px; vertical-align: 1px; + border: 1px solid var(--border); font-size: 10px; color: var(--text-faint); white-space: nowrap; +} +.ctx-tag.warn { color: var(--accent, #b45309); border-color: currentColor; } +/* deferred = listed, muted, never in the bar */ +.ctx-muted .ctx-table td { color: var(--text-faint); } +.ctx-muted .ctx-row-btn .name { color: var(--text-muted); } +.ctx-mini { display: block; height: 3px; margin-top: 3px; border-radius: 2px; background: var(--bg-hover); overflow: hidden; } +.ctx-mini > span { display: block; height: 100%; background: var(--text-faint); border-radius: 2px; } +.ctx-cache { font: 12px var(--font-mono); color: var(--text-muted); line-height: 1.7; } +.ctx-cache b { color: var(--text); font-weight: 600; } +.ctx-empty { color: var(--text-muted); font-size: 13px; line-height: 1.6; } +.ctx-empty p { margin: 0 0 8px; } +.ctx-loading { color: var(--text-faint); font-size: 12.5px; } +.ctx-error { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: 8px; font-size: 12px; color: var(--red-text); background: var(--red-soft); +} +@keyframes ctx-spin { to { transform: rotate(360deg); } } +.ctx-spin { animation: ctx-spin 1s linear infinite; } +@media (prefers-reduced-motion: reduce) { .ctx-spin { animation: none; opacity: .5; } } +@media (max-width: 1100px) { .ctx-panel { width: 320px; } } +@media (max-width: 820px) { + .ctx-panel { + position: fixed; top: 0; right: 0; bottom: 0; z-index: 110; + width: min(92vw, 380px); background: var(--bg-card); box-shadow: var(--shadow-menu); + border-left: 1px solid var(--border); + } + .ctx-backdrop { display: block; position: fixed; inset: 0; z-index: 105; border: none; background: rgba(0,0,0,.28); } +} diff --git a/crates/ccteam-web/web/src/lib/contextApi.test.ts b/crates/ccteam-web/web/src/lib/contextApi.test.ts new file mode 100644 index 000000000..f517ce8b4 --- /dev/null +++ b/crates/ccteam-web/web/src/lib/contextApi.test.ts @@ -0,0 +1,89 @@ +// `GET /api/v1/sessions/{sid}/context` client — URL, auth posture, error +// mapping, and tolerance for the contract's nullable / unknown fields. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getSessionContext, sessionContextUrl } from "./contextApi"; + +const realFetch = globalThis.fetch; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("contextApi", () => { + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + afterEach(() => { + globalThis.fetch = realFetch; + vi.restoreAllMocks(); + }); + + it("builds the per-session URL with an encoded sid", () => { + expect(sessionContextUrl("s12")).toBe("/api/v1/sessions/s12/context"); + expect(sessionContextUrl("a/b")).toBe("/api/v1/sessions/a%2Fb/context"); + }); + + it("GETs with same-origin creds and returns the body verbatim (unknown fields kept)", async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + jsonResponse(200, { + sid: "s12", + live: true, + breakdown: { vendor: "claude", categories: [], brand_new_field: 1 }, + extra_top_level: "ignored", + }), + ); + const got = await getSessionContext("s12"); + expect(globalThis.fetch).toHaveBeenCalledWith("/api/v1/sessions/s12/context", { + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + expect(got.sid).toBe("s12"); + expect(got.live).toBe(true); + expect(got.breakdown?.vendor).toBe("claude"); + }); + + it("passes a null breakdown through (no breakdown is a fact, not an error)", async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + jsonResponse(200, { sid: "s3", live: false, breakdown: null }), + ); + const got = await getSessionContext("s3"); + expect(got.breakdown).toBeNull(); + expect(got.live).toBe(false); + }); + + it("maps 401 → UNAUTHENTICATED and 404 → NOT_FOUND", async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce(jsonResponse(401, {})); + await expect(getSessionContext("s12")).rejects.toThrow("UNAUTHENTICATED"); + vi.mocked(globalThis.fetch).mockResolvedValueOnce(jsonResponse(404, { error: "unknown sid" })); + await expect(getSessionContext("s12")).rejects.toThrow("NOT_FOUND"); + }); + + it("lifts the server's reason on 503 (no gateway) and other failures", async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + jsonResponse(503, { error: "no gateway (standalone web)" }), + ); + await expect(getSessionContext("s12")).rejects.toThrow("HTTP 503: no gateway (standalone web)"); + }); + + it("wraps a network failure", async () => { + vi.mocked(globalThis.fetch).mockRejectedValueOnce(new TypeError("Failed to fetch")); + await expect(getSessionContext("s12")).rejects.toThrow("network: Failed to fetch"); + }); + + it("forwards an AbortSignal when given", async () => { + const controller = new AbortController(); + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + jsonResponse(200, { sid: "s12", live: true, breakdown: null }), + ); + await getSessionContext("s12", { signal: controller.signal }); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/v1/sessions/s12/context", + expect.objectContaining({ signal: controller.signal }), + ); + }); +}); diff --git a/crates/ccteam-web/web/src/lib/contextApi.ts b/crates/ccteam-web/web/src/lib/contextApi.ts new file mode 100644 index 000000000..ee6706d94 --- /dev/null +++ b/crates/ccteam-web/web/src/lib/contextApi.ts @@ -0,0 +1,120 @@ +// Per-session context-window breakdown — `GET /api/v1/sessions/{sid}/context`. +// +// What the window is MADE OF, mechanically (no model asked): system prompt, +// tool schemas, memory files, skills, MCP tools, and — inside the +// conversation — how much each tool's calls/results and each attachment type +// cost. The gateway asks the harness when the session is resident (`live: +// true`); otherwise it serves the snapshot persisted at the end of the last +// turn (`live: false`, dated by `captured_at`). `breakdown: null` = this +// harness offers no breakdown and no snapshot exists (non-Claude harnesses, +// terminal-protocol or remote-host sessions, or a session that has not +// finished a turn since the feature shipped) — a fact to explain, not an +// error. +// +// Every field below the top level is optional/nullable on purpose: the panel +// degrades section by section, and unknown extra fields are ignored. + +import { httpError } from "./httpError"; +import { sessionUrl } from "./sessionsApi"; + +/** `used` occupies the window; `deferred` = tool schemas NOT loaded (never + * counted — list them apart, muted); `buffer` = reserved for auto-compaction; + * `free` = headroom. Unknown kinds render as plain rows, outside the bar. */ +export type ContextCategoryKind = "used" | "deferred" | "buffer" | "free"; + +export interface ContextItem { + name: string; + /** Where it came from (`ccteam`, `Project`, `User`, `projectSettings`…). */ + source?: string | null; + /** Secondary line — usually a path. */ + detail?: string | null; + tokens?: number | null; + /** `false` = present but NOT loaded into the context (deferred schema). */ + loaded?: boolean | null; +} + +export interface ContextCategory { + id: string; + name: string; + tokens: number; + kind: ContextCategoryKind | string; + items?: ContextItem[] | null; +} + +export interface ContextToolUsage { + name: string; + call_tokens?: number | null; + result_tokens?: number | null; +} + +export interface ContextAttachmentUsage { + name: string; + tokens?: number | null; +} + +export interface ContextMessages { + tool_call_tokens?: number | null; + tool_result_tokens?: number | null; + attachment_tokens?: number | null; + assistant_tokens?: number | null; + user_tokens?: number | null; + other_tokens?: number | null; + tools?: ContextToolUsage[] | null; + attachments?: ContextAttachmentUsage[] | null; +} + +/** The API's real accounting for the most recent request. Cache hit ratio = + * `cache_read / (cache_read + cache_creation + input)`. */ +export interface ContextLastRequest { + input_tokens?: number | null; + output_tokens?: number | null; + cache_creation_tokens?: number | null; + cache_read_tokens?: number | null; +} + +export interface ContextBreakdown { + /** Harness id on the wire (`claude`…); the UI says "harness". */ + vendor?: string | null; + model?: string | null; + /** RFC 3339 — when these numbers were taken. */ + captured_at?: string | null; + used_tokens?: number | null; + window_tokens?: number | null; + auto_compact_threshold?: number | null; + categories?: ContextCategory[] | null; + messages?: ContextMessages | null; + last_request?: ContextLastRequest | null; +} + +export interface SessionContextResponse { + sid: string; + live: boolean; + breakdown: ContextBreakdown | null; +} + +export function sessionContextUrl(sid: string): string { + return `${sessionUrl(sid)}/context`; +} + +/** Same auth + error mapping as the other `/api/v1/sessions/{sid}/*` reads: + * 401 → `UNAUTHENTICATED`, 404 (unknown sid) → `NOT_FOUND`, anything else + * non-2xx (503 = no gateway) lifts the server's `{error}` body. */ +export async function getSessionContext( + sid: string, + options: { signal?: AbortSignal } = {}, +): Promise { + let res: Response; + try { + res = await fetch(sessionContextUrl(sid), { + headers: { Accept: "application/json" }, + credentials: "same-origin", + ...(options.signal ? { signal: options.signal } : {}), + }); + } catch (e) { + throw new Error(`network: ${e instanceof Error ? e.message : "connection failed"}`); + } + if (res.status === 401) throw new Error("UNAUTHENTICATED"); + if (res.status === 404) throw new Error("NOT_FOUND"); + if (!res.ok) throw await httpError(res); + return (await res.json()) as SessionContextResponse; +} diff --git a/crates/ccteam-web/web/src/lib/contextBreakdown.test.ts b/crates/ccteam-web/web/src/lib/contextBreakdown.test.ts new file mode 100644 index 000000000..ebd7d9933 --- /dev/null +++ b/crates/ccteam-web/web/src/lib/contextBreakdown.test.ts @@ -0,0 +1,346 @@ +// Pure rules behind the context panel: compact numbers, the bar's segments +// (deferred excluded), table order, message/tool/attachment sorting, the +// cache-hit ratio, middle truncation, relative age, and the per-browser +// open/closed memory. + +import { describe, expect, it } from "vitest"; + +import type { ContextBreakdown } from "./contextApi"; +import { + attachmentRows, + barSegments, + cacheHitRatio, + categoryLabel, + categorySlot, + CONTEXT_PANEL_OPEN_KEY, + formatPct, + formatTokensCompact, + formatTokensExact, + isNarrowViewport, + messageSplit, + messagesTotal, + pctOf, + readContextPanelOpen, + relativeTime, + splitCategories, + tableRows, + thresholdPct, + toolRows, + truncateMiddle, + usedPct, + writeContextPanelOpen, +} from "./contextBreakdown"; + +export const FIXTURE: ContextBreakdown = { + vendor: "claude", + model: "claude-sonnet-5-5", + captured_at: "2026-10-21T08:00:00Z", + used_tokens: 253_770, + window_tokens: 1_000_000, + auto_compact_threshold: 967_000, + categories: [ + { id: "system_prompt", name: "System prompt", tokens: 2247, kind: "used", items: [] }, + { id: "system_tools", name: "System tools", tokens: 6714, kind: "used", items: [] }, + { + id: "mcp_tools", + name: "MCP tools", + tokens: 941, + kind: "used", + items: [{ name: "mcp__ccteam__agent", source: "ccteam", detail: null, tokens: 941, loaded: true }], + }, + { + id: "mcp_tools_deferred", + name: "MCP tools (deferred)", + tokens: 1290, + kind: "deferred", + items: [{ name: "mcp__ccteam__agent_read", source: "ccteam", detail: null, tokens: 649, loaded: false }], + }, + { id: "system_tools_deferred", name: "System tools (deferred)", tokens: 12_011, kind: "deferred", items: [] }, + { + id: "memory_files", + name: "Memory files", + tokens: 15_169, + kind: "used", + items: [ + { name: "/home/u/.claude/CLAUDE.md", source: "User", detail: null, tokens: 103, loaded: null }, + { name: "/repo/CLAUDE.md", source: "Project", detail: null, tokens: 11_489, loaded: null }, + ], + }, + { id: "messages", name: "Messages", tokens: 237_146, kind: "used", items: [] }, + { id: "autocompact_buffer", name: "Autocompact buffer", tokens: 33_000, kind: "buffer", items: [] }, + { id: "free_space", name: "Free space", tokens: 713_230, kind: "free", items: [] }, + ], + messages: { + tool_call_tokens: 36_149, + tool_result_tokens: 62_805, + attachment_tokens: 69_528, + assistant_tokens: 67_354, + user_tokens: 1732, + other_tokens: 0, + tools: [ + { name: "mcp__ccteam__agent", call_tokens: 1220, result_tokens: 84 }, + { name: "Bash", call_tokens: 34_420, result_tokens: 61_749 }, + ], + attachments: [ + { name: "async_hook_response", tokens: 17_151 }, + { name: "prompt_snapshot", tokens: 26_483 }, + ], + }, + last_request: { input_tokens: 2, output_tokens: 1259, cache_creation_tokens: 1022, cache_read_tokens: 252_179 }, +}; + +describe("token / percent formatting", () => { + it("formats compactly with one decimal and no trailing .0", () => { + expect(formatTokensCompact(253_770)).toBe("253.8k"); + expect(formatTokensCompact(33_000)).toBe("33k"); + expect(formatTokensCompact(2247)).toBe("2.2k"); + expect(formatTokensCompact(941)).toBe("941"); + expect(formatTokensCompact(1_000_000)).toBe("1M"); + expect(formatTokensCompact(967_000)).toBe("967k"); + expect(formatTokensCompact(999_960)).toBe("1M"); + expect(formatTokensCompact(0)).toBe("0"); + }); + + it("renders unknown counts as a dash, never 0", () => { + expect(formatTokensCompact(null)).toBe("—"); + expect(formatTokensCompact(undefined)).toBe("—"); + expect(formatTokensCompact(Number.NaN)).toBe("—"); + expect(formatTokensExact(null)).toBe("—"); + }); + + it("exact numbers carry thousands separators for tooltips", () => { + expect(formatTokensExact(253_770)).toBe("253,770"); + }); + + it("pctOf is null without a positive whole", () => { + expect(pctOf(250, 1000)).toBe(25); + expect(pctOf(250, 0)).toBeNull(); + expect(pctOf(250, null)).toBeNull(); + expect(pctOf(null, 1000)).toBeNull(); + }); + + it("formatPct: one decimal, tiny values read <0.1%, null is a dash", () => { + expect(formatPct(25.377)).toBe("25.4%"); + expect(formatPct(71)).toBe("71%"); + expect(formatPct(99.6)).toBe("99.6%"); + expect(formatPct(0.04)).toBe("<0.1%"); + expect(formatPct(0)).toBe("0%"); + expect(formatPct(null)).toBe("—"); + }); +}); + +describe("categories", () => { + it("splits used / deferred / reserve, each sorted by tokens desc", () => { + const split = splitCategories(FIXTURE); + expect(split.used.map((c) => c.id)).toEqual([ + "messages", + "memory_files", + "system_tools", + "system_prompt", + "mcp_tools", + ]); + expect(split.deferred.map((c) => c.id)).toEqual(["system_tools_deferred", "mcp_tools_deferred"]); + expect(split.reserve.map((c) => c.id)).toEqual(["autocompact_buffer", "free_space"]); + expect(split.other).toEqual([]); + }); + + it("table rows = used desc, then buffer, then free — deferred never listed there", () => { + expect(tableRows(FIXTURE).map((c) => c.id)).toEqual([ + "messages", + "memory_files", + "system_tools", + "system_prompt", + "mcp_tools", + "autocompact_buffer", + "free_space", + ]); + }); + + it("unknown kinds are kept as plain rows after the reserve", () => { + const rows = tableRows({ + categories: [ + { id: "x", name: "X", tokens: 5, kind: "mystery" }, + { id: "messages", name: "Messages", tokens: 10, kind: "used" }, + ], + }); + expect(rows.map((c) => c.id)).toEqual(["messages", "x"]); + }); + + it("bar segments: used desc → buffer → free, deferred excluded, % of window", () => { + const segments = barSegments(FIXTURE); + expect(segments.map((s) => s.id)).toEqual([ + "messages", + "memory_files", + "system_tools", + "system_prompt", + "mcp_tools", + "autocompact_buffer", + "free_space", + ]); + expect(segments.find((s) => s.id === "mcp_tools_deferred")).toBeUndefined(); + expect(segments[0]).toMatchObject({ kind: "used", slot: 1 }); + expect(segments[0]!.pct).toBeCloseTo(23.7146, 6); + const buffer = segments.find((s) => s.id === "autocompact_buffer")!; + expect(buffer).toMatchObject({ kind: "buffer", slot: null }); + expect(buffer.pct).toBeCloseTo(3.3, 6); + expect(segments[segments.length - 1]).toMatchObject({ kind: "free", slot: null }); + expect(segments[segments.length - 1]!.pct).toBeCloseTo(71.323, 6); + }); + + it("bar segments fall back to their own sum when the window is unknown", () => { + const segments = barSegments({ + categories: [ + { id: "messages", name: "Messages", tokens: 75, kind: "used" }, + { id: "free_space", name: "Free", tokens: 25, kind: "free" }, + { id: "zero", name: "Zero", tokens: 0, kind: "used" }, + ], + }); + expect(segments.map((s) => [s.id, s.pct])).toEqual([ + ["messages", 75], + ["free_space", 25], + ]); + }); + + it("no categories → no bar", () => { + expect(barSegments({})).toEqual([]); + expect(barSegments({ categories: null })).toEqual([]); + }); + + it("colors follow the entity, unknown ids fold into neutral", () => { + expect(categorySlot("messages")).toBe(1); + expect(categorySlot("memory_files")).toBe(2); + expect(categorySlot("brand_new_category")).toBe(0); + const slots = ["messages", "memory_files", "system_tools", "skills", "system_prompt", "mcp_tools", "custom_agents", "mcp_instructions"].map(categorySlot); + expect(new Set(slots).size).toBe(8); + }); + + it("labels: zh maps known ids, en keeps the server name, unknown ids fall back", () => { + expect(categoryLabel({ id: "memory_files", name: "Memory files" }, "zh")).toBe("记忆文件"); + expect(categoryLabel({ id: "memory_files", name: "Memory files" }, "en")).toBe("Memory files"); + expect(categoryLabel({ id: "novel", name: "Novel thing" }, "zh")).toBe("Novel thing"); + }); + + it("usedPct / thresholdPct read off the window", () => { + expect(usedPct(FIXTURE)).toBeCloseTo(25.377, 3); + expect(thresholdPct(FIXTURE)).toBeCloseTo(96.7, 3); + expect(usedPct({ used_tokens: 5 })).toBeNull(); + expect(thresholdPct({ auto_compact_threshold: 5, window_tokens: 0 })).toBeNull(); + expect(thresholdPct({ auto_compact_threshold: 2000, window_tokens: 1000 })).toBe(100); + }); +}); + +describe("messages", () => { + it("splits the conversation into buckets, largest first, dropping a zero 'other'", () => { + expect(messageSplit(FIXTURE.messages!).map((r) => r.key)).toEqual([ + "attachment", + "assistant", + "tool_result", + "tool_call", + "user", + ]); + expect(messagesTotal(FIXTURE.messages!)).toBe(36_149 + 62_805 + 69_528 + 67_354 + 1732); + }); + + it("keeps a non-zero 'other' and omits null buckets", () => { + expect(messageSplit({ other_tokens: 9, user_tokens: null, assistant_tokens: 3 })).toEqual([ + { key: "other", tokens: 9 }, + { key: "assistant", tokens: 3 }, + ]); + expect(messageSplit({})).toEqual([]); + }); + + it("tool rows total call + result and sort by total desc", () => { + const rows = toolRows(FIXTURE.messages!); + expect(rows.map((r) => r.name)).toEqual(["Bash", "mcp__ccteam__agent"]); + expect(rows[0]).toEqual({ name: "Bash", call: 34_420, result: 61_749, total: 96_169 }); + expect(toolRows({ tools: [{ name: "Read" }] })).toEqual([{ name: "Read", call: 0, result: 0, total: 0 }]); + expect(toolRows({ tools: null })).toEqual([]); + }); + + it("attachments sort by tokens desc", () => { + expect(attachmentRows(FIXTURE.messages!).map((a) => a.name)).toEqual([ + "prompt_snapshot", + "async_hook_response", + ]); + expect(attachmentRows({})).toEqual([]); + }); +}); + +describe("last request", () => { + it("cache hit = read / (read + write + uncached input)", () => { + expect(cacheHitRatio(FIXTURE.last_request!)).toBeCloseTo(252_179 / (252_179 + 1022 + 2), 6); + }); + it("is null when nothing was read, written or sent", () => { + expect(cacheHitRatio({})).toBeNull(); + expect(cacheHitRatio({ output_tokens: 10 })).toBeNull(); + expect(cacheHitRatio({ cache_read_tokens: 0, input_tokens: 0 })).toBeNull(); + }); + it("treats missing halves as zero", () => { + expect(cacheHitRatio({ cache_read_tokens: 50, input_tokens: 50 })).toBe(0.5); + expect(cacheHitRatio({ input_tokens: 50 })).toBe(0); + }); +}); + +describe("text", () => { + it("truncateMiddle keeps head and tail, never longer than max", () => { + const path = "/home/ubuntu/rob/ccteam/.claude/agents/cto-the-very-long-name.md"; + const short = truncateMiddle(path, 30); + expect(short.length).toBeLessThanOrEqual(30); + expect(short.startsWith("/home/ubuntu")).toBe(true); + expect(short.endsWith("name.md")).toBe(true); + expect(short).toContain("…"); + expect(truncateMiddle("short", 30)).toBe("short"); + }); + + it("relativeTime in both languages; unparseable → null", () => { + const now = Date.parse("2026-10-21T10:00:00Z"); + expect(relativeTime("2026-10-21T09:59:40Z", now, "en")).toBe("just now"); + expect(relativeTime("2026-10-21T09:57:00Z", now, "en")).toBe("3 min ago"); + expect(relativeTime("2026-10-21T09:57:00Z", now, "zh")).toBe("3 分钟前"); + expect(relativeTime("2026-10-21T07:30:00Z", now, "en")).toBe("2 h ago"); + expect(relativeTime("2026-10-21T07:30:00Z", now, "zh")).toBe("2 小时前"); + expect(relativeTime("2026-10-18T07:30:00Z", now, "en")).toBe("3 d ago"); + expect(relativeTime("2026-10-18T07:30:00Z", now, "zh")).toBe("3 天前"); + expect(relativeTime("nope", now, "en")).toBeNull(); + expect(relativeTime(null, now, "en")).toBeNull(); + }); +}); + +describe("open/closed memory", () => { + function memoryStorage() { + const map = new Map(); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + map, + }; + } + + it("defaults closed, round-trips, and survives a throwing storage", () => { + const storage = memoryStorage(); + expect(readContextPanelOpen(storage)).toBe(false); + writeContextPanelOpen(storage, true); + expect(storage.map.get(CONTEXT_PANEL_OPEN_KEY)).toBe("1"); + expect(readContextPanelOpen(storage)).toBe(true); + writeContextPanelOpen(storage, false); + expect(readContextPanelOpen(storage)).toBe(false); + expect(readContextPanelOpen(null)).toBe(false); + const broken = { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + }; + expect(readContextPanelOpen(broken)).toBe(false); + expect(() => writeContextPanelOpen(broken, true)).not.toThrow(); + }); + + it("isNarrowViewport: no matchMedia (SSR / jsdom) counts as wide", () => { + expect(isNarrowViewport(undefined)).toBe(false); + expect(isNarrowViewport({})).toBe(false); + expect(isNarrowViewport({ matchMedia: () => ({ matches: true }) })).toBe(true); + expect(isNarrowViewport({ matchMedia: () => ({ matches: false }) })).toBe(false); + }); +}); diff --git a/crates/ccteam-web/web/src/lib/contextBreakdown.ts b/crates/ccteam-web/web/src/lib/contextBreakdown.ts new file mode 100644 index 000000000..8aa937f2e --- /dev/null +++ b/crates/ccteam-web/web/src/lib/contextBreakdown.ts @@ -0,0 +1,333 @@ +// Pure helpers behind the per-session context panel (`ContextPanel.tsx`): +// sorting, bar segments, category colors, cache ratio, token/percent/time +// formatting, and the per-browser open/closed memory. No React / DOM so the +// vitest suite exercises every rule directly (mirrors `statusLine.ts`). + +import type { + ContextAttachmentUsage, + ContextBreakdown, + ContextCategory, + ContextLastRequest, + ContextMessages, +} from "./contextApi"; +import type { Lang } from "./i18n"; + +// ---- tokens / percent ------------------------------------------------------ + +/** Compact token count: `253_770 → "253.8k"`, `33_000 → "33k"`, + * `1_000_000 → "1M"`, `941 → "941"`. One decimal, trailing `.0` dropped. + * Non-numbers / negatives render an em-dash — an unknown count is a + * different claim from zero. */ +export function formatTokensCompact(n: number | null | undefined): string { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return "—"; + const trim = (s: string) => s.replace(/\.0$/, ""); + if (n >= 999_950) return `${trim((n / 1_000_000).toFixed(1))}M`; + if (n >= 999.5) return `${trim((n / 1000).toFixed(1))}k`; + return `${Math.round(n)}`; +} + +/** Exact count with thousands separators for tooltips (`253,770`). */ +export function formatTokensExact(n: number | null | undefined): string { + if (typeof n !== "number" || !Number.isFinite(n)) return "—"; + return Math.round(n).toLocaleString("en-US"); +} + +/** `tokens / whole × 100`, or null when the whole is unknown / non-positive. */ +export function pctOf(tokens: number | null | undefined, whole: number | null | undefined): number | null { + if (typeof tokens !== "number" || !Number.isFinite(tokens)) return null; + if (typeof whole !== "number" || !Number.isFinite(whole) || whole <= 0) return null; + return (tokens / whole) * 100; +} + +/** One decimal, trailing `.0` dropped: `25.4%`, `71%`, `99.6%`. Values under + * a tenth of a percent (but above zero) read `<0.1%` rather than a misleading + * `0%`; null → em-dash. */ +export function formatPct(pct: number | null): string { + if (pct === null || !Number.isFinite(pct)) return "—"; + if (pct > 0 && pct < 0.1) return "<0.1%"; + return `${pct.toFixed(1).replace(/\.0$/, "")}%`; +} + +// ---- categories -------------------------------------------------------------- + +/** Categorical color slot per category id (1..8 → `--ctx-`; 0 → the + * neutral "other" grey). FIXED per entity, never assigned by rank, so a + * category keeps its color across sessions and refreshes. The eight hues are + * the validated dataviz default palette (adjacent-pair CVD ΔE ≥ 8 in both + * modes); any id not listed folds into neutral rather than minting a 9th hue. */ +const CATEGORY_SLOT: Record = { + messages: 1, + memory_files: 2, + system_tools: 3, + skills: 4, + system_prompt: 5, + mcp_tools: 6, + custom_agents: 7, + mcp_instructions: 8, +}; + +export function categorySlot(id: string): number { + return CATEGORY_SLOT[id] ?? 0; +} + +/** Localized display name for a known category id; the server's own `name` + * otherwise (English copy, so `en` always prefers it). */ +const CATEGORY_LABEL_ZH: Record = { + system_prompt: "系统提示词", + system_tools: "系统工具", + mcp_tools: "MCP 工具", + mcp_instructions: "MCP 服务说明", + mcp_tools_deferred: "MCP 工具(延迟)", + system_tools_deferred: "系统工具(延迟)", + custom_agents: "自定义 agent", + memory_files: "记忆文件", + skills: "Skills", + messages: "消息", + autocompact_buffer: "自动压缩预留", + free_space: "剩余空间", +}; + +export function categoryLabel(category: Pick, lang: Lang): string { + if (lang === "zh") return CATEGORY_LABEL_ZH[category.id] ?? category.name; + return category.name; +} + +function tokensOf(c: { tokens?: number | null }): number { + return typeof c.tokens === "number" && Number.isFinite(c.tokens) ? c.tokens : 0; +} + +const byTokensDesc = (a: T, b: T) => tokensOf(b) - tokensOf(a); + +export interface CategorySplit { + /** Occupies the window — sorted tokens desc. */ + used: ContextCategory[]; + /** Tool schemas NOT loaded — never counted; sorted tokens desc. */ + deferred: ContextCategory[]; + /** Auto-compact reserve(s), then free space — in that order. */ + reserve: ContextCategory[]; + /** Any kind the UI does not know; shown as plain rows, outside the bar. */ + other: ContextCategory[]; +} + +export function splitCategories(breakdown: ContextBreakdown): CategorySplit { + const all = breakdown.categories ?? []; + const used = all.filter((c) => c.kind === "used").sort(byTokensDesc); + const deferred = all.filter((c) => c.kind === "deferred").sort(byTokensDesc); + const reserve = [ + ...all.filter((c) => c.kind === "buffer"), + ...all.filter((c) => c.kind === "free"), + ]; + const other = all.filter( + (c) => c.kind !== "used" && c.kind !== "deferred" && c.kind !== "buffer" && c.kind !== "free", + ); + return { used, deferred, reserve, other }; +} + +/** The category table order: used (desc) → buffer → free → unknown kinds. + * Deferred categories are NOT here — they get their own muted list. */ +export function tableRows(breakdown: ContextBreakdown): ContextCategory[] { + const split = splitCategories(breakdown); + return [...split.used, ...split.reserve, ...split.other]; +} + +export interface BarSegment { + id: string; + name: string; + tokens: number; + kind: "used" | "buffer" | "free"; + /** Share of the window, 0..100. */ + pct: number; + /** Color slot (used only); null for buffer / free. */ + slot: number | null; +} + +/** One stacked bar of the window: used categories (desc, each its own color), + * then buffer, then free. Deferred categories are excluded — they do not + * occupy context. Zero-token segments are dropped (nothing to paint). The + * denominator is `window_tokens`; when the server omits it, the segments' + * own sum stands in so the bar still reads as part-to-whole. */ +export function barSegments(breakdown: ContextBreakdown): BarSegment[] { + const { used, reserve } = splitCategories(breakdown); + const ordered: Array = [ + ...used.map((c) => ({ ...c, kind: "used" as const })), + ...reserve.map((c) => ({ ...c, kind: c.kind === "buffer" ? ("buffer" as const) : ("free" as const) })), + ].filter((c) => tokensOf(c) > 0); + const sum = ordered.reduce((acc, c) => acc + tokensOf(c), 0); + const whole = + typeof breakdown.window_tokens === "number" && breakdown.window_tokens > 0 + ? breakdown.window_tokens + : sum; + if (whole <= 0) return []; + return ordered.map((c) => ({ + id: c.id, + name: c.name, + tokens: tokensOf(c), + kind: c.kind, + pct: (tokensOf(c) / whole) * 100, + slot: c.kind === "used" ? categorySlot(c.id) : null, + })); +} + +/** Window occupancy in percent — `used_tokens / window_tokens`. */ +export function usedPct(breakdown: ContextBreakdown): number | null { + return pctOf(breakdown.used_tokens, breakdown.window_tokens); +} + +/** Where the auto-compact threshold sits on the bar (0..100), or null. */ +export function thresholdPct(breakdown: ContextBreakdown): number | null { + const pct = pctOf(breakdown.auto_compact_threshold, breakdown.window_tokens); + return pct === null ? null : Math.max(0, Math.min(100, pct)); +} + +export function hasItems(category: ContextCategory): boolean { + return Array.isArray(category.items) && category.items.length > 0; +} + +/** Items of a category, largest first. */ +export function sortedItems(category: ContextCategory) { + return [...(category.items ?? [])].sort(byTokensDesc); +} + +// ---- messages ---------------------------------------------------------------- + +export type MessageSplitKey = + | "tool_call" + | "tool_result" + | "attachment" + | "assistant" + | "user" + | "other"; + +export interface MessageSplitRow { + key: MessageSplitKey; + tokens: number; +} + +/** The five (six) buckets of the conversation, largest first. Buckets the + * server left null are omitted; a zero `other` is omitted too (noise). */ +export function messageSplit(messages: ContextMessages): MessageSplitRow[] { + const pairs: Array<[MessageSplitKey, number | null | undefined]> = [ + ["tool_call", messages.tool_call_tokens], + ["tool_result", messages.tool_result_tokens], + ["attachment", messages.attachment_tokens], + ["assistant", messages.assistant_tokens], + ["user", messages.user_tokens], + ["other", messages.other_tokens], + ]; + return pairs + .filter((entry): entry is [MessageSplitKey, number] => typeof entry[1] === "number" && Number.isFinite(entry[1])) + .filter(([key, tokens]) => !(key === "other" && tokens <= 0)) + .map(([key, tokens]) => ({ key, tokens })) + .sort(byTokensDesc); +} + +/** Sum of the known buckets — the denominator for "% of messages". */ +export function messagesTotal(messages: ContextMessages): number { + return messageSplit(messages).reduce((acc, row) => acc + row.tokens, 0); +} + +export interface ToolRow { + name: string; + call: number; + result: number; + total: number; +} + +/** Per-tool cost (call + result), largest total first. */ +export function toolRows(messages: ContextMessages): ToolRow[] { + return (messages.tools ?? []) + .map((tool) => { + const call = typeof tool.call_tokens === "number" ? tool.call_tokens : 0; + const result = typeof tool.result_tokens === "number" ? tool.result_tokens : 0; + return { name: tool.name, call, result, total: call + result }; + }) + .sort((a, b) => b.total - a.total); +} + +/** Attachment types, largest first. */ +export function attachmentRows(messages: ContextMessages): ContextAttachmentUsage[] { + return [...(messages.attachments ?? [])].sort(byTokensDesc); +} + +// ---- last request ------------------------------------------------------------ + +/** `cache_read / (cache_read + cache_creation + input)` in 0..1, or null when + * nothing was read, written or sent (no request to speak of). */ +export function cacheHitRatio(last: ContextLastRequest): number | null { + const read = typeof last.cache_read_tokens === "number" ? last.cache_read_tokens : 0; + const write = typeof last.cache_creation_tokens === "number" ? last.cache_creation_tokens : 0; + const input = typeof last.input_tokens === "number" ? last.input_tokens : 0; + const denominator = read + write + input; + if (denominator <= 0) return null; + return read / denominator; +} + +// ---- text ---------------------------------------------------------------------- + +/** Keep the head and the tail of a long path (`/repo/.cl…/cto.md`) so neither + * the project nor the file is lost; the full string rides the tooltip. */ +export function truncateMiddle(text: string, max = 44): string { + if (text.length <= max) return text; + const keep = Math.max(1, max - 1); + const head = Math.ceil(keep * 0.55); + const tail = keep - head; + return `${text.slice(0, head)}…${text.slice(text.length - tail)}`; +} + +/** `刚刚` / `3 分钟前` / `2 小时前` / `3 天前` (en: `just now` / `3 min ago` / + * `2 h ago` / `3 d ago`). Null for an unparseable timestamp. */ +export function relativeTime(iso: string | null | undefined, nowMs: number, lang: Lang): string | null { + if (!iso) return null; + const at = new Date(iso).getTime(); + if (Number.isNaN(at)) return null; + const diff = Math.max(0, nowMs - at); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return lang === "en" ? "just now" : "刚刚"; + if (minutes < 60) return lang === "en" ? `${minutes} min ago` : `${minutes} 分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return lang === "en" ? `${hours} h ago` : `${hours} 小时前`; + const days = Math.floor(hours / 24); + return lang === "en" ? `${days} d ago` : `${days} 天前`; +} + +// ---- open/closed memory ------------------------------------------------------ + +/** localStorage key for the panel's docked state (per browser, not per + * session: the panel is a workspace layout preference that should survive a + * session switch — SessionView remounts per sid and reads this back). */ +export const CONTEXT_PANEL_OPEN_KEY = "ccteam.contextPanel.open.v1"; + +type StorageLike = Pick; + +export function readContextPanelOpen(storage: StorageLike | null | undefined): boolean { + if (!storage) return false; + try { + return storage.getItem(CONTEXT_PANEL_OPEN_KEY) === "1"; + } catch { + return false; + } +} + +export function writeContextPanelOpen(storage: StorageLike | null | undefined, open: boolean): void { + if (!storage) return; + try { + storage.setItem(CONTEXT_PANEL_OPEN_KEY, open ? "1" : "0"); + } catch { + // Private mode / quota — the toggle still works for this page. + } +} + +/** ≤820px is the shell's phone breakpoint (the sidebar becomes a drawer). On + * such a viewport the panel is an overlay that covers the chat, so a + * remembered "open" is NOT restored automatically — the user re-opens it. + * SSR / jsdom (no matchMedia) count as wide. */ +export const NARROW_VIEWPORT_QUERY = "(max-width: 820px)"; + +export function isNarrowViewport(win: { matchMedia?: (q: string) => { matches: boolean } } | undefined): boolean { + if (!win || typeof win.matchMedia !== "function") return false; + try { + return win.matchMedia(NARROW_VIEWPORT_QUERY).matches; + } catch { + return false; + } +} diff --git a/crates/ccteam-web/web/src/lib/i18n.ts b/crates/ccteam-web/web/src/lib/i18n.ts index 7b74a89b4..69e7ab5a1 100644 --- a/crates/ccteam-web/web/src/lib/i18n.ts +++ b/crates/ccteam-web/web/src/lib/i18n.ts @@ -74,7 +74,64 @@ export const I18N: Record> = { noMatch: "没有匹配的会话", connLost: "连接已断开", historyLoadFailed: "历史加载失败,当前显示可能不完整", + stepsShow: "展开步骤", + stepsHide: "收起步骤", + stepInspector: "步骤详情", + stepInput: "输入", + stepOutput: "输出", + stepThinking: "思考", + stepRunning: "仍在运行 —— 完成后显示完整输出", + stepUnavailable: "该步骤没有完整记录(旧版守护进程或未落盘)", + stepNoOutput: "(无输出)", + stepTruncated: "内容过长,已截断", + stepLoading: "加载中…", + close: "关闭", retry: "重试", + // Context panel (per-session right sidebar — what the window is made of). + ctxPanelTitle: "上下文", + ctxOpen: "查看上下文构成", + ctxClosePanel: "关闭上下文面板", + ctxRefresh: "刷新", + ctxRefreshing: "刷新中…", + ctxLoading: "加载中…", + ctxLoadFailed: "上下文加载失败", + ctxLive: "实时", + ctxSnapshot: "快照", + ctxCapturedAt: "采集于", + ctxModel: "模型", + ctxTokens: "tokens", + ctxBarLabel: "上下文窗口构成", + ctxAutoCompactAt: "自动压缩阈值", + ctxNoBreakdown: "这个会话还没有上下文构成数据。", + ctxNoBreakdownWhy: "只有 Claude harness 的 stream-json 本机会话会上报构成;其他 harness、terminal 协议和远程主机上的会话没有这项数据。会话还需要在本功能上线后完成一个 turn,这里才会出现快照。", + ctxCategories: "构成", + ctxColName: "类别", + ctxColTokens: "tokens", + ctxColPct: "占窗口", + ctxDeferred: "延迟加载的工具(未计入)", + ctxDeferredNote: "这些工具 schema 尚未载入上下文,不占用窗口空间;模型用到时才按需加载。", + ctxNotLoaded: "未加载", + ctxMessages: "消息", + ctxSplitToolCalls: "工具调用", + ctxSplitToolResults: "工具结果", + ctxSplitAttachments: "附件", + ctxSplitAssistant: "助手文本", + ctxSplitUser: "用户文本", + ctxSplitOther: "其他", + ctxColOfMessages: "占消息", + ctxTools: "按工具", + ctxColTool: "工具", + ctxColCall: "调用", + ctxColResult: "结果", + ctxColTotal: "合计", + ctxAttachments: "按附件类型", + ctxColType: "类型", + ctxLastRequest: "最近一次请求", + ctxCacheHit: "缓存命中", + ctxCacheRead: "缓存读", + ctxCacheWrite: "缓存写", + ctxUncached: "未缓存输入", + ctxOutput: "输出", busyElapsed: "已运行", busyLastEvent: "最近事件", stopTip: "停止(状态保留,可 resume)", @@ -409,7 +466,64 @@ export const I18N: Record> = { noMatch: "No matching sessions", connLost: "Connection lost", historyLoadFailed: "History failed to load — the transcript may be incomplete", + stepsShow: "Show steps", + stepsHide: "Hide steps", + stepInspector: "Step detail", + stepInput: "Input", + stepOutput: "Output", + stepThinking: "Thinking", + stepRunning: "Still running — the full output appears once it finishes", + stepUnavailable: "No full record for this step (older daemon, or not recorded)", + stepNoOutput: "(no output)", + stepTruncated: "Too long — truncated", + stepLoading: "Loading…", + close: "Close", retry: "Retry", + // Context panel (per-session right sidebar — what the window is made of). + ctxPanelTitle: "Context", + ctxOpen: "Show context breakdown", + ctxClosePanel: "Close context panel", + ctxRefresh: "Refresh", + ctxRefreshing: "Refreshing…", + ctxLoading: "Loading…", + ctxLoadFailed: "Context load failed", + ctxLive: "live", + ctxSnapshot: "snapshot", + ctxCapturedAt: "captured", + ctxModel: "model", + ctxTokens: "tokens", + ctxBarLabel: "Context window composition", + ctxAutoCompactAt: "auto-compact at", + ctxNoBreakdown: "No context breakdown for this session yet.", + ctxNoBreakdownWhy: "Only Claude stream-json sessions on this host report a breakdown; other harnesses, terminal-protocol sessions and sessions on remote hosts do not. A session also has to finish one turn after this feature shipped before a snapshot appears here.", + ctxCategories: "Breakdown", + ctxColName: "Category", + ctxColTokens: "tokens", + ctxColPct: "of window", + ctxDeferred: "Deferred tools (not counted)", + ctxDeferredNote: "These tool schemas are not loaded into the context and take no window space; they load on demand when the model reaches for them.", + ctxNotLoaded: "not loaded", + ctxMessages: "Messages", + ctxSplitToolCalls: "Tool calls", + ctxSplitToolResults: "Tool results", + ctxSplitAttachments: "Attachments", + ctxSplitAssistant: "Assistant text", + ctxSplitUser: "User text", + ctxSplitOther: "Other", + ctxColOfMessages: "of messages", + ctxTools: "By tool", + ctxColTool: "Tool", + ctxColCall: "Call", + ctxColResult: "Result", + ctxColTotal: "Total", + ctxAttachments: "By attachment type", + ctxColType: "Type", + ctxLastRequest: "Last request", + ctxCacheHit: "cache hit", + ctxCacheRead: "cache read", + ctxCacheWrite: "cache write", + ctxUncached: "uncached input", + ctxOutput: "output", busyElapsed: "running", busyLastEvent: "last event", stopTip: "Stop (state kept, resume anytime)", diff --git a/crates/ccteam-web/web/src/lib/sessionsApi.ts b/crates/ccteam-web/web/src/lib/sessionsApi.ts index 2831539f3..983a58522 100644 --- a/crates/ccteam-web/web/src/lib/sessionsApi.ts +++ b/crates/ccteam-web/web/src/lib/sessionsApi.ts @@ -291,6 +291,32 @@ export function getHistory( return getJson(`${sessionUrl(sid)}${query ? `?${query}` : ""}`); } +/** One finished step in full — `GET /api/v1/sessions/{sid}/steps/{step_id}`. + * `input` is the call's arguments (pretty JSON / command / path / query); + * `output` what it returned, or a reasoning block's text. Each field is + * capped server-side; `truncated` says one was cut. */ +export interface StepRecord { + step_id: string; + ts: string; + kind: string; + name: string; + input?: string; + output?: string; + truncated: boolean; +} + +/** Fetch a finished step. Rejects `NOT_FOUND` while the step still runs. */ +export function getStep( + sid: string, + stepId: string, + options: ReadRequestOptions = {}, +): Promise { + return getJson( + `${sessionUrl(sid)}/steps/${encodeURIComponent(stepId)}`, + options, + ); +} + /** `GET /api/v1/sessions/{sid}/status` — the per-session statusline (model + * context-window usage). A brand-new session reports `model`/`context`/ * `status_line` all `null`. 404 (unknown sid) maps to NOT_FOUND, 503 (no live diff --git a/crates/ccteam-web/web/src/lib/stepLoad.test.ts b/crates/ccteam-web/web/src/lib/stepLoad.test.ts new file mode 100644 index 000000000..5becd8b52 --- /dev/null +++ b/crates/ccteam-web/web/src/lib/stepLoad.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { failedLoad, initialLoad, shouldRetry } from "./stepLoad"; + +describe("step inspector load state", () => { + it("asks the server for any step with a record id, running or not", () => { + expect(initialLoad({ step_id: "n-1-t1" })).toBeNull(); + }); + + it("says a step from a daemon that sent no id has no record", () => { + expect(initialLoad({ step_id: "" })).toEqual({ state: "unavailable" }); + }); + + it("reads a 404 by the local done flag and keeps any other failure", () => { + expect(failedLoad(new Error("NOT_FOUND"), false)).toEqual({ state: "running" }); + expect(failedLoad(new Error("NOT_FOUND"), true)).toEqual({ state: "unavailable" }); + expect(failedLoad(new Error("network: down"), true)).toEqual({ + state: "error", + message: "network: down", + }); + }); + + it("retries only while the record may still appear", () => { + expect(shouldRetry({ state: "running" })).toBe(true); + expect(shouldRetry({ state: "unavailable" })).toBe(true); + expect(shouldRetry({ state: "error", message: "x" })).toBe(false); + expect(shouldRetry({ state: "loading" })).toBe(false); + }); +}); diff --git a/crates/ccteam-web/web/src/lib/stepLoad.ts b/crates/ccteam-web/web/src/lib/stepLoad.ts new file mode 100644 index 000000000..4258fb89a --- /dev/null +++ b/crates/ccteam-web/web/src/lib/stepLoad.ts @@ -0,0 +1,35 @@ +// What the step inspector shows for a step, before and after its full record +// is fetched. Pure + DOM-free so the decisions are unit-testable. + +import type { StepRecord } from "./sessionsApi"; + +export type StepLoad = + | { state: "loading" } + | { state: "ready"; record: StepRecord } + | { state: "running" } + | { state: "unavailable" } + | { state: "error"; message: string }; + +/** How often an open panel re-asks for a step the server has not recorded + * yet — a live completion frame can be missed (a reload mid-tool), and a + * reasoning step may show before its text does. */ +export const STEP_RETRY_MS = 3000; + +/** A step with no id has no record anywhere: nothing to ask. `null` = ask + * the server (it, not the possibly stale local list, knows what finished). */ +export function initialLoad(step: { step_id: string }): StepLoad | null { + return step.step_id ? null : { state: "unavailable" }; +} + +/** A fetch failure as panel state. A 404 means "not recorded yet": the step + * is still running when the local list says so, else it has no record. */ +export function failedLoad(err: unknown, done: boolean): StepLoad { + const message = err instanceof Error ? err.message : String(err); + if (message !== "NOT_FOUND") return { state: "error", message }; + return done ? { state: "unavailable" } : { state: "running" }; +} + +/** Whether to ask again later: only while the record may still appear. */ +export function shouldRetry(load: StepLoad): boolean { + return load.state === "running" || load.state === "unavailable"; +} diff --git a/crates/ccteam-web/web/src/pages/SessionView.steps.test.tsx b/crates/ccteam-web/web/src/pages/SessionView.steps.test.tsx new file mode 100644 index 000000000..bfd040d31 --- /dev/null +++ b/crates/ccteam-web/web/src/pages/SessionView.steps.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +// The two-layer step view, mounted for real: a folded activity row expands +// into its step list, and picking a step opens the inspector with the full +// record fetched by its `step_id` (a step not recorded yet reads as running). + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../lib/authedEventSource", () => ({ + createAuthedEventSource: () => ({ + close() {}, + addEventListener() {}, + removeEventListener() {}, + onmessage: null, + onerror: null, + onopen: null, + readyState: 1, + }), +})); + +import SessionView from "./SessionView"; +import { appendEvent, rowsKeyFor, type TranscriptRow } from "./chatTranscript"; +import type { SessionEvent } from "../hooks/useSessionEvents"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const activity = (a: { + kind?: string; + name?: string; + summary: string; + status: string; + item_id: string; +}): SessionEvent => ({ + kind: "activity", + content: a.summary, + activity: { kind: "tool_call", name: "Bash", step_id: `n-1-${a.item_id}`, ...a }, +}); + +describe("SessionView step view", () => { + let host: HTMLDivElement; + let root: Root; + let fetched: string[]; + + beforeEach(() => { + fetched = []; + let rows: TranscriptRow[] = []; + rows = appendEvent(rows, activity({ summary: "Bash(ls)", status: "started", item_id: "t1" })); + rows = appendEvent(rows, activity({ summary: "Bash(ls)", status: "completed", item_id: "t1" })); + rows = appendEvent(rows, activity({ name: "Read", summary: "Read(a.rs)", status: "started", item_id: "t2" })); + localStorage.setItem(rowsKeyFor("s9"), JSON.stringify(rows)); + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/steps/")) { + fetched.push(url); + if (!url.endsWith("/n-1-t1")) { + return Promise.resolve(new Response("{}", { status: 404 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + step_id: "n-1-t1", + ts: "2026-10-19T00:00:00Z", + kind: "tool_call", + name: "Bash", + input: '{\n "command": "ls"\n}', + output: "a.txt\nb.txt", + truncated: false, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + } + return new Promise(() => {}); + }) as typeof fetch; + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + const q = (id: string) => host.querySelector(`[data-testid="${id}"]`); + const all = (id: string) => [...host.querySelectorAll(`[data-testid="${id}"]`)]; + + it("expands the fold, then opens a finished step in full", async () => { + await act(async () => { + root.render( + + + , + ); + }); + expect(q("fold-steps")).toBeNull(); + await act(async () => (q("fold-toggle") as HTMLElement).click()); + const steps = all("fold-step"); + expect(steps.map((s) => s.textContent)).toEqual(["🔧Bash(ls)✓", "📖Read(a.rs)…"]); + + // The running step: not recorded yet, so the panel says it still runs. + await act(async () => steps[1]!.click()); + expect(q("step-inspector")).not.toBeNull(); + expect(fetched).toEqual(["/api/v1/sessions/s9/steps/n-1-t2"]); + expect(q("step-state")?.textContent).toContain("仍在运行"); + + // The finished step: its record, fetched by step id. + await act(async () => steps[0]!.click()); + expect(fetched.at(-1)).toBe("/api/v1/sessions/s9/steps/n-1-t1"); + expect(q("step-input")?.textContent).toContain('"command": "ls"'); + expect(q("step-output")?.textContent).toBe("a.txt\nb.txt"); + + await act(async () => (q("step-inspector-close") as HTMLElement).click()); + expect(q("step-inspector")).toBeNull(); + }); +}); diff --git a/crates/ccteam-web/web/src/pages/SessionView.test.tsx b/crates/ccteam-web/web/src/pages/SessionView.test.tsx index 8d8150c18..87ab2fa62 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.test.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.test.tsx @@ -26,6 +26,7 @@ import { MemoryRouter } from "react-router-dom"; import SessionView, { BusyHeartbeat, RowTime } from "./SessionView"; import { rowsKeyFor } from "./chatTranscript"; +import { CONTEXT_PANEL_OPEN_KEY } from "../lib/contextBreakdown"; import type { SessionView as SessionSummary } from "../lib/sessionsApi"; import type { SessionEvent } from "../hooks/useSessionEvents"; @@ -185,6 +186,69 @@ describe("SessionView mount-empty invariant (key={sid} remount)", () => { }); }); +// The context panel (right sidebar): its two entry points in the header and +// the per-browser docked memory. The panel body itself is covered by +// ContextPanel.test.tsx (jsdom); SSR here proves the mount-time decisions. +describe("context panel entry points + docked memory", () => { + const strip = (h: string) => h.replace(//g, ""); + beforeEach(() => { + globalThis.fetch = vi.fn().mockReturnValue(new Promise(() => {})); + vi.spyOn(globalThis.localStorage, "getItem").mockReturnValue(null); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("always offers the header toggle; the panel stays closed until remembered open", () => { + const html = render(); + expect(html).toMatch(/]*data-testid="ctx-toggle"[^>]*aria-pressed="false"/); + expect(html).toContain('aria-label="查看上下文构成"'); + expect(html).not.toContain('data-testid="context-panel"'); + // The chat column is still wrapped so the panel can dock beside it. + expect(html).toContain('class="conv-body"'); + expect(html).toContain('class="conv-main"'); + // No status yet ⇒ no chip; the toggle is the way in. + expect(html).not.toContain('data-testid="ctx-chip"'); + }); + + it("the `model · ctx N%` chip is a labelled button into the panel once a status is known", () => { + vi.spyOn(globalThis.localStorage, "getItem").mockImplementation((k) => + k === rowsKeyFor("s9") + ? JSON.stringify([ + { + id: "a1", + kind: "assistant", + content: "ok", + status: { + model: "claude-sonnet-5-5", + turn: 1, + context: { used_tokens: 250_000, window_tokens: 1_000_000, pct: 25 }, + }, + }, + ]) + : null, + ); + const html = strip(render()); + expect(html).toMatch( + /]*class="chip chip-btn"[^>]*data-testid="ctx-chip"[^>]*aria-label="查看上下文构成 · claude-sonnet-5-5"[^>]*aria-pressed="false"/, + ); + expect(html).toContain("claude-sonnet-5-5 · ctx 25%"); + expect(html).not.toContain('title="model · context window"'); + }); + + it("restores a remembered-open panel on mount (per-browser layout memory)", () => { + vi.spyOn(globalThis.localStorage, "getItem").mockImplementation((k) => + k === CONTEXT_PANEL_OPEN_KEY ? "1" : null, + ); + const html = render(); + expect(html).toContain('data-testid="context-panel"'); + expect(html).toContain('aria-label="上下文"'); + // Effects don't run under SSR: the first paint is the loading state. + expect(html).toContain('data-testid="ctx-loading"'); + expect(html).toMatch(/]*data-testid="ctx-toggle"[^>]*aria-pressed="true"/); + }); +}); + type HookStateSetter = (value: unknown | ((previous: unknown) => unknown)) => void; function createHookHarness() { diff --git a/crates/ccteam-web/web/src/pages/SessionView.tsx b/crates/ccteam-web/web/src/pages/SessionView.tsx index acf0fc1b6..7e3d9f056 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.tsx @@ -17,16 +17,19 @@ // byte-exact `ccteam-pty.v1` relay via TerminalView. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { ArrowDown, Clock, Pencil, X } from "lucide-react"; +import { ArrowDown, Clock, PanelRightClose, PanelRightOpen, Pencil, X } from "lucide-react"; import { ChatComposer } from "../components/ChatComposer"; +import { ContextPanel } from "../components/ContextPanel"; import { InlineRename } from "../components/InlineRename"; import type { TurnAttachment } from "../lib/attachmentsApi"; import CostPill from "../components/CostPill"; import { Markdown } from "../components/Markdown"; +import { StepInspector } from "../components/StepInspector"; import { TerminalView } from "../components/TerminalView"; import { VendorChip } from "../components/VendorChip"; import { foldSessionLiveness, + isTurnBoundary, turnInFlight, useSessionEvents, type TurnMark, @@ -58,10 +61,16 @@ import { mergeHistory, nextRowId, saveRows, + stepIcon, type TranscriptRow, } from "./chatTranscript"; import { railSessionLabel } from "./railHelpers"; import { contextPct, formatElapsed, formatTurnStatus } from "../lib/statusLine"; +import { + isNarrowViewport, + readContextPanelOpen, + writeContextPanelOpen, +} from "../lib/contextBreakdown"; function formatAttachmentSize(size: number): string { if (size < 1024) return `${size} B`; @@ -210,13 +219,66 @@ export default function SessionView({ const [busySince, setBusySince] = useState(null); const [historyError, setHistoryError] = useState(null); const [rows, setRows] = useState(() => loadRows(sid)); + // Step view: which folded activity rows are expanded into their step list, + // and which step the inspector shows (looked up live in `rows`, so a running + // step flips to done — and its record loads — while the panel is open). + // Per-sid by construction: the shell remounts this view on a sid change. + const [expandedFolds, setExpandedFolds] = useState>(() => new Set()); + const [inspected, setInspected] = useState<{ rowId: string; itemId: string } | null>(null); + const inspectedStep = useMemo(() => { + if (!inspected) return null; + const row = rows.find((r) => r.id === inspected.rowId); + return row?.fold?.steps?.find((st) => st.item_id === inspected.itemId) ?? null; + }, [inspected, rows]); + const closeInspector = useCallback(() => setInspected(null), []); + const toggleFold = useCallback((rowId: string) => { + setExpandedFolds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) next.delete(rowId); + else next.add(rowId); + return next; + }); + }, []); const [statusModel, setStatusModel] = useState(null); const [statusEffort, setStatusEffort] = useState(null); const [ctxPct, setCtxPct] = useState(null); + // ---- context panel (the right sidebar) ------------------------------------ + // Docked/closed is a per-browser LAYOUT preference: it survives this + // component's per-sid remount by living in localStorage. A phone viewport + // never auto-restores it (the panel is a chat-covering overlay there — the + // user re-opens it). `ctxOpenedByUser` tells the panel whether to take + // focus: yes after a click, no when the docked state was merely restored. + const [ctxOpen, setCtxOpen] = useState( + () => + readContextPanelOpen(typeof localStorage === "undefined" ? null : localStorage) && + !isNarrowViewport(typeof window === "undefined" ? undefined : window), + ); + const [ctxOpenedByUser, setCtxOpenedByUser] = useState(false); + const ctxToggleRef = useRef(null); + const setContextOpen = useCallback((next: boolean, byUser: boolean) => { + setCtxOpen(next); + setCtxOpenedByUser(byUser); + writeContextPanelOpen(typeof localStorage === "undefined" ? null : localStorage, next); + }, []); + const toggleContext = useCallback( + () => setContextOpen(!ctxOpen, true), + [ctxOpen, setContextOpen], + ); + // Closing from inside the panel (Esc / ×) hands focus back to the toggle. + const closeContext = useCallback(() => { + setContextOpen(false, false); + ctxToggleRef.current?.focus(); + }, [setContextOpen]); + const { events, lastError, gatewayUnavailable, connectionEpoch } = useSessionEvents(sid); + // A turn ending is the moment the window changes, so the panel re-fetches + // on the newest boundary frame's id (identity, not a count: the ring drops + // its oldest frames once full, so a count could stand still). + const latestTurnEndId = [...events].reverse().find((event) => isTurnBoundary(event))?.id; + // The SSE buffer survives reconnects. Keep both the fold cursor and a live // view of its current length so an authoritative history reseed can install // a precise barrier: everything already buffered is represented by history; @@ -634,10 +696,20 @@ export default function SessionView({ @ {session.host} ) : null} {displayStatusModel ? ( - + // The `model · ctx N%` chip IS the entry point into the context + // panel — the affordance sits on the number it explains. + ) : null}
@@ -660,228 +732,310 @@ export default function SessionView({ ) : null}
+ {/* Always-present toggle (the chip above needs a reported status; this + one is the way in before the first turn and for every harness — + the panel itself explains when there is nothing to show). */} + - {showTerminal && session?.project ? ( -
- -
- ) : ( - <> -
-
-
- {historyError ? ( -
-
- {t("historyLoadFailed")}: {historyError}{" "} + {/* [chat column | context panel] — the panel is a docked right + sidebar beside the chat (an overlay drawer ≤820px, see index.css), + mounted only while open so mount = fetch. */} +
+
+ {showTerminal && session?.project ? ( +
+ +
+ ) : ( + <> +
+
+
+ {historyError ? ( +
+
+ {t("historyLoadFailed")}: {historyError}{" "} + +
+
+ ) : null} + {historyPage.hasMore ? ( -
-
- ) : null} - {historyPage.hasMore ? ( - - ) : null} - {rows.map((row) => { - if (row.kind === "approval") { - return ( -
- {t("approve")} -
- {row.content} -
- {(row.options ?? []).map((opt, i) => ( - - ))} + ) : null} + {rows.map((row) => { + if (row.kind === "approval") { + return ( +
+ {t("approve")} +
+ {row.content} +
+ {(row.options ?? []).map((opt, i) => ( + + ))} +
+ {row.resolved ? ( +
+ 已回应 +
+ ) : null} +
+ +
+ ); + } + if (row.kind === "system") { + return ( +
+
{row.content}
+
- {row.resolved ? ( -
- 已回应 + ); + } + if (row.kind === "activity") { + const steps = row.fold?.steps ?? []; + if (steps.length === 0) { + return ( +
+
{row.content}
+
- ) : null} -
- -
- ); - } - if (row.kind === "system") { - return ( -
-
{row.content}
- -
- ); - } - if (row.kind === "activity") { - return ( -
-
{row.content}
- -
- ); - } - if (row.kind === "user") { - return ( -
- you -
{row.content}
- -
- ); - } - if (row.kind === "tool") { - return ( -
-
{row.content}
- -
- ); - } - // assistant — full Markdown document (红线: never plain text). - return ( -
-
- {row.content ? : null} - -
- {(() => { - // ONE footer line, no bubble header: - // `HH:MM · claude · s12 · · turn N · ctx N%` — - // identity + model + metrics once, in the order the IM - // status line uses (`render_status_line`), so the two - // surfaces read the same. The ledger lives on the - // session pages. - const footer = formatTurnStatus(row.status); - const model = row.status?.model || displayStatusModel; - const tail = [vendor, sid, model, footer?.text] - .filter((part): part is string => !!part) - .join(" · "); + ); + } + const open = expandedFolds.has(row.id); return ( - +
+ + {open ? ( +
    + {steps.map((step) => { + const selected = + inspected?.rowId === row.id && inspected.itemId === step.item_id; + return ( +
  1. + +
  2. + ); + })} +
+ ) : null} - {row.ts ? ` · ${tail}` : tail} - +
); - })()} -
- ); - })} - {busy ? ( -
-
- - -
+ } + if (row.kind === "user") { + return ( +
+ you +
{row.content}
+ +
+ ); + } + if (row.kind === "tool") { + return ( +
+
{row.content}
+ +
+ ); + } + // assistant — full Markdown document (红线: never plain text). + return ( +
+
+ {row.content ? : null} + +
+ {(() => { + // ONE footer line, no bubble header: + // `HH:MM · claude · s12 · · turn N · ctx N%` — + // identity + model + metrics once, in the order the IM + // status line uses (`render_status_line`), so the two + // surfaces read the same. The ledger lives on the + // session pages. + const footer = formatTurnStatus(row.status); + const model = row.status?.model || displayStatusModel; + const tail = [vendor, sid, model, footer?.text] + .filter((part): part is string => !!part) + .join(" · "); + return ( + + + {row.ts ? ` · ${tail}` : tail} + + ); + })()} +
+ ); + })} + {busy ? ( +
+
+ + +
+
+ ) : null}
+
+ {showJump ? ( + + ) : null} + {inspectedStep ? ( + ) : null}
-
- {showJump ? ( - - ) : null} -
- -
-
- {scheduled.length > 0 ? ( -
-
- - {t("scheduleQueue")} -
- {scheduled.map((item) => ( -
- - - {item.text.replace(/\s+/g, " ").trim().slice(0, 80)} - - {item.status === "failed" ? ( - - {t("scheduleFailed")} - - ) : null} - + +
+
+ {scheduled.length > 0 ? ( +
+
+ + {t("scheduleQueue")} +
+ {scheduled.map((item) => ( +
+ + + {item.text.replace(/\s+/g, " ").trim().slice(0, 80)} + + {item.status === "failed" ? ( + + {t("scheduleFailed")} + + ) : null} + +
+ ))}
- ))} + ) : null} + {}} + locked + modelLabel={displayStatusModel ?? ""} + effortLabel={statusEffort ?? ""} + uploadSlug={session?.project} + onSchedule={scheduleText} + scheduleTimezone={daemonTimezone} + />
- ) : null} - {}} - locked - modelLabel={displayStatusModel ?? ""} - effortLabel={statusEffort ?? ""} - uploadSlug={session?.project} - onSchedule={scheduleText} - scheduleTimezone={daemonTimezone} - /> -
-
- - )} +
+ + )} +
+ {ctxOpen ? ( + + ) : null} +
); } diff --git a/crates/ccteam-web/web/src/pages/chatTranscript.test.ts b/crates/ccteam-web/web/src/pages/chatTranscript.test.ts index 723d90649..9a441d4bf 100644 --- a/crates/ccteam-web/web/src/pages/chatTranscript.test.ts +++ b/crates/ccteam-web/web/src/pages/chatTranscript.test.ts @@ -13,6 +13,7 @@ import { eventToRow, foldActivity, emptyFold, + FOLD_STEPS_CAP, historyToRows, leadingClosingStatus, loadRows, @@ -244,6 +245,7 @@ describe("chatTranscript activity fold (v0.8.21 — mirrors IM ProgressFold)", ( summary: "", status: "started", item_id: "", + step_id: "", ...a, }); const actEv = (a: Partial, id?: string): SessionEvent => ({ @@ -318,6 +320,44 @@ describe("chatTranscript activity fold (v0.8.21 — mirrors IM ProgressFold)", ( expect(rows[0].fold).toBeUndefined(); }); + it("lists every step once, marking a tool done when it completes", () => { + let rows: TranscriptRow[] = []; + rows = appendEvent(rows, actEv({ name: "Bash", summary: "Bash(ls)", item_id: "t1", step_id: "n-1-t1" })); + rows = appendEvent(rows, actEv({ kind: "thinking", name: "", summary: "weighing", item_id: "r1", step_id: "n-1-r1", status: "update" })); + rows = appendEvent(rows, actEv({ kind: "thinking", name: "", summary: "weighing", item_id: "r1", step_id: "n-1-r1", status: "update" })); + expect(rows[0].fold?.steps?.map((s) => [s.item_id, s.done])).toEqual([ + ["t1", false], + ["r1", true], + ]); + rows = appendEvent(rows, actEv({ name: "Bash", summary: "Bash(ls)", item_id: "t1", step_id: "n-1-t1", status: "completed" })); + expect(rows).toHaveLength(1); + expect(rows[0].fold?.steps?.[0]).toEqual({ + item_id: "t1", + step_id: "n-1-t1", + kind: "tool_call", + name: "Bash", + summary: "Bash(ls)", + done: true, + }); + expect(rows[0].content).toBe("⏳ working… · 🔧 bash ×1"); + }); + + it("keeps only the newest FOLD_STEPS_CAP steps while counting all", () => { + let fold = emptyFold(); + for (let i = 0; i < FOLD_STEPS_CAP + 5; i++) + fold = foldActivity(fold, act({ name: "Read", item_id: `r${i}` })); + expect(fold.steps).toHaveLength(FOLD_STEPS_CAP); + expect(fold.steps?.[0]?.item_id).toBe("r5"); + expect(renderFold(fold)).toBe(`⏳ working… · 📖 read ×${FOLD_STEPS_CAP + 5}`); + }); + + it("a fold persisted before the step list existed still folds", () => { + const legacy = { seen: ["a"], buckets: [{ emoji: "🔧", label: "bash", count: 1 }], thinking: false }; + const next = foldActivity(legacy, act({ name: "Read", item_id: "b" })); + expect(next.steps?.map((s) => s.item_id)).toEqual(["b"]); + expect(renderFold(next)).toBe("⏳ working… · 🔧 bash ×1 · 📖 read ×1"); + }); + it("foldActivity/renderFold are pure (no mutation of prev)", () => { const a = emptyFold(); const b = foldActivity(a, act({ name: "Bash", item_id: "x" })); diff --git a/crates/ccteam-web/web/src/pages/chatTranscript.ts b/crates/ccteam-web/web/src/pages/chatTranscript.ts index f0e9aadc9..3c1bbbbc9 100644 --- a/crates/ccteam-web/web/src/pages/chatTranscript.ts +++ b/crates/ccteam-web/web/src/pages/chatTranscript.ts @@ -158,8 +158,36 @@ export interface ActivityFold { buckets: { emoji: string; label: string; count: number }[]; /** Any reasoning step seen — drives the `💭 thinking…` head when no tool ran. */ thinking: boolean; + /** The run's steps in order (newest last, capped at {@link FOLD_STEPS_CAP}) — + * what the expanded row lists and the step inspector opens. Light on + * purpose: the full input / output / reasoning is fetched by `step_id` on + * demand, never stored in the per-sid localStorage buffer. Absent on a fold + * persisted before the list existed. */ + steps?: FoldStep[]; } +/** One step of a folded run, as the expanded activity row lists it. */ +export interface FoldStep { + item_id: string; + /** Empty when the daemon sent none — the step then has no full record. */ + step_id: string; + kind: string; + name: string; + /** One-line preview: the call, or the opening of the reasoning. */ + summary: string; + /** Whether the step finished (its record is then fetchable). */ + done: boolean; +} + +/** Most steps a fold keeps for the expanded list; older ones drop (the + * counters above still count them). Kept small: every fold row is persisted + * with the per-sid transcript. */ +export const FOLD_STEPS_CAP = 100; + +/** Longest step summary the list keeps — the rows buffer lives in a 5 MB + * localStorage quota shared by every session; the full text is a fetch away. */ +const STEP_SUMMARY_MAX = 120; + interface ActivityCategory { emoji: string; label: string; @@ -221,25 +249,78 @@ function activityCategory(a: SessionActivity): ActivityCategory | null { /** A fresh, empty fold. */ export function emptyFold(): ActivityFold { - return { seen: [], buckets: [], thinking: false }; + return { seen: [], buckets: [], thinking: false, steps: [] }; +} + +/** A step is finished once its item completes; a reasoning block is whole + * when it is reported (the daemon records it then). */ +function stepDone(a: SessionActivity): boolean { + return a.status === "completed" || a.kind === "thinking"; +} + +/** `steps` with `a` merged in: a known item updates in place (a completion + * marks it done and fills a step id it lacked), a new one appends. */ +function foldStep(steps: readonly FoldStep[], a: SessionActivity): FoldStep[] { + const at = a.item_id ? steps.findIndex((s) => s.item_id === a.item_id) : -1; + if (at >= 0) { + const prev = steps[at]!; + const done = prev.done || stepDone(a); + const step_id = prev.step_id || a.step_id; + if (done === prev.done && step_id === prev.step_id) return steps as FoldStep[]; + const next = [...steps]; + next[at] = { ...prev, done, step_id }; + return next; + } + const next = [ + ...steps, + { + item_id: a.item_id, + step_id: a.step_id, + kind: a.kind, + name: a.name, + summary: + a.summary.length > STEP_SUMMARY_MAX ? `${a.summary.slice(0, STEP_SUMMARY_MAX)}…` : a.summary, + done: stepDone(a), + }, + ]; + return next.length > FOLD_STEPS_CAP ? next.slice(next.length - FOLD_STEPS_CAP) : next; } /** Fold one structured activity into `prev`, returning a NEW fold (pure + - * immutable). De-dups by `item_id`; a `thinking` step only flags the head. */ + * immutable). De-dups by `item_id`; a `thinking` step only flags the head. + * Every step also lands in {@link ActivityFold.steps}. */ export function foldActivity(prev: ActivityFold, a: SessionActivity): ActivityFold { + const prevSteps = prev.steps ?? []; + const steps = foldStep(prevSteps, a); // Already counted (start↔complete pair, or a repeated reasoning update): - // re-assert the thinking flag if needed, otherwise leave the fold untouched. + // re-assert the thinking flag if needed, otherwise only the step list moves. if (a.item_id && prev.seen.includes(a.item_id)) { - return a.kind === "thinking" && !prev.thinking ? { ...prev, thinking: true } : prev; + const thinking = prev.thinking || a.kind === "thinking"; + if (thinking === prev.thinking && steps === prevSteps) return prev; + return { ...prev, thinking, steps }; } const seen = a.item_id ? [...prev.seen, a.item_id] : prev.seen; const cat = activityCategory(a); - if (!cat) return { ...prev, seen, thinking: true }; + if (!cat) return { ...prev, seen, thinking: true, steps }; const buckets = prev.buckets.map((b) => ({ ...b })); const hit = buckets.find((b) => b.label === cat.label); if (hit) hit.count += 1; else buckets.push({ emoji: cat.emoji, label: cat.label, count: 1 }); - return { ...prev, seen, buckets }; + return { ...prev, seen, buckets, steps }; +} + +/** The icon the expanded list shows for a step — its fold category's. */ +export function stepIcon(step: Pick): string { + if (step.kind === "thinking") return "💭"; + const cat = activityCategory({ + kind: step.kind, + name: step.name, + summary: "", + status: "", + item_id: "", + step_id: "", + }); + return cat?.emoji ?? "🔧"; } /** Render a fold to its one-line summary, mirroring `ProgressFold::render`: diff --git a/docs/mcp-cn.md b/docs/mcp-cn.md index 5b36df6c0..488544f77 100644 --- a/docs/mcp-cn.md +++ b/docs/mcp-cn.md @@ -132,7 +132,7 @@ token 文件里是裸 hex,`ccteam:` 前缀由调用方自己加。loopback 绑 ## 4. 完成通知 -每个 `agent` 任务都挂 watch(除非你退订),**在它自己那条请求绑定的那个 turn 的终结性边界上回报** —— 子会话自己的后台活还会把它叫醒的那种边界不算终结,只把请求带着往下走(见 §3 `agent`);话痨子会话的中途叙述只进账本;同一子会话里另一个任务先完成,那是那条任务的回报、不是你的。通知 = 一行头 —— `s12 done · codex · turn 7 · ctx 19% · «核对修复» req-18d2… · 还排队 2`(85% 起带 `⚠`;失败写 `s12 FAILED () …`)—— 加一段答案节选。头里点名**哪一条**请求答了、以及**它自己的** title(后来的派工绝不改前面那条的名字),`还排队 N` 是这个子会话还欠你多少。`turn N` 数的是**已完成**的 turn,不是被接受的消息数:给一个只完成了一个 turn 的子会话派三条任务,报的是 `turn 1`。 +每个 `agent` 任务都挂 watch(除非你退订),**在它自己那条请求绑定的那个 turn 的终结性边界上回报** —— 子会话自己的后台活还会把它叫醒的那种边界不算终结,只把请求带着往下走(见 §3 `agent`);话痨子会话的中途叙述只进账本;同一子会话里另一个任务先完成,那是那条任务的回报、不是你的。通知 = 一行头 —— `s12 done · codex · turn 7 · ctx 19% · «核对修复» req-18d2… · 还排队 2`(85% 起带 `⚠`;失败写 `s12 FAILED () …`)—— 加一段答案节选。头里点名**哪一条**请求答了、以及**它自己的** title(后来的派工绝不改前面那条的名字),`还排队 N` 是这个子会话还欠你多少。steer 进同一个 turn 的多条请求共用它的同一份答案、合在一起回报:每个父会话每个 turn 一条通知,头里按派工顺序写 `· answers N requests: «title» req-…, req-…`(每条请求照旧各自了结、各自记账)。一个没有任何文本就结束的 turn 同样了结它的请求,节选处写 `(no reply)`。`turn N` 数的是**已完成**的 turn,不是被接受的消息数:给一个只完成了一个 turn 的子会话派三条任务,报的是 `turn 1`。 | `notify` | 节选 | 用途 | |---|---|---| diff --git a/docs/mcp.md b/docs/mcp.md index 8635f0df3..efe1a3cee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -132,7 +132,7 @@ No parameters; returns the same brief `status` body. It exists for hosts that su ## 4. Completion notifications -Every `agent` task is watched (unless you opt out) and reports **at the terminal boundary of the turn its own request is bound to** — a boundary the child's own background work will wake it out of is not one, and carries the request forward instead (§3 `agent`); a chatty child's mid-turn narration stays in the ledger, and a sibling task finishing first is that task's report, not yours. The notification is one header line — `s12 done · codex · turn 7 · ctx 19% · «verify the fix» req-18d2… · 2 still queued` (`⚠` from 85 %; `s12 FAILED () …` on failure) — followed by an excerpt of the answer. The header names **which** request answered and **its own** title (a later dispatch never renames an earlier one), and `N still queued` is what that child still owes you. `turn N` counts turns that **finished**, not messages that were accepted: three tasks handed to a child that has completed one report `turn 1`. +Every `agent` task is watched (unless you opt out) and reports **at the terminal boundary of the turn its own request is bound to** — a boundary the child's own background work will wake it out of is not one, and carries the request forward instead (§3 `agent`); a chatty child's mid-turn narration stays in the ledger, and a sibling task finishing first is that task's report, not yours. The notification is one header line — `s12 done · codex · turn 7 · ctx 19% · «verify the fix» req-18d2… · 2 still queued` (`⚠` from 85 %; `s12 FAILED () …` on failure) — followed by an excerpt of the answer. The header names **which** request answered and **its own** title (a later dispatch never renames an earlier one), and `N still queued` is what that child still owes you. Several requests steered into one turn share its one answer and are reported together: one notification per parent per turn, its header reading `· answers N requests: «title» req-…, req-…` in dispatch order (each request is still resolved and recorded on its own). A turn that ends without any text settles its requests too, with `(no reply)` in place of an excerpt. `turn N` counts turns that **finished**, not messages that were accepted: three tasks handed to a child that has completed one report `turn 1`. | `notify` | Excerpt | Use | |---|---|---| diff --git a/docs/orchestration-cn.md b/docs/orchestration-cn.md index 4507249a1..5cfa2206a 100644 --- a/docs/orchestration-cn.md +++ b/docs/orchestration-cn.md @@ -109,7 +109,7 @@ steer 有一处代价值得知道:在 Claude 上,这行纠正会以 turn 中预 2. **各自独立作答** —— 各自独立会话,互不串味。 3. **在每条任务真正结束时收集** —— 一条任务一条完成通知,落在结束这段活的那个边界上;还缺的用 `agent_read{sid}` 补(缺席/失败的成员标记出来,绝不 kill)。 **Claude 子会话的一条任务,它自己需要几个 turn 就答几个 turn。** Claude Code 会自己叫醒自己的模型:子会话起的后台任务、`Monitor` 或它自己派出的 `Agent` 一跑完,CLI 就自己递一行进去,模型在一个 ccteam 从没提交过的新 turn 里作答。所以一个边界只有在 harness 手上再没有能把它叫醒的东西时,才算结束你这条任务 —— 快照里没有还在跑的后台任务,也没有 CLI 可能当下一个 prompt 重跑的注入行。在那之前,你的请求保持绑定(vendor 自己开的下一个 turn 继承它),每过一个边界记一条 `progress`、点名那个执行 turn(在 `agent_read` 的 request 行里看得到),而**什么都不推给你** —— `brief` 和 `final` 都不推;但这些 turn 照样计费。两种「扣住」的时长不同。注入行是弱的那种:边界之后 30 秒内没有 replay turn 开出来,这个边界就作为答案投给你。后台任务的扣住没有计时器 —— 但它活不过子会话的进程:子会话被释放(空闲或容量)或 daemon 重启时,它扣住的最后一个边界就作为答案投出,因为再没有东西能叫醒它了。中间子会话读作 `working`(整整五分钟没动静才转 `stale`,就是平常那个安静会话的词),`agent_stop` 依然能就地把这条链切断。你最终只收到一条通知,它指向真正承载答案的那个 turn。codex 与 ACP 系那边,turn 只因为 ccteam 派了活才存在,所以每个边界本来就是结束。 - 每条完成通知只有一行头 —— `s12 done · codex · turn 7 · ctx 19% · «裁决» req-18d2… · 还排队 2`(`⚠` 从 85% 起;失败写 `s12 FAILED () …`)—— 后面直接是答案节选(默认 500 字符,`notify:"final"` 是 2000):装得下就是整段答案;装不下则给子会话的**结论** —— 它在末次工具调用之后写的那段、守纪律的工人收尾用的回执 —— 前面带一个 `…[+N chars: agent_read{sid,turn:,max_chars}]…` 标记,写明被略去的叙述字数与读那条答案的精确调用(子会话之后再完成别的 turn,它依然指向同一条);只有 vendor 没标出这个边界的 turn 才回落到头尾预览。头里点名**哪一条**任务回来了、以及这个子会话还欠你几条,一个子会话上的多条任务就不会糊成一份报告。父会话仍在 turn 中时,Claude 暂存完成通知,在下一可投递边界把当时已排队的通知放进一个批次 turn,不等待凑批。每条头部与摘录原文保留,请求逐条独立记账;每批预算 50,000 字符(含重放标记),超限按 FIFO 确定性拆批,尾部说明还剩几条。单条不可拆通知自身超限时明确标记并独立投递,保留全文。显式排队任务与斜杠命令仍各自成 turn。崩溃后仍留在镜像中的批次按原身份重放并标明重复,即使其中某条已被 `agent_read` 读过也不删除(`collected` 不是消费确认)。同一 daemon 生命期内至多一次(送到一半崩溃,下一个生命会重发而不是丢掉:同一 `request_id`、同一 turn、同一条完成);`agent_read` 的名册行、transcript 与内联 `agent` 结果都给数字 `context_pct`,据此决定**复用还是新开**不用再多调一次:上下文还宽裕就继续派给它;接近告警带就为下个任务新开一个、旧的闲置。中途叙述根本不会叫醒你 —— 它只留在子会话自己的账本里 —— 所以话痨子会话不多花你一分钱;而你真想**看一眼**时,`agent_read{sid,n:0}` 会给 `partial:true` 加一段有界节选:正在跑的那个 turn 说到哪儿了 —— 一种不用把它停掉就能看见它在干什么的办法。子会话比内联 `agent{wait}` 还久时,用 `agent_read{sid,wait:240}` 把这次读挂住到它的 turn 结束(带 `since:` 重复调用即可继续向前翻 —— 最旧的未读在前,`remaining` 告诉你还剩几条未读);`agent_read{sid,n:0}` 不返回已完成 turn 的正文,仍可给出在飞叙述。重复读取时保存返回的 `cursor`,再传 `since:`,只读之后完成的 turn 与当前 `in_flight`;`n:1` 读最新已完成 turn,可能重复;`turn:` 是精确定位,不是去重规则。`max_chars` 由序列化后的 `turns`、`requests`、`in_flight` 共用,少量控制字段与遗漏计数在预算外。终结请求历史用 `history:true` 按需读取。这些都不是轮询:自己雇的会话以完成通知为准;永远不需要你自己去 tail 它的 `turns.jsonl`。 + 每条完成通知只有一行头 —— `s12 done · codex · turn 7 · ctx 19% · «裁决» req-18d2… · 还排队 2`(`⚠` 从 85% 起;失败写 `s12 FAILED () …`)—— 后面直接是答案节选(默认 500 字符,`notify:"final"` 是 2000):装得下就是整段答案;装不下则给子会话的**结论** —— 它在末次工具调用之后写的那段、守纪律的工人收尾用的回执 —— 前面带一个 `…[+N chars: agent_read{sid,turn:,max_chars}]…` 标记,写明被略去的叙述字数与读那条答案的精确调用(子会话之后再完成别的 turn,它依然指向同一条);只有 vendor 没标出这个边界的 turn 才回落到头尾预览。头里点名**哪一条**任务回来了、以及这个子会话还欠你几条,一个子会话上的多条任务就不会糊成一份报告。你 steer 进子会话正在跑的 turn 的几行,共用那个 turn 的同一份答案,所以它结束时你只收到**一条**通知,头里列出它了结的每条请求(`· answers 3 requests: «裁决» req-18d2…, req-18d3…, req-18d4…`),而不是同一份答案按行各发一遍。一个什么都没说就结束的 turn(比如对「无需回复」的说明默默收下)同样了结绑在它上面的请求:你收到头行加 `(no reply)`,不会再有请求被一直算作「还排队」。父会话仍在 turn 中时,Claude 暂存完成通知,在下一可投递边界把当时已排队的通知放进一个批次 turn,不等待凑批。每条头部与摘录原文保留,请求逐条独立记账;每批预算 50,000 字符(含重放标记),超限按 FIFO 确定性拆批,尾部说明还剩几条。单条不可拆通知自身超限时明确标记并独立投递,保留全文。显式排队任务与斜杠命令仍各自成 turn。崩溃后仍留在镜像中的批次按原身份重放并标明重复,即使其中某条已被 `agent_read` 读过也不删除(`collected` 不是消费确认)。同一 daemon 生命期内至多一次(送到一半崩溃,下一个生命会重发而不是丢掉:同一 `request_id`、同一 turn、同一条完成);`agent_read` 的名册行、transcript 与内联 `agent` 结果都给数字 `context_pct`,据此决定**复用还是新开**不用再多调一次:上下文还宽裕就继续派给它;接近告警带就为下个任务新开一个、旧的闲置。中途叙述根本不会叫醒你 —— 它只留在子会话自己的账本里 —— 所以话痨子会话不多花你一分钱;而你真想**看一眼**时,`agent_read{sid,n:0}` 会给 `partial:true` 加一段有界节选:正在跑的那个 turn 说到哪儿了 —— 一种不用把它停掉就能看见它在干什么的办法。子会话比内联 `agent{wait}` 还久时,用 `agent_read{sid,wait:240}` 把这次读挂住到它的 turn 结束(带 `since:` 重复调用即可继续向前翻 —— 最旧的未读在前,`remaining` 告诉你还剩几条未读);`agent_read{sid,n:0}` 不返回已完成 turn 的正文,仍可给出在飞叙述。重复读取时保存返回的 `cursor`,再传 `since:`,只读之后完成的 turn 与当前 `in_flight`;`n:1` 读最新已完成 turn,可能重复;`turn:` 是精确定位,不是去重规则。`max_chars` 由序列化后的 `turns`、`requests`、`in_flight` 共用,少量控制字段与遗漏计数在预算外。终结请求历史用 `history:true` 按需读取。这些都不是轮询:自己雇的会话以完成通知为准;永远不需要你自己去 tail 它的 `turns.jsonl`。 4. **综合裁决你自己来** —— 共识、分歧、你的拍板。可选:把收来的答案回投给某个子会话互驳,或再起一个会话当裁判。 **账单始终可见。** `agent_read` 每行带累计 `cost_usd` / `tokens_total`,一场扇出花多少钱是可加总的数字,不是惊喜。对 **codex** 成员有一条诚实的限制:app-server 按单次 Responses 请求报 token,不按 turn 报,而一个 turn 通常是多次请求 —— 所以 codex 成员的 `tokens_total` / `cost_usd` 是该 turn 真实消耗的**下界**,不是精确值。其余 vendor 均按 turn 上报。看到 codex 的数字,请理解为「至少这么多」。 diff --git a/docs/orchestration.md b/docs/orchestration.md index 81ff75a5a..095ef157b 100644 --- a/docs/orchestration.md +++ b/docs/orchestration.md @@ -109,7 +109,7 @@ Default: omit `model` — vendor defaults track their latest releases. 2. **Let each answer independently** — separate sessions, no cross-contamination. 3. **Collect when each task really ends** — one completion notification per task, at the boundary that ends the work; `agent_read{sid}` picks up anything you're still missing (an absent or failed member is noted, never killed). **A Claude child answers in as many turns as its own work needs.** Claude Code wakes its own model: when a background task, a `Monitor`, or an `Agent` the child launched finishes, the CLI prompts it again and the model answers in a fresh turn ccteam never submitted. So a boundary ends your task only when the harness is holding nothing that will wake the child again — no background task left in its snapshot, no injected line the CLI might still re-run as the next prompt. Until then your request stays bound (the turn the vendor opens next inherits it), collects one `progress` row naming that execution turn — visible in `agent_read`'s request rows — and **nothing is pushed to you**, neither `brief` nor `final`, though every one of those turns is billed. The two holds differ in how long they last. An injected line is the weaker one: if no replay turn opens within 30 seconds of the boundary, that boundary is delivered as the answer. A background hold has no timer — but it cannot outlive the child's process: when the child is released (idle or capacity) or the daemon restarts, the last boundary it held open is delivered as the answer, because nothing can wake it any more. The child reads `working` in between (`stale` after five silent minutes, the ordinary quiet-session word), and an `agent_stop` still cuts the chain wherever it has got to. You get exactly one notification, and it points at the turn that actually carried the answer. On codex and the ACP vendors a turn exists only because ccteam asked for one, so every boundary there is already the end. - Every completion notification is one header line — `s12 done · codex · turn 7 · ctx 19% · «the verdict» req-18d2… · 2 still queued` (`⚠` from 85%; `s12 FAILED () …` on failure) — followed by the answer excerpt (500 characters by default, 2000 under `notify:"final"`): the whole answer when it fits; otherwise the child's **conclusion** — what it wrote after its last tool call, the receipt a disciplined worker ends on — behind one `…[+N chars: agent_read{sid,turn:,max_chars}]…` marker that names the exact read of that answer (it stays correct after the child finishes later turns); only a turn with no such boundary falls back to a head/tail preview. The header names **which** of your tasks came back and how many that child still owes you, so several tasks on one child never blur into one report. If you are mid-turn when children finish, Claude parks their notifications and delivers the ones already queued at the next boundary in one batch turn, without waiting to gather more. Every item's header and excerpt stay verbatim; requests are recorded independently. Batches target 50,000 characters including replay markers, split in FIFO order, and state how many notifications remain. A single indivisible oversized notification is labelled and delivered alone with its text intact. Explicit queued tasks and slash commands remain separate turns. A batch retained across a crash replays with the same identity and a repeat marker, including lines already read through `agent_read` (`collected` is not a consumption acknowledgement). Delivery is at most once while the daemon lives (a crash mid-delivery can repeat it in the next life rather than drop it: same `request_id`, same turn, same completion); and `agent_read` rows, transcripts and inline `agent` results carry `context_pct` as a number, so you can decide **reuse vs. fresh session** without another call: keep dispatching to a child while its context is comfortably low; once it nears the warning band, spawn a new one for the next task and let the old one idle. Mid-turn narration never wakes you at all — it stays in the child's ledger — so a chatty child costs you nothing extra, and when you do want to LOOK, `agent_read{sid,n:0}` answers `partial:true` with a bounded excerpt of what the running turn has said so far: a way to see what a working child is doing that is not stopping it to find out. If a child outlasts an inline `agent{wait}`, `agent_read{sid,wait:240}` holds the read open until its turn ends instead (repeat with `since:` to page forward — oldest unread first, `remaining` says how many are still unread); `agent_read{sid,n:0}` omits completed turn bodies while still reporting running narration. For repeated reads, keep the returned `cursor` and send `since:`: only newer completed turns are returned, plus the current `in_flight` excerpt. `n:1` reads the latest completed turn and may repeat it; `turn:` locates one exact turn and is not a deduplication rule. `max_chars` is shared by serialized `turns`, `requests`, and `in_flight`; small control fields and omission counts sit outside that payload budget. Completed request history is available with `history:true`. None of this is polling: for your own hires the completion notification is authoritative, and reading a child's `turns.jsonl` yourself is never necessary. + Every completion notification is one header line — `s12 done · codex · turn 7 · ctx 19% · «the verdict» req-18d2… · 2 still queued` (`⚠` from 85%; `s12 FAILED () …` on failure) — followed by the answer excerpt (500 characters by default, 2000 under `notify:"final"`): the whole answer when it fits; otherwise the child's **conclusion** — what it wrote after its last tool call, the receipt a disciplined worker ends on — behind one `…[+N chars: agent_read{sid,turn:,max_chars}]…` marker that names the exact read of that answer (it stays correct after the child finishes later turns); only a turn with no such boundary falls back to a head/tail preview. The header names **which** of your tasks came back and how many that child still owes you, so several tasks on one child never blur into one report. Lines you steer into a child's running turn all share that turn's one answer, so when it ends you get **one** notification for them, its header listing every request it settles (`· answers 3 requests: «the verdict» req-18d2…, req-18d3…, req-18d4…`) instead of the same answer once per line. A turn that ends without saying anything — a "no reply needed" note acknowledged silently — still settles what was bound to it: you get the header and `(no reply)`, and nothing is left counted as still queued. If you are mid-turn when children finish, Claude parks their notifications and delivers the ones already queued at the next boundary in one batch turn, without waiting to gather more. Every item's header and excerpt stay verbatim; requests are recorded independently. Batches target 50,000 characters including replay markers, split in FIFO order, and state how many notifications remain. A single indivisible oversized notification is labelled and delivered alone with its text intact. Explicit queued tasks and slash commands remain separate turns. A batch retained across a crash replays with the same identity and a repeat marker, including lines already read through `agent_read` (`collected` is not a consumption acknowledgement). Delivery is at most once while the daemon lives (a crash mid-delivery can repeat it in the next life rather than drop it: same `request_id`, same turn, same completion); and `agent_read` rows, transcripts and inline `agent` results carry `context_pct` as a number, so you can decide **reuse vs. fresh session** without another call: keep dispatching to a child while its context is comfortably low; once it nears the warning band, spawn a new one for the next task and let the old one idle. Mid-turn narration never wakes you at all — it stays in the child's ledger — so a chatty child costs you nothing extra, and when you do want to LOOK, `agent_read{sid,n:0}` answers `partial:true` with a bounded excerpt of what the running turn has said so far: a way to see what a working child is doing that is not stopping it to find out. If a child outlasts an inline `agent{wait}`, `agent_read{sid,wait:240}` holds the read open until its turn ends instead (repeat with `since:` to page forward — oldest unread first, `remaining` says how many are still unread); `agent_read{sid,n:0}` omits completed turn bodies while still reporting running narration. For repeated reads, keep the returned `cursor` and send `since:`: only newer completed turns are returned, plus the current `in_flight` excerpt. `n:1` reads the latest completed turn and may repeat it; `turn:` locates one exact turn and is not a deduplication rule. `max_chars` is shared by serialized `turns`, `requests`, and `in_flight`; small control fields and omission counts sit outside that payload budget. Completed request history is available with `history:true`. None of this is polling: for your own hires the completion notification is authoritative, and reading a child's `turns.jsonl` yourself is never necessary. 4. **Synthesize the verdict yourself** — consensus, disagreements, and your call. Optionally dispatch the collected answers back to one child for rebuttal, or spawn a third session as tie-breaker. **The bill stays visible.** `agent_read` rows carry the accrued `cost_usd` / `tokens_total` per member, so a fan-out's cost is a sum you can read, not a surprise. One honest caveat for **codex** members: the app-server reports token usage per Responses request, not per turn, and a turn is usually many requests — so a codex member's `tokens_total` and `cost_usd` are a **lower bound** on what the turn actually burned, not an exact figure. Every other vendor reports per turn. Treat a codex number as "at least this much". diff --git a/docs/usage-cn.md b/docs/usage-cn.md index 7beb0e6a7..11be7dcbd 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -210,6 +210,7 @@ web url: http://<你的局域网IP>:7331/?token=ccteam:<令牌> ### Status / 成本 - **Status** 页:daemon 健康、会话 live/idle 数、每条会话的成本、今日成本/预算(也是顶栏成本药丸的来源)。 +- **上下文侧栏**:在 web 控制台点会话头部的 `model · ctx` 标签(或右上角的侧栏按钮),右侧打开侧栏(切换会话时保持打开),显示上下文窗口由什么组成:system prompt、工具、memory 文件(每个 `CLAUDE.md` / 记忆文件按路径列出)、skills、MCP 工具与说明、自定义 agent,以及对话本身按工具(调用 vs 结果 token)和附件类型的拆分,外加上一次请求的缓存读 / 缓存写 / 未缓存输入。harness 延迟加载(未载入)的工具单独列出,因为它们不占窗口。数字是 harness 自己的账(Claude `/context` 的数据),不是让模型去估:在线会话每次打开和点「刷新」都现查,turn 结束时侧栏自动刷新(所以 `/compact` 后立刻看得到变化);已释放的会话显示最后一个 turn 结束时的快照并标出时间。目前只支持 Claude stream-json 会话,其他 harness 仍只显示 ctx 百分比。REST:`GET /api/v1/sessions/{sid}/context`。 - 成本按 vendor 分别记账。Claude/Codex/Grok 有价表时用表计价;**OpenCode 只认自报 USD**(无上报或 0 显示「—」,绝不套用他家价表);**DSH 会上报原始 token,但暂时没有 USD 价表**。 ### 标准资源 API(给集成方) @@ -442,7 +443,7 @@ ccteam status # daemon 心跳 + 各项目及其会话 + web 链 ccteam doctor # 安装 / 依赖体检(--verify-mcp 校验 MCP 表面) ``` -`ccteam init` 只写 ccteam 自己的东西:项目 `.ccteam/`(状态)+ `.claude/settings.local.json`(ccteam 的 hook,写进本地层,**不碰**你的 `.claude/settings.json`)——**不种任何角色**,`.claude/agents/` 归你。重跑安全。偏好存 `~/.ccteam/preferences.toml`(目前一个键:`fallback.on_claude_quota` = `off`|`codex`,Claude 配额触顶是否回退 Codex)。 +`ccteam init` 只写 ccteam 自己的东西:项目 `.ccteam/`(状态)——**不种任何角色**,`.claude/agents/` 归你;也不写任何 Claude 设置(不建 `.claude/settings.local.json`、不装 hook),**不碰**你的 `.claude/settings.json`。重跑安全;对旧版 ccteam 初始化过的项目,重跑会把旧版写进 `.claude/settings.local.json` 的 Claude Code hook(及 Telegram 插件钉)剥掉,其余键和你自己的 hook 原样保留;文件不是合法 JSON 时原样不动、仅在回执里注明。偏好存 `~/.ccteam/preferences.toml`(目前一个键:`fallback.on_claude_quota` = `off`|`codex`,Claude 配额触顶是否回退 Codex)。 ### `project`(项目生命周期) @@ -456,7 +457,7 @@ ccteam project rm demo --dry-run # 先预览会停什么、删什么 ccteam project rm demo --purge # 注销 + 删 ccteam 在项目里建的痕迹 ``` -`rm --purge` 只删 ccteam 建的(项目 `.ccteam/` + settings.local.json 里 ccteam 的 hook 段);**永远保留**你的 work-role、`CLAUDE.md`/`AGENTS.md`、`.env`、业务代码、你的 `.claude/settings.json`。 +`rm --purge` 只删 ccteam 建的(项目 `.ccteam/` + settings.local.json 里残留的 ccteam hook 条目);**永远保留**你的 work-role、`CLAUDE.md`/`AGENTS.md`、`.env`、业务代码、你的 `.claude/settings.json`。 ### `session`(会话) @@ -559,7 +560,7 @@ ccteam update --now # 跳过等待在飞 turn 的排空,立即重 重启永远不会把 daemon 搬到别处:`ccteam update`(与不带 flag 的 `ccteam daemon restart` 一样)重放 launcher 记录的 flag,daemon 早于这份记录时则用它在 `/health` 上报的绑定地址。两者都拿不到 —— 既无 launcher 记录、`/health` 也不应答(用 `--no-web` 起的 daemon,或早于身份面的旧构建)—— 就只换二进制、**拒绝**重启而不是瞎猜,并在提示里写出该跑的那条重启命令:带上 daemon 正在用的 flag 执行 `ccteam stop && ccteam start --web-bind [...]`,或 `ccteam daemon restart --web-bind `。 -daemon 重启对活会话意味着什么 = 按 id 恢复的合同,再加一条规则 —— **一个会话一个进程**:`terminal`/tmux 会话照常跑(独立进程树);默认 `stream-json` 会话的进程是被「放手」而不是被杀 —— 空闲的自己退出,turn 进行中的跑完这个 turn。新 daemon 凭 body 记录(`/.ccteam/chat//body.json`)认出这样的幸存者,绝不为同一会话再起第二个进程:会话显示为 `detached`(web 侧栏、`agent_read activity:detached`、IM `/sessions`),发给它的消息排队、等该进程一退出就投递,`/stop` / `agent_stop` 立即结束它;它退出后 ccteam 从 Claude 自己的 transcript 里找回它这段时间给出的回答并投递(IM/web 回复、委派通知),再按 id 重建会话。ACP(grok/kimi/opencode)与 codex 进程随 daemon 一起结束;在飞的 turn 被打断,下条消息按 id 恢复上下文。`ccteam status` 与 `ccteam doctor` 显示安装渠道、运行中 vs 二进制版本、以及是否有新版本(惰性检查,最多每 ~20h 一次;`preferences.toml` 的 `check_for_update` 可关)。**卫星自己更新** —— 在每台上跑 `ccteam update`;控制台的主机页与 `ccteam status` 会标出版本落后于 daemon 的主机。 +daemon 重启对活会话意味着什么 = 按 id 恢复的合同,再加一条规则 —— **一个会话一个进程**:`terminal`/tmux 会话照常跑(独立进程树);默认 `stream-json` 会话的进程是被「放手」而不是被杀 —— 空闲的自己退出,turn 进行中的跑完这个 turn,还有后台任务在跑的(dev server、watcher)活到那个任务结束。新 daemon 凭 body 记录(`/.ccteam/chat//body.json`)认出这样的幸存者,绝不为同一会话再起第二个进程:会话显示为 `detached`(web 侧栏、`agent_read activity:detached`、IM `/sessions`),发给它的消息排队、等该进程一退出就投递。等待有上限:有消息排队、且 Claude transcript 显示最后一个 turn 已结束并静默 30 秒(或 turn 未结束但已静默 30 分钟)时,ccteam 结束这个进程 —— 只结束它本身,它启动的后台进程继续运行、无人看管,并会列给你、记进进度账本(`session_body_exited` 的 `orphans`)—— 再按原上下文恢复同一会话并投递排队消息;`/interrupt `(REST `POST /api/v1/sessions/{sid}/interrupt`)立即这样做,`/stop` / `agent_stop` 则结束会话、它当前的进程及该进程的后台进程(更早一次 `/interrupt` 或自动结束留下的后台进程属于已结束的那个进程,不受影响);它退出后 ccteam 从 Claude 自己的 transcript 里找回它这段时间给出的回答并投递(IM/web 回复、委派通知),再按 id 重建会话。ACP(grok/kimi/opencode)与 codex 进程随 daemon 一起结束;在飞的 turn 被打断,下条消息按 id 恢复上下文。`ccteam status` 与 `ccteam doctor` 显示安装渠道、运行中 vs 二进制版本、以及是否有新版本(惰性检查,最多每 ~20h 一次;`preferences.toml` 的 `check_for_update` 可关)。**卫星自己更新** —— 在每台上跑 `ccteam update`;控制台的主机页与 `ccteam status` 会标出版本落后于 daemon 的主机。 ### 多机(卫星节点) diff --git a/docs/usage.md b/docs/usage.md index 5d97706bb..99c5e7ba9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -217,6 +217,7 @@ One daemon can serve multiple users on one machine. This is **soft isolation** u - **Status** shows daemon health, live/idle session counts, per-session cost, and today's total cost / budget. The top-bar cost pill uses the same data. - **Per-reply status line.** Every reply a session produces carries a compact status line built once by the daemon from the same data — `→ cct/s42 (reviewer) · codex · gpt-5.6-sol · turn 7 · ctx 19% · $0.42` (project/sid, role, vendor + observed model, context-window usage with `⚠` from 85%, completed turns, cumulative cost — or `12.3k tok` when the vendor has no price table; a field the vendor does not report is simply omitted, never shown as a placeholder). It is attached once per vendor turn, on the turn's final message only (a Codex turn that streams several messages gets exactly one), so it never repeats — and when a long turn's text was already delivered mid-turn, that one status line arrives on its own as the turn's closing receipt. IM chats get it as the last line of the reply; the web console shows it as a muted footer under the turn's last assistant message and keeps the header model/ctx chips in sync from it. Agent-facing surfaces get numbers, not prose: `agent_read` transcripts and roster rows, and inline `agent` results, carry `context_pct` (plus the existing `cost_usd` / `tokens_total` / `model`), and each child→parent completion notification opens with one header line — `s12 done · codex · turn 7 · ctx 19%` — so a parent can decide between reusing a child and spawning a fresh one without another call; interim notes carry nothing extra. +- **Context sidebar.** Click a session's `model · ctx` chip (or the sidebar button at the top right) in the web console to open a right-hand sidebar — it stays open as you switch sessions — that shows what the context window is made of: system prompt, tools, memory files (each `CLAUDE.md` / memory file by path), skills, MCP tools and instructions, custom agents, and the conversation itself split by tool (call vs result tokens) and by attachment type, plus the last request's cache read / cache write / uncached input. Tool schemas the harness has deferred (not loaded) are listed apart, because they do not occupy the window. The numbers are the harness's own accounting (Claude's `/context` data), not a model's guess: a live session is asked on every open and on **Refresh**, and the sidebar refreshes itself when a turn ends (so a `/compact` shows up at once); a released session shows the snapshot taken at the end of its last turn, with its time. Claude stream-json sessions only for now; other harnesses keep the ctx % alone. REST: `GET /api/v1/sessions/{sid}/context`. - Cost is tracked separately by vendor. Claude / Codex / Grok use embedded tables when the model is known; **OpenCode and Pi use only vendor-reported USD** (or "—" when missing/zero — never another vendor's price table); **DSH reports raw tokens but has no USD price table yet**; **Kimi always shows "—"** (its ACP wire carries no usage/cost). A turn that fails part-way is still billed by the vendor, so its tokens and cost land on the ledger like any other turn — a failure never reads as free. ### Standard Resource API @@ -461,7 +462,7 @@ ccteam status # Daemon heartbeat, projects, sessions, web link, ccteam doctor # Install/dependency checks; --verify-mcp checks MCP surface. ``` -`ccteam init` only writes ccteam-owned files: project `.ccteam/` and the ccteam hook section in `.claude/settings.local.json` (it seeds no roles — `.claude/agents/` stays yours). It does **not** touch your `.claude/settings.json`. Re-running is safe. Preferences live in `~/.ccteam/preferences.toml`; currently `fallback.on_claude_quota = off|codex` controls whether Claude quota exhaustion falls back to Codex. +`ccteam init` only writes ccteam-owned files: project `.ccteam/` (it seeds no roles — `.claude/agents/` stays yours, and it writes no Claude settings: no `.claude/settings.local.json`, no hooks). It does **not** touch your `.claude/settings.json`. Re-running is safe, and on a project an older ccteam initialized it strips the Claude Code hooks (and the Telegram plugin pin) that version wrote into `.claude/settings.local.json`, leaving every other key and your own hooks in place; a file that is not valid JSON is left untouched and noted. Preferences live in `~/.ccteam/preferences.toml`; currently `fallback.on_claude_quota = off|codex` controls whether Claude quota exhaustion falls back to Codex. ### `project` (Project Lifecycle) @@ -475,7 +476,7 @@ ccteam project rm demo --dry-run # Preview what would stop/delete. ccteam project rm demo --purge # Deregister and remove ccteam-owned project traces. ``` -`rm --purge` removes only ccteam-owned traces: project `.ccteam/` and ccteam hook entries in `settings.local.json`. It **always keeps** your work roles, `CLAUDE.md` / `AGENTS.md`, `.env`, product code, and `.claude/settings.json`. +`rm --purge` removes only ccteam-owned traces: project `.ccteam/` and any ccteam hook entries left in `settings.local.json`. It **always keeps** your work roles, `CLAUDE.md` / `AGENTS.md`, `.env`, product code, and `.claude/settings.json`. ### `session` (Sessions) @@ -558,7 +559,7 @@ ccteam update --now # Skip the drain wait and restart immediately A restart never moves the daemon: `ccteam update` (like `ccteam daemon restart` with no flags) replays the launcher's recorded flags or, when the daemon predates that record, the binds it reports on `/health`. When neither is available — no launcher record and no `/health` answer (a daemon started with `--no-web`, or a build older than the identity surface) — the binary is swapped but the restart is **refused** rather than guessed, and the message names the exact restart to run: `ccteam stop && ccteam start --web-bind [...]` with the flags the daemon is running, or `ccteam daemon restart --web-bind `. -What a daemon restart does to live sessions is the resume-by-id contract plus one rule — **one session, one process**: `terminal`/tmux sessions keep running (separate process tree); a default `stream-json` session's process is let go, not killed — an idle one exits on its own, a session mid-turn keeps working to the end of its turn. The new daemon finds such a survivor by its body record (`/.ccteam/chat//body.json`) and never starts a second process for the same session: the session shows as `detached` (web rail, `agent_read activity:detached`, IM `/sessions`), messages sent to it queue and are delivered the moment that process exits, `/stop` / `agent_stop` ends it now, and when it exits ccteam recovers the answer it gave in the meantime from Claude's own transcript and delivers it (IM/web reply, delegation notification) before rebuilding the session by id. ACP (grok/kimi/opencode) and codex processes end with the daemon; their in-flight turn is interrupted and the session resumes its context by id on the next message. `ccteam status` and `ccteam doctor` show the install channel, the running-vs-binary version, and whether a newer release is available (a lazy check, at most once every ~20h; toggle with `check_for_update` in `preferences.toml`). **Satellites update themselves** — run `ccteam update` on each; the console's Hosts view and `ccteam status` flag any host whose version lags the daemon. +What a daemon restart does to live sessions is the resume-by-id contract plus one rule — **one session, one process**: `terminal`/tmux sessions keep running (separate process tree); a default `stream-json` session's process is let go, not killed — an idle one exits on its own, a session mid-turn keeps working to the end of its turn, and one with a background task still running (a dev server, a watcher) stays alive until that task ends. The new daemon finds such a survivor by its body record (`/.ccteam/chat//body.json`) and never starts a second process for the same session: the session shows as `detached` (web rail, `agent_read activity:detached`, IM `/sessions`), messages sent to it queue and are delivered the moment that process exits. The wait is bounded: once a message is queued and Claude's transcript shows the last turn ended and has been quiet for 30 seconds (or a turn has been silent for 30 minutes), ccteam ends that process — only that process: background processes it started keep running, unattended, and are listed to you and in the progress ledger (`orphans` on `session_body_exited`) — and resumes the same session on its context with the queued messages. `/interrupt ` (REST `POST /api/v1/sessions/{sid}/interrupt`) does that at once; `/stop` / `agent_stop` ends the session together with its current process and that process's background processes (ones left running by an earlier `/interrupt` or automatic end belong to the process that already ended and are not touched), and when it exits ccteam recovers the answer it gave in the meantime from Claude's own transcript and delivers it (IM/web reply, delegation notification) before rebuilding the session by id. ACP (grok/kimi/opencode) and codex processes end with the daemon; their in-flight turn is interrupted and the session resumes its context by id on the next message. `ccteam status` and `ccteam doctor` show the install channel, the running-vs-binary version, and whether a newer release is available (a lazy check, at most once every ~20h; toggle with `check_for_update` in `preferences.toml`). **Satellites update themselves** — run `ccteam update` on each; the console's Hosts view and `ccteam status` flag any host whose version lags the daemon. State file quick reference. `~/.ccteam` is grouped by responsibility: `secrets/` for credentials, `state/` for daemon-written state, `cache/` for disposable cache, and `run/` for sockets. diff --git a/dsh-plugins/ccteam-ui/package-lock.json b/dsh-plugins/ccteam-ui/package-lock.json index e5bc90620..20ef2e88e 100644 --- a/dsh-plugins/ccteam-ui/package-lock.json +++ b/dsh-plugins/ccteam-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ccteam/ccteam-ui", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ccteam/ccteam-ui", - "version": "0.12.0", + "version": "0.13.0", "bundleDependencies": [ "@deepseek-ai/schemastery" ], @@ -41,27 +41,27 @@ "vitest": "^3.2.4" }, "optionalDependencies": { - "@ccteam/engine-darwin-arm64": "0.12.0", - "@ccteam/engine-darwin-x64": "0.12.0", - "@ccteam/engine-linux-arm64": "0.12.0", - "@ccteam/engine-linux-x64": "0.12.0" + "@ccteam/engine-darwin-arm64": "0.13.0", + "@ccteam/engine-darwin-x64": "0.13.0", + "@ccteam/engine-linux-arm64": "0.13.0", + "@ccteam/engine-linux-x64": "0.13.0" } }, "node_modules/@ccteam/engine-darwin-arm64": { "optional": true, - "version": "0.12.0" + "version": "0.13.0" }, "node_modules/@ccteam/engine-darwin-x64": { "optional": true, - "version": "0.12.0" + "version": "0.13.0" }, "node_modules/@ccteam/engine-linux-arm64": { "optional": true, - "version": "0.12.0" + "version": "0.13.0" }, "node_modules/@ccteam/engine-linux-x64": { "optional": true, - "version": "0.12.0" + "version": "0.13.0" }, "node_modules/@deepseek-ai/cordis": { "version": "4.0.1", diff --git a/dsh-plugins/ccteam-ui/package.json b/dsh-plugins/ccteam-ui/package.json index 67ac05dfd..75d20b1bb 100644 --- a/dsh-plugins/ccteam-ui/package.json +++ b/dsh-plugins/ccteam-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ccteam/ccteam-ui", - "version": "0.12.0", + "version": "0.13.0", "description": "ccteam inside the DeepSeek Harness: the workbench in DSH web (cross-harness session tree, native-grade chat, one-click spawn), the six ccteam MCP tools for DSH agents, and the ACP transport ccteam hires DSH sessions through", "type": "module", "main": "lib/index.js", @@ -19,7 +19,7 @@ "README.md" ], "ccteam": { - "engine": "0.12.0" + "engine": "0.13.0" }, "dsh": { "bundle": { @@ -49,10 +49,10 @@ "@deepseek-ai/schemastery": "^3.18.1" }, "optionalDependencies": { - "@ccteam/engine-darwin-arm64": "0.12.0", - "@ccteam/engine-darwin-x64": "0.12.0", - "@ccteam/engine-linux-arm64": "0.12.0", - "@ccteam/engine-linux-x64": "0.12.0" + "@ccteam/engine-darwin-arm64": "0.13.0", + "@ccteam/engine-darwin-x64": "0.13.0", + "@ccteam/engine-linux-arm64": "0.13.0", + "@ccteam/engine-linux-x64": "0.13.0" }, "bundledDependencies": [ "@deepseek-ai/schemastery" diff --git a/dsh-plugins/ccteam-ui/src/index.ts b/dsh-plugins/ccteam-ui/src/index.ts index 4a4605f33..9f2bf594e 100644 --- a/dsh-plugins/ccteam-ui/src/index.ts +++ b/dsh-plugins/ccteam-ui/src/index.ts @@ -102,7 +102,7 @@ export const Config: Schema = Schema.object({ * Rust side gates the handshake on it (`MIN_DSH_CLIENT_VERSION`), so it must * stay in step with package.json — asserted by tests/host-exports.test.ts. */ -export const PACKAGE_VERSION = '0.12.0' +export const PACKAGE_VERSION = '0.13.0' /** * The engine version this plugin is published against — package.json @@ -114,7 +114,7 @@ export const PACKAGE_VERSION = '0.12.0' * repairs — the engine is older, so update the engine; the plugin is older, so * update the plugin. */ -export const ENGINE_VERSION = '0.12.0' +export const ENGINE_VERSION = '0.13.0' export interface ApplyContext extends BffContext { /**