From a3cddd9a8a71fcbc4b0af00357ae4c6d4342754f Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 00:26:25 +0000 Subject: [PATCH 01/29] feat(init): project creation writes no Claude hooks; re-init strips old ones docs-local issue #212. Creating a project (ccteam init, web POST /projects, IM/web chat create, satellite import - all via bootstrap_project_at_dir) no longer lays down .claude/settings.local.json. The hooks block it carried only served the retired tmux/rmux terminal protocol; the default stream-json session is hookless (CCTEAM_HOOKLESS, events from stdout, HITL via can_use_tool), so nothing depended on it. The rest of that template (permissions.defaultMode, env.CCTEAM_HOME/CCTEAM_PROJECTS_ROOT, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, an always-empty enabledPlugins) either fed the hook subprocesses or is overridden by ccteam's own spawn flags, so the file is not created at all. Existing projects: remove_chat_hooks becomes remove_ccteam_hooks and now recognises every hook shape ccteam ever wrote (<...>/hook.sh , ccteam [internal] hook, cct hook, ccteam mux hook-emit), so it also catches the bootstrap progress-append/load-context entries the old predicate missed. It runs on bootstrap, on an init re-run, and on project rm --purge; it removes only those entries, keeps every other key and user hook, deletes a file left empty, and never reads or writes .claude/settings.json. Removed as dead: the settings template + render/merge helpers and their exports, rewrite_legacy_hook_commands and remove_cost_accumulate_hooks (no production caller; the new predicate covers their legacy forms). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- crates/ccteam-cli/src/commands.rs | 97 ++- .../ccteam-core/src/hooks_dispatcher/mod.rs | 2 +- crates/ccteam-core/src/lib.rs | 17 +- crates/ccteam-core/src/paths.rs | 19 +- crates/ccteam-core/src/projects.rs | 162 ++-- crates/ccteam-core/src/templates/mod.rs | 521 +----------- .../ccteam-core/src/templates/settings.json | 68 -- crates/ccteam-core/src/tool_surface.rs | 773 +++++------------- crates/ccteam-core/tests/cost_summary_test.rs | 73 +- docs/usage-cn.md | 4 +- docs/usage.md | 4 +- 12 files changed, 383 insertions(+), 1361 deletions(-) delete mode 100644 crates/ccteam-core/src/templates/settings.json diff --git a/README.md b/README.md index 657120cef..c32ae0618 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,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. - **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 +267,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 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..c13211036 100644 --- a/crates/ccteam-cli/src/commands.rs +++ b/crates/ccteam-cli/src/commands.rs @@ -211,6 +211,13 @@ pub fn run_init(paths: &CcteamPaths, opts: InitOptions) -> Result { target.join(".claude").join("agents").display(), project_report.agents_action, )); + if let Some(entries) = project_report.hooks_stripped { + out.push_str(&format!( + " claude hooks {} (stripped {entries} retired ccteam hook entr{})\n", + target.join(".claude").join("settings.local.json").display(), + if entries == 1 { "y" } else { "ies" }, + )); + } out.push_str(&format!( " config.yaml {} (upserted)\n", ccteam_core::ccteam_config_path(&paths.root).display(), @@ -315,6 +322,10 @@ struct ProjectInstallReport { state_action: &'static str, workflow_action: &'static str, agents_action: &'static str, + /// Old ccteam hook entries a refresh stripped from + /// `.claude/settings.local.json` (docs-local issue #212); `None` when + /// there was nothing to strip. + hooks_stripped: Option, } impl ProjectInstallReport { @@ -363,6 +374,7 @@ fn install_project_at( let state_action: &'static str; let workflow_action: &'static str; let agents_action: &'static str; + let mut hooks_stripped = None; let final_team: String; let owner_final: Option; @@ -424,6 +436,18 @@ fn install_project_at( } else { "preserved" }; + + // 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 gets the same scrub inside `bootstrap_project_at_dir`). + let scrub = ccteam_core::remove_ccteam_hooks( + &target.join(".claude").join("settings.local.json"), + false, + )?; + use ccteam_core::CcteamHookScrubAction as A; + if let A::Removed { entries } | A::RemovedNowEmpty { entries } = scrub.action { + hooks_stripped = Some(entries); + } } Ok(ProjectInstallReport { @@ -434,6 +458,7 @@ fn install_project_at( state_action, workflow_action, agents_action, + hooks_stripped, }) } @@ -3664,16 +3689,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,8 +3740,8 @@ 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 => {} A::WouldRemove { entries } => { @@ -3734,9 +3759,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 +4662,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 +4714,50 @@ 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"); + } + /// `--force` re-runs overwrite user files. workflow.yaml lives in /// `.ccteam/`, not the root. #[test] 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/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..9a2f07964 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,17 @@ 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. + crate::tool_surface::remove_ccteam_hooks( + &project_dir.join(".claude").join("settings.local.json"), + false, + )?; // 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 +1169,96 @@ 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" ); } - /// 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 +1275,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..4f3946cab 100644 --- a/crates/ccteam-core/src/tool_surface.rs +++ b/crates/ccteam-core/src/tool_surface.rs @@ -493,307 +493,57 @@ 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 { - /// `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 { +pub enum CcteamHookScrubAction { /// `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, /// `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`]); 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, }); } @@ -814,7 +564,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 { +/// 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 +1056,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 +1098,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 +1115,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 +1123,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 +1141,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 +1173,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 +1202,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 +1210,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 +1237,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 +1245,77 @@ 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")); } } 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/docs/usage-cn.md b/docs/usage-cn.md index 7beb0e6a7..57f3b0f6b 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -442,7 +442,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 剥掉,其余键和你自己的 hook 原样保留。偏好存 `~/.ccteam/preferences.toml`(目前一个键:`fallback.on_claude_quota` = `off`|`codex`,Claude 配额触顶是否回退 Codex)。 ### `project`(项目生命周期) @@ -456,7 +456,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`(会话) diff --git a/docs/usage.md b/docs/usage.md index 5d97706bb..6a76f0912 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -461,7 +461,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 that version wrote into `.claude/settings.local.json`, leaving every other key and your own hooks in place. 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 +475,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) From 662409ba8a8eb570663c3395a91d9509f3d22682 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 00:26:42 +0000 Subject: [PATCH 02/29] fix(im): one bad event can no longer stop reply delivery for every session docs-local issue #210: during an ENOSPC window the single gateway event consumer task exited (its panic message was lost to the full log disk). Its mpsc receiver dropped, every session's event pump read the closed sink as "daemon exited" and stopped silently, and no reply reached IM, web or turns.jsonl again until the daemon restarted. - Consumer: each event is delivered under catch_unwind; a panic is logged at error with event id / channel / sid and the loop goes on. Every awaited platform call (send, progress edit/seed, reactions, split notice, durable send + replay) is bounded by a 120s timeout; a timed-out durable send is recorded Failed in the ledger. A poisoned channel-map lock no longer panics. Consumer exit logs at error. - GatewayEventSink logs at error (sid, event id, channel) when the delivery mpsc is closed, so every pump that stops says so; the stop semantics for real shutdown are unchanged. - fs_atomic::open_jsonl_append terminates a torn last line before appending, so the first record after ENOSPC is not glued onto the fragment. Used by turns.jsonl, progress.jsonl, the durable outbox, pending_turns and experience.jsonl; the outbox and pending_turns now write each record with a single write_all. Co-Authored-By: Claude Opus 5.5 --- .../src/execution/experience.rs | 5 +- .../ccteam-harness/src/execution/fs_atomic.rs | 68 +++ .../src/execution/progress_bridge.rs | 5 +- .../src/execution/turns_mirror.rs | 26 +- crates/ccteam-im/src/daemon.rs | 571 ++++++++++++++---- crates/ccteam-im/src/gateway.rs | 35 +- crates/ccteam-im/src/pending_turns.rs | 12 +- 7 files changed, 568 insertions(+), 154 deletions(-) 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/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/turns_mirror.rs b/crates/ccteam-harness/src/execution/turns_mirror.rs index 2d45dad92..cb6bab730 100644 --- a/crates/ccteam-harness/src/execution/turns_mirror.rs +++ b/crates/ccteam-harness/src/execution/turns_mirror.rs @@ -183,10 +183,7 @@ pub fn append_turn(project_dir: &Path, sid: &str, record: &TurnRecord) -> 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()))?; @@ -316,6 +313,27 @@ mod tests { ); } + /// 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(); diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index 8a9b8f239..6c9f7b39a 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -1757,7 +1757,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 +1779,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 +1810,230 @@ 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<()> { + use futures::FutureExt; 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" + let event_id = evt.id.clone(); + let channel_name = evt.channel.clone(); + let sid = evt.sid.clone(); + let delivery = + deliver_gateway_event(&event_outbox_root, &channels, &mut state, evt, call_timeout); + if let Err(panic) = std::panic::AssertUnwindSafe(delivery).catch_unwind().await { + tracing::error!( + event_id = %event_id, + channel = %channel_name, + sid = sid.as_deref().unwrap_or("-"), + panic = panic_message(panic.as_ref()), + "ccteam-im: gateway event delivery panicked; event dropped, consumer continues" ); - 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)" - ); - } + } + } + tracing::error!( + "ccteam-im: gateway event consumer exited; session replies are no longer delivered" + ); + }) +} + +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("") +} + +/// 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 +2050,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 +2074,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, @@ -2012,6 +2131,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 +2150,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 +2179,7 @@ async fn send_gateway_outbound( channel_name, channel, part_msg, + call_timeout, ) .await; if !sent { @@ -2070,7 +2199,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,6 +2222,7 @@ 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, @@ -2105,7 +2237,16 @@ async fn queue_and_send_durable_part( error: None, }, ); - finish_durable_outbound_send(ccteam_root, id, inbound_id, channel_name, channel, message).await + finish_durable_outbound_send( + ccteam_root, + id, + inbound_id, + channel_name, + channel, + message, + call_timeout, + ) + .await } /// Send a single already-queued durable row and append its terminal @@ -2117,8 +2258,9 @@ async fn finish_durable_outbound_send( channel_name: &str, channel: &(dyn Channel + Send + Sync), message: SendMessage, + call_timeout: Duration, ) -> bool { - match channel.send(&message).await { + match bounded_platform_call(call_timeout, "send", channel.send(&message)).await { Ok(platform_message_id) => { append_durable_outbound( ccteam_root, @@ -2211,6 +2353,7 @@ async fn replay_durable_outbox(ccteam_root: &Path, channels: &ChannelMap) { &row.channel, channel.as_ref(), row.message, + PLATFORM_CALL_TIMEOUT, ) .await; } @@ -2237,12 +2380,10 @@ fn append_durable_outbound_inner(ccteam_root: &Path, row: &DurableOutboundRow) - 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'); + ccteam_harness::execution::fs_atomic::open_jsonl_append(&path)?.write_all(&line)?; Ok(()) } @@ -2656,6 +2797,172 @@ 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; + } + 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, + 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"); + } + /// (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/gateway.rs b/crates/ccteam-im/src/gateway.rs index 2879a2695..d3df52297 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -2290,7 +2290,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 +2298,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 +2307,37 @@ 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(event: &GatewayEvent) { + tracing::error!( + sid = event.sid.as_deref().unwrap_or("-"), + event_id = %event.id, + channel = %event.channel, + "ccteam-im: gateway event consumer is gone; stopping this session's event pump \ + (expected only during daemon shutdown)" + ); } } diff --git a/crates/ccteam-im/src/pending_turns.rs b/crates/ccteam-im/src/pending_turns.rs index 5e065ebba..34baef481 100644 --- a/crates/ccteam-im/src/pending_turns.rs +++ b/crates/ccteam-im/src/pending_turns.rs @@ -7,7 +7,6 @@ //! (`submit_resolved` / event pump). No alternate turns writer. use std::collections::VecDeque; -use std::fs::OpenOptions; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; @@ -115,13 +114,12 @@ pub fn enqueue_pending_turn( intent, request_id, }; - let mut f = OpenOptions::new() - .create(true) - .append(true) - .open(&path) + // One `write_all` per record (see `open_jsonl_append`). + let mut line = serde_json::to_vec(&row)?; + line.push(b'\n'); + let mut f = ccteam_harness::execution::fs_atomic::open_jsonl_append(&path) .with_context(|| format!("open pending_turns {}", path.display()))?; - serde_json::to_writer(&mut f, &row)?; - f.write_all(b"\n")?; + f.write_all(&line)?; f.sync_all().ok(); Ok(()) } From 287ecb2825513691979e1866c8bcce0c013cc867 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 00:41:38 +0000 Subject: [PATCH 03/29] fix(init): pin Telegram off via --settings; never fail create on a bad settings file docs-local issue #212, checker follow-up to a3cddd9a. - Every local stream-json spawn still wrote enabledPlugins."telegram@claude-plugins-official"=false into .claude/settings.local.json, recreating the file on a project's first message. The pin now rides claude's flag-settings layer (`--settings ''` in spawn_spec::build_argv; it outranks user/project/local), so the stream-json path writes no project file. The frozen terminal protocol keeps its own writes. - remove_ccteam_hooks also strips that pin (exactly `false`; a user value stays; an emptied enabledPlugins is pruned), so init re-run and `rm --purge` clean it from older projects. - remove_ccteam_hooks no longer errors on an unreadable / invalid file: it reports Unparseable and leaves the file byte-for-byte. Project creation (bootstrap_project_at_dir: init, web, IM, satellite) warns and continues; `ccteam init` notes it in the receipt; `rm --purge` still refuses. Pre-v0.8.6 projects may still have ccteam hooks in .claude/settings.json; they are inert (CCTEAM_HOOKLESS) and deliberately not touched (red line). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- crates/ccteam-cli/src/commands.rs | 84 ++++++++++---- crates/ccteam-core/src/marketplace_plugin.rs | 5 +- crates/ccteam-core/src/projects.rs | 47 +++++++- crates/ccteam-core/src/tool_surface.rs | 103 +++++++++++++++++- .../src/execution/claude_stream_json/mod.rs | 12 +- .../claude_stream_json/spawn_spec.rs | 39 +++++++ docs/usage-cn.md | 2 +- docs/usage.md | 2 +- 9 files changed, 253 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index c32ae0618..aceb4cc55 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,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 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. +- **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 +267,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/` — or, before uninstalling, run `ccteam project rm --purge `, which also strips any hook entries an older ccteam left in `.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 c13211036..3e4f6ec07 100644 --- a/crates/ccteam-cli/src/commands.rs +++ b/crates/ccteam-cli/src/commands.rs @@ -211,11 +211,10 @@ pub fn run_init(paths: &CcteamPaths, opts: InitOptions) -> Result { target.join(".claude").join("agents").display(), project_report.agents_action, )); - if let Some(entries) = project_report.hooks_stripped { + if let Some(note) = &project_report.hooks_note { out.push_str(&format!( - " claude hooks {} (stripped {entries} retired ccteam hook entr{})\n", + " claude settings {} ({note})\n", target.join(".claude").join("settings.local.json").display(), - if entries == 1 { "y" } else { "ies" }, )); } out.push_str(&format!( @@ -322,10 +321,10 @@ struct ProjectInstallReport { state_action: &'static str, workflow_action: &'static str, agents_action: &'static str, - /// Old ccteam hook entries a refresh stripped from + /// What happened to old ccteam hook entries in /// `.claude/settings.local.json` (docs-local issue #212); `None` when - /// there was nothing to strip. - hooks_stripped: Option, + /// there was nothing to report. + hooks_note: Option, } impl ProjectInstallReport { @@ -374,7 +373,6 @@ fn install_project_at( let state_action: &'static str; let workflow_action: &'static str; let agents_action: &'static str; - let mut hooks_stripped = None; let final_team: String; let owner_final: Option; @@ -436,20 +434,21 @@ fn install_project_at( } else { "preserved" }; - - // 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 gets the same scrub inside `bootstrap_project_at_dir`). - let scrub = ccteam_core::remove_ccteam_hooks( - &target.join(".claude").join("settings.local.json"), - false, - )?; - use ccteam_core::CcteamHookScrubAction as A; - if let A::Removed { entries } | A::RemovedNowEmpty { entries } = scrub.action { - hooks_stripped = Some(entries); - } } + // 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, @@ -458,10 +457,23 @@ fn install_project_at( state_action, workflow_action, agents_action, - hooks_stripped, + 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::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 @@ -3744,6 +3756,14 @@ fn purge_project_managed_paths( 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::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 ({})", @@ -4758,6 +4778,30 @@ mod tests { 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}"); + } + } + /// `--force` re-runs overwrite user files. workflow.yaml lives in /// `.ccteam/`, not the root. #[test] 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/projects.rs b/crates/ccteam-core/src/projects.rs index 9a2f07964..aa453074a 100644 --- a/crates/ccteam-core/src/projects.rs +++ b/crates/ccteam-core/src/projects.rs @@ -361,10 +361,26 @@ pub fn bootstrap_project_at_dir( // 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. - crate::tool_surface::remove_ccteam_hooks( - &project_dir.join(".claude").join("settings.local.json"), - false, - )?; + // 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) => { + if let crate::tool_surface::CcteamHookScrubAction::Unparseable { error } = report.action + { + 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 @@ -1256,6 +1272,29 @@ mod tests { ); } + /// 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}"); + } + } + /// 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. diff --git a/crates/ccteam-core/src/tool_surface.rs b/crates/ccteam-core/src/tool_surface.rs index 4f3946cab..7773b37c2 100644 --- a/crates/ccteam-core/src/tool_surface.rs +++ b/crates/ccteam-core/src/tool_surface.rs @@ -502,6 +502,11 @@ pub enum CcteamHookScrubAction { /// No ccteam hook entries found. Idempotent re-run after a successful /// scrub hits this branch. NoChangeNeeded, + /// The file exists but could not be read or is not valid JSON (an empty + /// file, a trailing comma …). It is 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). + 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 @@ -532,7 +537,10 @@ pub struct CcteamHookScrubReport { /// clean them. /// /// **Scope (red line).** Removes ONLY entries whose `command` is a ccteam -/// hook ([`hook_command_is_ccteam_hook`]); every other key (`permissions`, +/// 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 @@ -547,12 +555,36 @@ pub fn remove_ccteam_hooks(settings_path: &Path, dry_run: bool) -> Result(&body).map_err(|e| e.to_string()) + }); + let mut v = match parsed { + Ok(v) => v, + Err(error) => { + return Ok(CcteamHookScrubReport { + target: settings_path.to_path_buf(), + action: CcteamHookScrubAction::Unparseable { error }, + }) + } + }; 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()) + { + 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 { @@ -629,6 +661,11 @@ pub fn remove_ccteam_hooks(settings_path: &Path, dry_run: bool) -> Result) -> 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/docs/usage-cn.md b/docs/usage-cn.md index 57f3b0f6b..444e885cf 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -442,7 +442,7 @@ ccteam status # daemon 心跳 + 各项目及其会话 + web 链 ccteam doctor # 安装 / 依赖体检(--verify-mcp 校验 MCP 表面) ``` -`ccteam init` 只写 ccteam 自己的东西:项目 `.ccteam/`(状态)——**不种任何角色**,`.claude/agents/` 归你;也不写任何 Claude 设置(不建 `.claude/settings.local.json`、不装 hook),**不碰**你的 `.claude/settings.json`。重跑安全;对旧版 ccteam 初始化过的项目,重跑会把旧版写进 `.claude/settings.local.json` 的 Claude Code hook 剥掉,其余键和你自己的 hook 原样保留。偏好存 `~/.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`(项目生命周期) diff --git a/docs/usage.md b/docs/usage.md index 6a76f0912..f943b79a9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -461,7 +461,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/` (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 that version wrote into `.claude/settings.local.json`, leaving every other key and your own hooks in place. 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) From 656dd442ac74a84600816905d44719aa712e6dc9 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 00:49:17 +0000 Subject: [PATCH 04/29] fix(daemon): open mcp.sock only after the web /mcp listener is bound `ccteam start` reports "started" as soon as the daemon's mcp.sock answers an initialize probe, and every MCP client then dials the TCP /mcp that run/mcp-url advertises. The two listeners came up in separate tasks, and the web one does work before binding (the legacy DSH orphan sweep walks /proc), so a client right after start could hit "connection refused" - which is how `ccteam flow run` failed its first hire in flow_run_test on a busy host. ServeOpts gains a `bound` notifier fired once every listener is bound and run/mcp-url + daemon-endpoint.json carry the bound address; the daemon body holds mcp.sock until it fires (or the web task exits, so a failed web still leaves a usable daemon as before). flow_run_test now asserts the advertised port accepts the moment start returns. docs-local issue #214 Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-cli/src/commands.rs | 1 + crates/ccteam-cli/src/main.rs | 25 +++++++++++++++++++++--- crates/ccteam-cli/tests/flow_run_test.rs | 5 +++++ crates/ccteam-web/src/lib.rs | 11 +++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/ccteam-cli/src/commands.rs b/crates/ccteam-cli/src/commands.rs index 70ae81737..74a03491a 100644 --- a/crates/ccteam-cli/src/commands.rs +++ b/crates/ccteam-cli/src/commands.rs @@ -1785,6 +1785,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() diff --git a/crates/ccteam-cli/src/main.rs b/crates/ccteam-cli/src/main.rs index ca8a27ee3..8167722fd 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 = web_bound.clone(); let mut rx = shutdown_rx.clone(); let web_bridge = web_chat_bridge .as_ref() @@ -1848,7 +1856,7 @@ 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 { - ccteam_web::serve_with_state_factory_and_shutdown( + let served = ccteam_web::serve_with_state_factory_and_shutdown( opts, move |paths, auth| { let mut state = ccteam_web::AppState::with_auth(paths, auth); @@ -1867,7 +1875,13 @@ fn run_start(web: StartWebOpts, imd: StartImdOpts) -> Result<()> { let _ = rx.changed().await; }, ) - .await + .await; + // A web that failed before binding must not hold the socket + // closed forever: the daemon stays up without it, as before. + if let Some(bound) = web_exited { + bound.notify_one(); + } + served })) }; @@ -1919,6 +1933,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; }) @@ -2336,6 +2353,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/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-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); From 0e5abf0e551cc9e8c6e1213e0a7eb5409f3298d1 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 01:20:32 +0000 Subject: [PATCH 05/29] fix(im): /interrupt ends a body left over from a restart and resumes the same sid After `ccteam daemon restart`, a claude stream-json body that still has a background task running (a run_in_background dev server, a watcher) does not exit on stdin EOF: real claude 2.1.296 stays alive until that task ends and then self-continues. The new daemon registers it DETACHED and waits for it, which is unbounded, so every message to the sid queues behind it forever. The receipt pointed at `/stop`, which ends the session: it sets stopped_at, drops the chat's focus routes (the next message mints a new sid) and strands the queued turns. The session looked permanently lost. `/interrupt` (IM and `POST /api/v1/sessions/{sid}/interrupt`) on a detached sid now ends only that process. The DETACHED entry, the queue and the focus routes stay, and the body watcher takes the same path it takes for a body that exits on its own: it recovers what the body said, rebuilds the sid with --resume (same context) and drains the queued turns. The receipt names this way out, and the usage docs describe it. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-im/src/gateway.rs | 145 ++++++++++++++++++- crates/ccteam-web/src/routes/sessions_api.rs | 2 + docs/usage-cn.md | 2 +- docs/usage.md | 2 +- 4 files changed, 141 insertions(+), 10 deletions(-) diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 2879a2695..f77099445 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -4469,6 +4469,44 @@ 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 and its body is + /// now gone; `Ok(false)` when `sid` was not detached. + /// + /// Waiting is the only automatic exit from DETACHED, and it is unbounded: + /// a body that lost its stdin is never driveable again, yet `claude` stays + /// alive after stdin EOF for as long as any background task it started is + /// running (and self-continues when that task ends) — a dev server or a + /// watcher keeps it alive for good, and every message to the sid queues + /// behind it forever. `/stop` was the only way out, and it ends the + /// session (stopped, focus routes dropped), so the chat's next message + /// minted a new sid. This is the way out that keeps the sid: only the + /// process is signalled; the record, the queue, the focus routes and the + /// DETACHED entry stay, and the watcher takes the same path as a body + /// that exited on its own — recover what it said, rebuild by sid + /// (`--resume`, same context), drain the queued turns. + pub(crate) async fn end_detached_body(&mut self, sid: &str) -> Result { + let Some(detached) = self.detached.get(sid).cloned() else { + return Ok(false); + }; + let body = detached.body.clone(); + let sid_owned = sid.to_string(); + let ended = tokio::task::spawn_blocking(move || { + session_body::terminate(&body, &sid_owned, std::time::Duration::from_secs(5)) + }) + .await + .context("join body terminate")??; + if !ended { + return Err(anyhow!( + "{sid}'s body (pid {}) is still alive after SIGTERM and SIGKILL", + detached.body.pid + )); + } + tracing::info!(session = %sid, pid = detached.body.pid, "ccteam-im: detached body ended by user interrupt; resuming by sid"); + self.body_watch_notify.notify_one(); + Ok(true) + } + /// 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 +4538,11 @@ 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}) and is delivered when that process exits. \ + `/interrupt {sid}` ends the process now and resumes {sid} on the same context \ + (its background tasks end with it); `/stop {sid}` ends the session.", detached.body.pid, since ); Ok((receipt, format!("queued-behind-body:{sid}:{queued}"))) @@ -5629,15 +5669,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 等)" ))) @@ -17948,7 +17993,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).await? { + return Ok(()); + } let session = self .sessions .get(sid) @@ -22086,6 +22139,82 @@ 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:?}" + ); + let status = child.wait().expect("the ended body is waitable"); + assert!(status.code().is_none(), "signalled: {status:?}"); + + let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); + Gateway::body_watch_tick(&gateway).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()]); + } + /// 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 diff --git a/crates/ccteam-web/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index b22f25d96..ecec6bf3b 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -1741,6 +1741,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( diff --git a/docs/usage-cn.md b/docs/usage-cn.md index 7beb0e6a7..bec263210 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -559,7 +559,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`),发给它的消息排队、等该进程一退出就投递,`/interrupt `(REST `POST /api/v1/sessions/{sid}/interrupt`)立即结束这个进程并按原上下文恢复同一会话、投递排队消息,`/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 的主机。 ### 多机(卫星节点) diff --git a/docs/usage.md b/docs/usage.md index 5d97706bb..fd4ff3be9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -558,7 +558,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, `/interrupt ` (REST `POST /api/v1/sessions/{sid}/interrupt`) ends that process now and resumes the same session on its context with the queued messages, `/stop` / `agent_stop` ends the session, 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. From e965098e97c2321a59b17edfad47e07fa4acc00d Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 01:44:15 +0000 Subject: [PATCH 06/29] fix(harness): a failed local claude --resume is surfaced, never "fallen back" from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `.jsonl` exists, start_thread resumed and, on any failure, fell back to a fresh `--session-id ` on the SAME uuid. Real claude refuses that ("Session ID … is already in use", exit 1), so the fallback could never start. It turned one failed resume into a second failure, and it logged a chat_session_reset claiming the context was lost when the transcript was intact. The local arm now returns the resume failure itself, as a readable, retryable error. Nothing sticks: the transcript is untouched, the killed handshake child's body record is cleared (so the next spawn does not post-mortem a turn that never ran), and the next message tries --resume again. The remote fallback stays: there the transcript's existence is only a guess. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- .../src/execution/claude_stream_json/mod.rs | 62 +++++++++++-------- .../tests/claude_stream_json_test.rs | 54 ++++++++++------ 2 files changed, 69 insertions(+), 47 deletions(-) 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..0527de81f 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs @@ -1855,7 +1855,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 @@ -2053,11 +2062,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 +2100,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; send the message again to retry the resume" + )) + })? }; let identity = SessionIdentity { diff --git a/crates/ccteam-harness/tests/claude_stream_json_test.rs b/crates/ccteam-harness/tests/claude_stream_json_test.rs index ccf3110d2..7caadb2dc 100644 --- a/crates/ccteam-harness/tests/claude_stream_json_test.rs +++ b/crates/ccteam-harness/tests/claude_stream_json_test.rs @@ -2278,40 +2278,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 +2325,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(); } From b76dd809baf7f70911da3102f59a473fcdee3f4e Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 01:50:08 +0000 Subject: [PATCH 07/29] perf: costs that grew with daemon and session lifetime stay bounded docs-local issue #213. Paths whose cost was O(file size) or O(sessions ever) on a per-turn / per-poll / per-start cadence: - IM durable outbox: compacted to the rows still owed (Queued/Failed) at start and whenever an append pushes it past 8 MB; replay streams the file in queue order instead of read_to_string + HashMap of every row. At-least-once is unchanged: every undelivered id keeps its latest row. 86 MB ledger: start 535 ms -> 8 ms after first compaction. - journal::FoldCache: per-file incremental fold over an append-only journal (offset + inode, LRU-bounded); a later ask reads only the appended lines. - claude_stream_json `/goal` + Stop-hook gating (thread_status at every boundary and team-graph poll) moved to turn_gating.rs on FoldCache: 13 MB transcript 12.8 ms -> 9 us per warm call. - turns_mirror::digest: user/status turn counts, answer seq, last turn id as a fold. Replaces full read_all_turns in pump start (x2), every final answer, the per-turn meta refresh (3+ per turn) and the agent wait settle poll (every 20 ms for 2 s). 547 KB turns: 0.96 ms -> 3 us. - list_session_metas: stat-keyed parse cache; a rescan re-reads only meta.json files whose (dev, ino, len, mtime) moved. Serves the web status / team-graph polls, the history sidebar and IM listings. 1263 sessions: 9.8 ms -> 4.7 ms. - web SSE replay ring: only the 128 most recently active sessions keep their 64-frame backlog (next_seq kept, so seqs never restart); previously every sid ever seen kept one for the process lifetime. - progress projection: cost_summary() for budget gates and fleet headers instead of deep-copying every session to read one number. - daemon.log rotated to daemon.log.1 at start once past 16 MB. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-core/src/daemon.rs | 44 +++ .../src/execution/claude_stream_json/mod.rs | 232 +------------- .../claude_stream_json/turn_gating.rs | 303 ++++++++++++++++++ .../ccteam-harness/src/execution/journal.rs | 150 +++++++++ .../src/execution/session_meta.rs | 109 ++++++- .../src/execution/turns_mirror.rs | 113 +++++++ crates/ccteam-im/src/daemon.rs | 156 ++++++++- crates/ccteam-im/src/gateway.rs | 70 ++-- crates/ccteam-im/src/mcp/dispatch.rs | 9 +- crates/ccteam-im/src/mcp/protocol.rs | 2 +- crates/ccteam-im/src/mcp/vendor_panel.rs | 9 +- crates/ccteam-im/src/progress_projection.rs | 19 +- crates/ccteam-web/src/ring.rs | 64 +++- 13 files changed, 972 insertions(+), 308 deletions(-) create mode 100644 crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs 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-harness/src/execution/claude_stream_json/mod.rs b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs index 53099805d..241ce1e0f 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs @@ -33,6 +33,7 @@ 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}; @@ -1561,155 +1562,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( @@ -2964,8 +2816,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 +2826,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()); @@ -3515,10 +3368,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 +3712,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/turn_gating.rs b/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs new file mode 100644 index 000000000..7905422eb --- /dev/null +++ b/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs @@ -0,0 +1,303 @@ +//! 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. +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/journal.rs b/crates/ccteam-harness/src/execution/journal.rs index 2862c6518..931684e43 100644 --- a/crates/ccteam-harness/src/execution/journal.rs +++ b/crates/ccteam-harness/src/execution/journal.rs @@ -456,6 +456,127 @@ 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 file that shrank or whose inode changed +/// is a different file, and its fold restarts. A trailing line without `\n` +/// is left for the next read (its writer may still be extending it). +/// +/// 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 +617,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/session_meta.rs b/crates/ccteam-harness/src/execution/session_meta.rs index bf4a5dd73..d29215fa3 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,14 @@ 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 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 +411,84 @@ 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.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()); + seen.insert(name, CachedMeta { stamp, 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. +/// `meta.json` is only ever replaced by rename ([`write_session_meta`]), so a +/// new write is a new inode — the stamp cannot miss one. +static META_SCAN_CACHE: std::sync::LazyLock< + std::sync::Mutex>>, +> = std::sync::LazyLock::new(Default::default); + +struct CachedMeta { + stamp: MetaStamp, + meta: SessionMeta, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct MetaStamp { + file_id: (u64, u64), + len: u64, + modified: Option, +} + +impl MetaStamp { + 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 +710,40 @@ 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())] + ); + // Same length, same second: only the rename (new inode) tells. + 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())] + ); + } + // ---- truncate_title ---------------------------------------------------- #[test] diff --git a/crates/ccteam-harness/src/execution/turns_mirror.rs b/crates/ccteam-harness/src/execution/turns_mirror.rs index cb6bab730..b04c42f44 100644 --- a/crates/ccteam-harness/src/execution/turns_mirror.rs +++ b/crates/ccteam-harness/src/execution/turns_mirror.rs @@ -203,6 +203,75 @@ 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. +pub fn digest(project_dir: &Path, sid: &str) -> TurnsDigest { + /// 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)); + CACHE + .read( + &turns_jsonl_path(project_dir, sid), + || DigestFold { + prefix: format!("{sid}-"), + digest: TurnsDigest::default(), + }, + |fold| fold.digest.clone(), + ) + .unwrap_or_default() +} + /// 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. @@ -313,6 +382,50 @@ 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"), TurnsDigest::default()); + append_turn(tmp.path(), "s9", &mk_turn("s9-3", "r", "hi", "a")).unwrap(); + assert_eq!(digest(tmp.path(), "s9"), 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"); + 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"); + assert_eq!(d, full(tmp.path())); + assert_eq!(d.last_turn_id.as_deref(), Some("x-7")); + } + /// 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] diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index 6c9f7b39a..f4ea34faf 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -2302,18 +2302,49 @@ async fn finish_durable_outbound_send( } } -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 `Sent`, 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 == DurableOutboundState::Sent => { + 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) => { @@ -2326,10 +2357,45 @@ async fn replay_durable_outbox(ccteam_root: &Path, channels: &ChannelMap) { } } } - for row in latest - .into_values() - .filter(|row| row.state != DurableOutboundState::Sent) - { + 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 delivered 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)?; + Ok(owed) +} + +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 { let Some(channel) = channels.get(&row.channel) else { append_durable_outbound( ccteam_root, @@ -2371,11 +2437,15 @@ 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)?; @@ -2383,7 +2453,18 @@ fn append_durable_outbound_inner(ccteam_root: &Path, row: &DurableOutboundRow) - // One `write_all` per record (see `open_jsonl_append`). let mut line = serde_json::to_vec(row)?; line.push(b'\n'); - ccteam_harness::execution::fs_atomic::open_jsonl_append(&path)?.write_all(&line)?; + 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. + if row.state != DurableOutboundState::Queued && file.metadata()?.len() > compact_bytes { + 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(()) } @@ -2963,6 +3044,49 @@ mod tests { assert_eq!(rows[0].id, "o-1"); } + /// 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, + 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/gateway.rs b/crates/ccteam-im/src/gateway.rs index d3df52297..0ac7bb4d6 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -7735,26 +7735,13 @@ 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 + let digest = 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 - .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 + .map(|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 let mut fold = crate::progress::ProgressFold::new(); let mut dirty = false; @@ -8616,15 +8603,12 @@ impl Gateway { // before this one; this one is the next. let durable = project_dir .as_ref() - .and_then(|dir| { - ccteam_harness::execution::turns_mirror::read_all_turns( + .map(|dir| { + ccteam_harness::execution::turns_mirror::digest( dir, &session_id, ) - .ok() - }) - .map(|turns| { - turns.iter().filter(|turn| turn.status.is_some()).count() as u64 + .status_turns }) .unwrap_or(completed_turns); completed_turns = durable.max(completed_turns).saturating_add(1); @@ -14631,8 +14615,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() @@ -18504,9 +18487,7 @@ 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); + meta.turn_count = ccteam_harness::execution::turns_mirror::digest(project_dir, sid).user_turns; if let Some(session) = projection.and_then(|projection| projection.session_snapshot(slug, sid)) { meta.cost_usd = session.pricing(vendor).cost_usd; @@ -19414,23 +19395,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 { @@ -28690,8 +28654,16 @@ 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).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 diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index 5b706256c..9bdd04d8b 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) }), }, } @@ -4785,10 +4784,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 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-web/src/ring.rs b/crates/ccteam-web/src/ring.rs index 6bba3ccb3..43ec368af 100644 --- a/crates/ccteam-web/src/ring.rs +++ b/crates/ccteam-web/src/ring.rs @@ -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, } } @@ -94,9 +112,13 @@ impl SessionEventRing { 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 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 }); @@ -117,6 +154,7 @@ impl SessionEventRing { pub(crate) fn replay_since(&self, sid: &str, since: u64) -> Vec { let guard = self.rings.lock().unwrap(); guard + .by_sid .get(sid) .map(|s| s.buf.iter().filter(|e| e.seq > since).cloned().collect()) .unwrap_or_default() @@ -320,6 +358,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(); From 3db45ea20f37f687199f6eb7ccfcf2e11543203e Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 01:55:50 +0000 Subject: [PATCH 08/29] fix(im): no single long-lived daemon task dies globally on one panic docs-local issue #210 follow-up. The event consumer was one of several one-of-a-kind tasks whose death is global and silent; the same failure class sat in the inbound consumer and the other daemon loops. A clean stop also logged one delivery ERROR per busy session. - New ccteam_im::supervise: contain_panic (per-item, keeps the loop's state and receiver) and supervise_loop (restart a stateless loop after a 1s backoff); both log at error. - Inbound consumer: each message is routed under contain_panic (body extracted into route_inbound_message); channel-map and listener locks are poison-tolerant; its exit logs at error. The gateway event consumer now uses the same helper. - Delegation notifier: each pulse is handled under contain_panic, and the watch-set read is poison-tolerant. Scheduled scheduler, body watcher and idle-release sweeper run under supervise_loop. A panicking IM reload costs the reload, not the daemon's select loop. - ccteam-web: both ring feeders and the binding reaper run under supervise_loop. ccteam-cli hook sink: hook enrichment moved into the blocking task whose panic is already caught per event (now error). - Shutdown: the gateway's event_delivery_closing flag is raised before the consumer is aborted; a sink that finds the consumer gone after that logs at debug, so a clean stop logs no ERROR. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-cli/src/main.rs | 9 +- crates/ccteam-im/src/daemon.rs | 691 ++++++++++++--------- crates/ccteam-im/src/gateway.rs | 252 ++++++-- crates/ccteam-im/src/lib.rs | 1 + crates/ccteam-im/src/supervise.rs | 126 ++++ crates/ccteam-im/tests/im_progress_test.rs | 96 ++- crates/ccteam-web/src/ring.rs | 70 ++- crates/ccteam-web/src/routes/mcp.rs | 39 +- 8 files changed, 895 insertions(+), 389 deletions(-) create mode 100644 crates/ccteam-im/src/supervise.rs diff --git a/crates/ccteam-cli/src/main.rs b/crates/ccteam-cli/src/main.rs index ca8a27ee3..a28b524a7 100644 --- a/crates/ccteam-cli/src/main.rs +++ b/crates/ccteam-cli/src/main.rs @@ -1965,9 +1965,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 +1988,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" ), } } diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index 6c9f7b39a..664675e89 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 @@ -1876,24 +1937,21 @@ fn spawn_gateway_event_consumer_with_timeout( channels: Arc>, call_timeout: Duration, ) -> tokio::task::JoinHandle<()> { - use futures::FutureExt; tokio::spawn(async move { let mut state = GatewayEgressState::default(); while let Some(evt) = rx.recv().await { - let event_id = evt.id.clone(); - let channel_name = evt.channel.clone(); - let sid = evt.sid.clone(); - let delivery = - deliver_gateway_event(&event_outbox_root, &channels, &mut state, evt, call_timeout); - if let Err(panic) = std::panic::AssertUnwindSafe(delivery).catch_unwind().await { - tracing::error!( - event_id = %event_id, - channel = %channel_name, - sid = sid.as_deref().unwrap_or("-"), - panic = panic_message(panic.as_ref()), - "ccteam-im: gateway event delivery panicked; event dropped, consumer continues" - ); - } + 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" @@ -1901,14 +1959,6 @@ fn spawn_gateway_event_consumer_with_timeout( }) } -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("") -} - /// Deliver one gateway event to its channel. async fn deliver_gateway_event( event_outbox_root: &Path, @@ -2963,6 +3013,91 @@ mod tests { 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(); + } + /// (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/gateway.rs b/crates/ccteam-im/src/gateway.rs index d3df52297..478211114 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -971,6 +971,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 @@ -2279,6 +2284,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 { @@ -2309,7 +2316,7 @@ impl GatewayEventSink { fn send_broadcast_only(&self, event: GatewayEvent) -> bool { let live = !self.mpsc.is_closed(); if !live { - Self::log_consumer_gone(&event); + self.log_consumer_gone(&event); } let _ = self.broadcast.send(event); live @@ -2324,19 +2331,27 @@ impl GatewayEventSink { match self.mpsc.send(event) { Ok(()) => true, Err(tokio::sync::mpsc::error::SendError(event)) => { - Self::log_consumer_gone(&event); + self.log_consumer_gone(&event); false } } } - fn log_consumer_gone(event: &GatewayEvent) { + 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.sid.as_deref().unwrap_or("-"), + sid, event_id = %event.id, channel = %event.channel, - "ccteam-im: gateway event consumer is gone; stopping this session's event pump \ - (expected only during daemon shutdown)" + "ccteam-im: gateway event consumer is gone; stopping this session's event pump" ); } } @@ -3628,6 +3643,7 @@ impl Gateway { 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(), @@ -3695,6 +3711,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 { @@ -3702,6 +3719,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) @@ -16507,73 +16532,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; } } } @@ -22430,6 +22484,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")); @@ -22445,11 +22500,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`.) 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/supervise.rs b/crates/ccteam-im/src/supervise.rs new file mode 100644 index 000000000..ea58cdf41 --- /dev/null +++ b/crates/ccteam-im/src/supervise.rs @@ -0,0 +1,126 @@ +//! 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; + +/// Pause before a panicked loop is restarted, so a loop that panics on every +/// pass cannot spin. +const RESTART_BACKOFF: Duration = Duration::from_secs(1); + +/// 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, backoff: Duration, mut make: F) +where + F: FnMut() -> Fut, + Fut: Future, +{ + loop { + match std::panic::AssertUnwindSafe(make()).catch_unwind().await { + Ok(()) => { + tracing::error!(task, "ccteam-im: a long-lived task exited"); + return; + } + Err(panic) => { + 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; + } + } + } +} + +/// 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); + } + + /// 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", Duration::from_millis(1), 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); + } + + #[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..25c029811 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, @@ -226,6 +245,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 +276,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 +430,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-web/src/ring.rs b/crates/ccteam-web/src/ring.rs index 6bba3ccb3..b9e70a6cc 100644 --- a/crates/ccteam-web/src/ring.rs +++ b/crates/ccteam-web/src/ring.rs @@ -206,21 +206,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 +246,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)] 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; - } - }); + }, + )); } From 73b347f8bbfb89f9b6d89b0bed83a4310f79105f Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 02:03:08 +0000 Subject: [PATCH 09/29] fix(im): bound the wait behind a body left over from a restart A detached body was waited for until it exited, and real claude stays alive after stdin EOF for as long as a background task it started is running. A session nobody steered by hand (a delegated child, a scheduled run, an unwatched chat) stayed stuck behind it, with every turn queued and none delivered. The body watcher now ends the wait on its own, but only while a turn is queued for that sid. A body nobody is waiting on is left alone, so a job it scheduled for later still runs. The adapter reads the vendor record (`detached_activity`; for claude, the tail of the transcript plus the subagent transcripts beside it): - last turn ended and quiet for 30s: the process is kept alive only by background work and can never take the turn; - a turn still open but silent for 30 min: treated the same way (no record: 30 min since the body was detached). A body that is mid-turn and still writing keeps the wait. The process is ended with one SIGKILL to the recorded pid (`session_body::kill_body`), and the same path is used for `/interrupt`. SIGTERM is not used: claude answers it by killing every background task it started (measured on 2.1.296), and those tasks are the user's dev servers, stacks and scheduled jobs. With SIGKILL they keep running, reparented to init, and ccteam signals nothing else. Only the process ends, never the session: sid, ledger and focus routes stay, and the watcher's natural-exit path resumes the same sid with --resume and delivers the queued turns. The ledger records the reason as `ended_idle` or `ended_stalled`. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-harness/src/adapter.rs | 23 ++ .../src/execution/claude_stream_json/mod.rs | 19 +- .../execution/claude_stream_json/recovery.rs | 136 ++++++++- .../src/execution/session_body.rs | 63 ++++- crates/ccteam-harness/src/lib.rs | 10 +- crates/ccteam-im/src/gateway.rs | 257 ++++++++++++++++-- docs/usage-cn.md | 2 +- docs/usage.md | 2 +- 8 files changed, 472 insertions(+), 40 deletions(-) diff --git a/crates/ccteam-harness/src/adapter.rs b/crates/ccteam-harness/src/adapter.rs index d7172eb6c..cebe6fa3e 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 @@ -2007,6 +2018,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). 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 0527de81f..fc7134635 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs @@ -53,10 +53,10 @@ use crate::execution::progress_bridge::{ use crate::execution::session_status::{read_status_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}; @@ -2700,6 +2700,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, 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/session_body.rs b/crates/ccteam-harness/src/execution/session_body.rs index 070b919aa..8726950df 100644 --- a/crates/ccteam-harness/src/execution/session_body.rs +++ b/crates/ccteam-harness/src/execution/session_body.rs @@ -29,8 +29,10 @@ //! 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"). +//! project stop). [`kill_body`] ends the PROCESS of a detached body whose +//! session is wanted again (`/interrupt`, or a queued turn behind a body +//! that is idle or stalled) and leaves the session — sid, ledger, focus — +//! alone (AGENTS.md §三 "kill 的是进程,不是会话"). use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -207,6 +209,29 @@ pub fn terminate(body: &SessionBody, sid: &str, grace: Duration) -> Result Ok(!body_is_alive(body, sid)) } +/// End a detached body's process and NOTHING else: one SIGKILL to the +/// verified pid, then wait for it to go. Unlike [`terminate`], this gives +/// the vendor no chance to clean up — on purpose. `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 to run later. +/// A SIGKILL leaves them running, reparented to init; ccteam signals only the +/// one process it recorded. Callers use it only once the body has stopped +/// writing, so no transcript line is cut. `Ok(true)` when the body is gone. +pub fn kill_body(body: &SessionBody, sid: &str) -> Result { + if !body_is_alive(body, sid) { + return Ok(true); + } + signal(body.pid, libc::SIGKILL)?; + let started = Instant::now(); + while started.elapsed() < Duration::from_secs(2) { + if !body_is_alive(body, sid) { + return Ok(true); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(!body_is_alive(body, sid)) +} + #[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 +474,38 @@ mod tests { record(tmp.path(), "", Some(std::process::id()), "test").unwrap(); assert!(!body_path(tmp.path(), "").exists()); } + + #[test] + fn kill_body_ends_the_body_and_leaves_its_background_children() { + let tmp = tempfile::tempdir().unwrap(); + let pidfile = tmp.path().join("bg.pid"); + let mut child = Command::new("sh") + .arg("-c") + .arg(format!("sleep 60 & echo $! > {}; wait", pidfile.display())) + .env(BODY_SID_ENV, "s13") + .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!(kill_body(&body, "s13").unwrap()); + reaper.join().unwrap().unwrap(); + assert!(process_exists(bg), "the background child keeps running"); + signal(bg, libc::SIGKILL).unwrap(); + } } diff --git a/crates/ccteam-harness/src/lib.rs b/crates/ccteam-harness/src/lib.rs index 357bde424..904a680a4 100644 --- a/crates/ccteam-harness/src/lib.rs +++ b/crates/ccteam-harness/src/lib.rs @@ -51,11 +51,11 @@ pub use adapter::{ 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, + ChoiceSelection, ContextSource, 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, diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index f77099445..adc5e3b64 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -4473,32 +4473,27 @@ impl Gateway { /// the SESSION. Returns `Ok(true)` when `sid` was detached and its body is /// now gone; `Ok(false)` when `sid` was not detached. /// - /// Waiting is the only automatic exit from DETACHED, and it is unbounded: - /// a body that lost its stdin is never driveable again, yet `claude` stays + /// A body that lost its stdin is never driveable again, and `claude` stays /// alive after stdin EOF for as long as any background task it started is - /// running (and self-continues when that task ends) — a dev server or a - /// watcher keeps it alive for good, and every message to the sid queues - /// behind it forever. `/stop` was the only way out, and it ends the - /// session (stopped, focus routes dropped), so the chat's next message - /// minted a new sid. This is the way out that keeps the sid: only the - /// process is signalled; the record, the queue, the focus routes and the - /// DETACHED entry stay, and the watcher takes the same path as a body - /// that exited on its own — recover what it said, rebuild by sid - /// (`--resume`, same context), drain the queued turns. + /// running. The watcher bounds that wait on its own once a turn is queued + /// (see [`Self::detached_wait_bound`]); this is the user's way to cut it + /// short. Only the process is signalled ([`session_body::kill_body`]: its + /// background children keep running); the queue, the focus routes and the + /// DETACHED entry stay, and the watcher takes the same path as a body that + /// exited on its own — recover what it said, rebuild by sid (`--resume`, + /// same context), drain the queued turns. pub(crate) async fn end_detached_body(&mut self, sid: &str) -> Result { let Some(detached) = self.detached.get(sid).cloned() else { return Ok(false); }; let body = detached.body.clone(); let sid_owned = sid.to_string(); - let ended = tokio::task::spawn_blocking(move || { - session_body::terminate(&body, &sid_owned, std::time::Duration::from_secs(5)) - }) - .await - .context("join body terminate")??; + let ended = tokio::task::spawn_blocking(move || session_body::kill_body(&body, &sid_owned)) + .await + .context("join body kill")??; if !ended { return Err(anyhow!( - "{sid}'s body (pid {}) is still alive after SIGTERM and SIGKILL", + "{sid}'s body (pid {}) is still alive after SIGKILL", detached.body.pid )); } @@ -4540,9 +4535,10 @@ impl Gateway { let receipt = format!( "⏳ {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}) and is delivered when that process exits. \ - `/interrupt {sid}` ends the process now and resumes {sid} on the same context \ - (its background tasks end with it); `/stop {sid}` ends the session.", + 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. `/interrupt {sid}` does that now; `/stop {sid}` ends the \ + session.", detached.body.pid, since ); Ok((receipt, format!("queued-behind-body:{sid}:{queued}"))) @@ -4585,11 +4581,87 @@ impl Gateway { } for d in detached { // Off the lock: a pure OS probe. + let mut reason = "exited"; if session_body::body_is_alive(&d.body, &d.sid) { - continue; + let Some(bound) = Self::detached_wait_bound(gateway, &d).await else { + continue; + }; + let body = d.body.clone(); + let sid = d.sid.clone(); + match tokio::task::spawn_blocking(move || session_body::kill_body(&body, &sid)) + .await + { + Ok(Ok(true)) => {} + outcome => { + tracing::warn!(session = %d.sid, pid = d.body.pid, ?outcome, "ccteam-im: could not end a detached body; still waiting for it"); + continue; + } + } + tracing::info!(session = %d.sid, pid = d.body.pid, reason = bound, "ccteam-im: ended a detached body a queued turn was waiting on; resuming by sid"); + reason = bound; } - Self::on_detached_body_exited_shared(gateway, d).await; + Self::on_detached_body_exited_shared(gateway, d, reason).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 @@ -4597,6 +4669,7 @@ impl Gateway { async fn on_detached_body_exited_shared( gateway: &Arc>, d: DetachedBody, + reason: &str, ) { { let mut g = gateway.lock().await; @@ -4607,8 +4680,7 @@ impl Gateway { session_body::clear(&d.cwd, &d.sid); } 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::recover_and_report_shared(gateway, &d.sid, &d.slug, &d.cwd, d.body.pid, reason).await; Self::rebuild_one_shared(gateway, &d.sid).await; let mut g = gateway.lock().await; if g.sessions.contains_key(&d.sid) { @@ -5680,7 +5752,7 @@ impl Gateway { self.interrupt_session(&sid).await?; if detached { return Ok(Some(format!( - "已结束 {sid} 重启前遗留的进程(连同它启动的后台任务);{sid} 正按原上下文恢复,排队的消息随后送达" + "已结束 {sid} 重启前遗留的进程(它启动的后台进程继续运行);{sid} 正按原上下文恢复,排队的消息随后送达" ))); } Ok(Some(format!( @@ -22215,6 +22287,131 @@ mod tests { assert_eq!(submitted, vec!["hello after restart".to_string()]); } + /// 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 + ); + } + /// 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 @@ -23781,6 +23978,8 @@ mod tests { /// tasks into a genuinely busy child and assert their queue positions /// without racing the pump. events_held: Arc, + /// What `detached_activity` reports (`None` = no readable record). + detached_activity: Arc>>, } /// One thread's turn FIFO inside [`FakeAdapter`]. @@ -23858,6 +24057,7 @@ mod tests { 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)), } } @@ -24145,6 +24345,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" } diff --git a/docs/usage-cn.md b/docs/usage-cn.md index bec263210..a1a5f342e 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -559,7 +559,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,还有后台任务在跑的(dev server、watcher)活到那个任务结束。新 daemon 凭 body 记录(`/.ccteam/chat//body.json`)认出这样的幸存者,绝不为同一会话再起第二个进程:会话显示为 `detached`(web 侧栏、`agent_read activity:detached`、IM `/sessions`),发给它的消息排队、等该进程一退出就投递,`/interrupt `(REST `POST /api/v1/sessions/{sid}/interrupt`)立即结束这个进程并按原上下文恢复同一会话、投递排队消息,`/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 结束这个进程 —— 只结束它本身,它启动的后台进程继续运行、无人看管 —— 再按原上下文恢复同一会话并投递排队消息;`/interrupt `(REST `POST /api/v1/sessions/{sid}/interrupt`)立即这样做,`/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 的主机。 ### 多机(卫星节点) diff --git a/docs/usage.md b/docs/usage.md index fd4ff3be9..9c99bdc33 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -558,7 +558,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, 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, `/interrupt ` (REST `POST /api/v1/sessions/{sid}/interrupt`) ends that process now and resumes the same session on its context with the queued messages, `/stop` / `agent_stop` ends the session, 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 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, 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. From 5aa0fac43ea985f903d177e34bf21a49d2f2c372 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 02:15:28 +0000 Subject: [PATCH 10/29] fix(im): outbox replay gives up instead of re-sending forever; logs -f follows rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs-local issue #213, follow-up to the outbox compaction. - Every ledger row carries `attempts` (send attempts started; a Queued row is written ahead of each, so a crash mid-send still counts) and `first_queued_ms`. Replay sends an owed row only while attempts < 2 and it was first queued under 24 h ago; otherwise it appends a new terminal `GaveUp` row. A Failed send may still have landed (a timeout says nothing about delivery), so the old "replay everything not Sent" re-sent every failure at every daemon start, forever. Genuinely unsent Queued rows still get their replay (at-least-once kept). - Each replayed send runs under catch_unwind: a row whose send panics is logged and marked GaveUp, and replay continues — before, one poison row aborted the replay and panicked again at every start. - Compaction drops GaveUp rows along with Sent ones. - `ccteam daemon logs -f` reopens the path when the inode changes: a start that rotates daemon.log to daemon.log.1 made the follower replay the old file from its top and miss everything after. - Ledger fixtures in inbound_wiring_test / web_chat_bridge use a current timestamp (ts_ms 1 is now past the age bound); the two restart tests read sent rows before the restart that compacts them. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-cli/src/daemon_cli.rs | 35 +- crates/ccteam-cli/src/web_chat_bridge.rs | 8 +- crates/ccteam-im/src/daemon.rs | 310 +++++++++++++----- crates/ccteam-im/tests/inbound_wiring_test.rs | 47 ++- 4 files changed, 293 insertions(+), 107 deletions(-) diff --git a/crates/ccteam-cli/src/daemon_cli.rs b/crates/ccteam-cli/src/daemon_cli.rs index 949abd4a1..fbfb9d011 100644 --- a/crates/ccteam-cli/src/daemon_cli.rs +++ b/crates/ccteam-cli/src/daemon_cli.rs @@ -777,12 +777,21 @@ 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: 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). + 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 { @@ -803,8 +812,22 @@ pub fn run_daemon_logs(lines: usize, follow: bool, json: bool) -> Result<()> { } } -/// Last `n` lines of a file, reading only a bounded tail window (the -/// daemon log is unrotated and can grow large). +/// 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/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-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index f4ea34faf..ff86fd91d 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -2099,6 +2099,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, @@ -2114,8 +2122,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 @@ -2224,82 +2252,59 @@ async fn queue_and_send_durable_part( 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, + let now = now_unix_ms_u64(); + let queued = DurableOutboundRow { + ts_ms: now, + first_queued_ms: now, + attempts: 1, id, - inbound_id, - channel_name, - channel, + inbound_id: inbound_id.to_string(), + channel: channel_name.to_string(), + state: DurableOutboundState::Queued, message, - call_timeout, - ) - .await + 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 bounded_platform_call(call_timeout, "send", 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 } /// Past this size the ledger is compacted on the next append. Every reply @@ -2320,7 +2325,8 @@ fn durable_outbox_lock() -> std::sync::MutexGuard<'static, ()> { } /// The rows still owed a delivery: the LATEST row of every id whose latest -/// state is not `Sent`, in the order the ids were first queued. Streams the +/// 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; @@ -2338,7 +2344,7 @@ fn read_owed_durable_outbound(path: &Path) -> Vec { continue; } match serde_json::from_str::(&line) { - Ok(row) if row.state == DurableOutboundState::Sent => { + Ok(row) if row.state.is_settled() => { latest.remove(&row.id); } Ok(row) => { @@ -2365,7 +2371,7 @@ fn read_owed_durable_outbound(path: &Path) -> Vec { /// Rewrite the ledger to just the rows still owed (see /// [`read_owed_durable_outbound`]). Caller holds [`durable_outbox_lock`]. -/// A delivered row carries nothing replay could use, so dropping it keeps the +/// 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. @@ -2396,32 +2402,78 @@ async fn replay_durable_outbox(ccteam_root: &Path, channels: &ChannelMap) { } }; for row in owed { - 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( + 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 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 + }; + let Some(channel) = channels.get(&queued.channel) else { + append_durable_outbound( ccteam_root, - row.id, - &row.inbound_id, - &row.channel, - channel.as_ref(), - row.message, - PLATFORM_CALL_TIMEOUT, - ) - .await; + DurableOutboundRow { + state: DurableOutboundState::Failed, + error: Some("replay failed: channel is not configured".to_string()), + ..queued + }, + ); + return; + }; + 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: {}", 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 } } @@ -2897,6 +2949,9 @@ mod tests { 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 } @@ -3025,6 +3080,8 @@ mod tests { root.path(), &DurableOutboundRow { ts_ms: 2, + first_queued_ms: 2, + attempts: 1, id: "o-1".into(), inbound_id: "in-1".into(), channel: "trap".into(), @@ -3044,6 +3101,83 @@ mod tests { assert_eq!(rows[0].id, "o-1"); } + /// 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. @@ -3052,6 +3186,8 @@ mod tests { 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(), 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, From c93b93f8ba18470eb6e6fe7e4073d49b0ece3ea1 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 02:16:00 +0000 Subject: [PATCH 11/29] fix(im): contain each scheduled fire and sweep row; back off restarts docs-local issue #210 follow-up (checker on 3db45ea2). - Scheduled scheduler: fire_due_scheduled contains each row's submit; a panic fails the row ("panicked while firing") instead of escaping to the supervisor, which re-ran the same sorted batch every pass (a delivered turn re-sent each second, later due rows starved). A row is marked in flight before its submit and stays excluded until it is completed or failed, so it fires at most once per daemon life. - supervise_loop: exponential restart backoff, 1s doubling to a 60s cap, reset after a run that stayed up 300s, so a deterministic panic cannot flood the log. - idle_release_tick and body_watch_tick contain each session / each post-mortem; the post-mortem batch is taken off the gateway in one go, so one panic no longer drops the rest of it (wrapping only). - ccteam-web rings: poison-tolerant locks, so one panic under a ring lock does not make every later record/replay panic. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-im/src/gateway.rs | 267 ++++++++++++++++++++++++++---- crates/ccteam-im/src/supervise.rs | 72 +++++++- crates/ccteam-web/src/ring.rs | 33 +++- 3 files changed, 330 insertions(+), 42 deletions(-) diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 478211114..012261b44 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -959,6 +959,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, @@ -3640,6 +3645,7 @@ 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, @@ -4594,15 +4600,26 @@ impl Gateway { 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 { + // Off the lock: a pure OS probe. + if session_body::body_is_alive(&d.body, &d.sid) { + return; + } + Self::on_detached_body_exited_shared(gateway, d).await; + }) + .await; } } @@ -10113,6 +10130,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::>(); @@ -10125,11 +10143,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); } } @@ -14540,30 +14571,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 @@ -20972,6 +21011,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(); @@ -22169,6 +22240,42 @@ mod tests { assert_eq!(fake.starts.load(Ordering::SeqCst), 1); } + /// 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" + ); + } + /// 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 @@ -23780,6 +23887,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. @@ -23872,6 +23985,9 @@ 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())), @@ -23923,6 +24039,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(( @@ -24379,10 +24500,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() } @@ -39953,6 +40093,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/supervise.rs b/crates/ccteam-im/src/supervise.rs index ea58cdf41..f8fb4a744 100644 --- a/crates/ccteam-im/src/supervise.rs +++ b/crates/ccteam-im/src/supervise.rs @@ -17,9 +17,22 @@ use std::time::Duration; use futures::FutureExt; -/// Pause before a panicked loop is restarted, so a loop that panics on every -/// pass cannot spin. -const RESTART_BACKOFF: Duration = Duration::from_secs(1); +/// 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. @@ -49,18 +62,23 @@ where supervise_loop_with_backoff(task, RESTART_BACKOFF, make).await; } -async fn supervise_loop_with_backoff(task: &'static str, backoff: Duration, mut make: F) +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()), @@ -68,6 +86,7 @@ where "ccteam-im: a long-lived task panicked; restarting it" ); tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(policy.max); } } } @@ -96,13 +115,19 @@ mod tests { 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", Duration::from_millis(1), move || { + supervise_loop_with_backoff("t", FAST, move || { let counted = Arc::clone(&counted); async move { if counted.fetch_add(1, Ordering::SeqCst) < 2 { @@ -114,6 +139,43 @@ mod tests { 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"); diff --git a/crates/ccteam-web/src/ring.rs b/crates/ccteam-web/src/ring.rs index b9e70a6cc..b8814c24e 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; @@ -93,7 +93,7 @@ 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 mut guard = self.rings.lock().unwrap_or_else(PoisonError::into_inner); let state = guard.entry(sid.to_string()).or_default(); state.next_seq += 1; let seq = state.next_seq; @@ -115,7 +115,7 @@ 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 .get(sid) .map(|s| s.buf.iter().filter(|e| e.seq > since).cloned().collect()) @@ -169,7 +169,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 +188,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() @@ -295,6 +295,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(); From 87692bc9de102e2d05a443d842033b11a6a2d955 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 02:17:33 +0000 Subject: [PATCH 12/29] test(init): pin that a stream-json spawn writes no project settings docs-local issue #212, checker follow-ups to 287ecb28. - claude_stream_json_test: start_thread with the fake claude creates no .claude/ (and no settings.local.json) in the project. Proven red by re-adding the ensure_telegram_plugin_disabled call, green without it. - remove_ccteam_hooks reports Unreadable (I/O: EISDIR, EACCES) apart from Unparseable (invalid JSON); the init receipt, bootstrap warning and purge refusal name the right one. - Doc the accepted cost of the Telegram pin scrub: a hand-written `false` is indistinguishable from ccteam's, and stripping it re-enables the plugin for the user's own non-ccteam claude runs in that dir. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-cli/src/commands.rs | 31 ++++++++++ crates/ccteam-core/src/projects.rs | 14 +++-- crates/ccteam-core/src/tool_surface.rs | 59 ++++++++++++++----- .../tests/claude_stream_json_test.rs | 29 +++++++++ 4 files changed, 115 insertions(+), 18 deletions(-) diff --git a/crates/ccteam-cli/src/commands.rs b/crates/ccteam-cli/src/commands.rs index 3e4f6ec07..0888871b8 100644 --- a/crates/ccteam-cli/src/commands.rs +++ b/crates/ccteam-cli/src/commands.rs @@ -469,6 +469,7 @@ fn scrub_note(report: &ccteam_core::CcteamHookScrubReport) -> Option { "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, } @@ -3758,6 +3759,12 @@ fn purge_project_managed_paths( 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", @@ -4802,6 +4809,30 @@ mod tests { } } + /// 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-core/src/projects.rs b/crates/ccteam-core/src/projects.rs index aa453074a..5f8e4f43c 100644 --- a/crates/ccteam-core/src/projects.rs +++ b/crates/ccteam-core/src/projects.rs @@ -366,13 +366,19 @@ pub fn bootstrap_project_at_dir( let settings_local = project_dir.join(".claude").join("settings.local.json"); match crate::tool_surface::remove_ccteam_hooks(&settings_local, false) { Ok(report) => { - if let crate::tool_surface::CcteamHookScrubAction::Unparseable { error } = report.action - { - tracing::warn!( + 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!( diff --git a/crates/ccteam-core/src/tool_surface.rs b/crates/ccteam-core/src/tool_surface.rs index 7773b37c2..a419b56be 100644 --- a/crates/ccteam-core/src/tool_surface.rs +++ b/crates/ccteam-core/src/tool_surface.rs @@ -502,10 +502,13 @@ pub enum CcteamHookScrubAction { /// No ccteam hook entries found. Idempotent re-run after a successful /// scrub hits this branch. NoChangeNeeded, - /// The file exists but could not be read or is not valid JSON (an empty - /// file, a trailing comma …). It is 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). + /// 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 }, @@ -555,17 +558,25 @@ pub fn remove_ccteam_hooks(settings_path: &Path, dry_run: bool) -> Result(&body).map_err(|e| e.to_string()) - }); - let mut v = match parsed { + 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(error) => { - return Ok(CcteamHookScrubReport { - target: settings_path.to_path_buf(), - action: CcteamHookScrubAction::Unparseable { error }, + Err(e) => { + return untouched(CcteamHookScrubAction::Unparseable { + error: e.to_string(), }) } }; @@ -576,6 +587,10 @@ pub fn remove_ccteam_hooks(settings_path: &Path, dry_run: bool) -> Result Date: Wed, 14 Oct 2026 02:19:09 +0000 Subject: [PATCH 13/29] fix(im): restart recovery keeps its promises (checker findings A-D) A. Bodies are spawned as their own process-group leaders. Ending one (stop, /interrupt, or the automatic bound) now signals the group: SIGTERM, then a group SIGKILL that also sweeps members which outlived the leader. So no orphaned dev server is left holding a port that the resumed session needs. A body that does not lead its own group (spawned by an older build, inside the daemon's group) has only its own pid signalled. The receipt, the IM reply and the docs now say the background processes end with it. B. Every path that brings a session back drains its FIFO before any new line is submitted: the backlog goes out first, in order, on the next message. If the watcher's rebuild fails after a body ends, the reply target is told why and that the queued messages are kept. D. When a line was queued for a dead child and the resume then failed, the line is withdrawn. The error says the message was NOT delivered (so resending cannot deliver it twice) and names `/new` as the way out. The harness error no longer gives advice of its own. C. The ledger records why a detached body ended: `interrupted`, `auto_recovered` or `exited`. The recovered-answer and unrecoverable notes are worded for the reason recorded. Minors: the kill never runs under the gateway lock. REST /interrupt and /stop await it off the lock. The IM /interrupt door hands it to its own task, which wakes the watcher when done. A failed REST interrupt now returns 502 with the cause instead of 404 "unknown session". Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- .../src/execution/claude_stream_json/mod.rs | 2 +- .../execution/claude_stream_json/transport.rs | 7 + .../src/execution/session_body.rs | 147 +++-- .../tests/claude_stream_json_test.rs | 16 + crates/ccteam-im/src/gateway.rs | 513 +++++++++++++++--- crates/ccteam-im/src/pending_turns.rs | 32 ++ crates/ccteam-web/src/routes/sessions_api.rs | 13 +- docs/usage-cn.md | 2 +- docs/usage.md | 2 +- 9 files changed, 605 insertions(+), 129 deletions(-) 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 fc7134635..c130bedc4 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/mod.rs @@ -2121,7 +2121,7 @@ impl HarnessAdapter for ClaudeStreamJsonAdapter { ); HarnessError::SpawnFailed(format!( "claude --resume {uuid} failed: {err}. The conversation is intact on \ - disk; send the message again to retry the resume" + disk; the next resume retries it" )) })? }; 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/session_body.rs b/crates/ccteam-harness/src/execution/session_body.rs index 8726950df..6d8780dc6 100644 --- a/crates/ccteam-harness/src/execution/session_body.rs +++ b/crates/ccteam-harness/src/execution/session_body.rs @@ -28,11 +28,15 @@ //! - 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). [`kill_body`] ends the PROCESS of a detached body whose -//! session is wanted again (`/interrupt`, or a queued turn behind a body -//! that is idle or stalled) and leaves the session — sid, ledger, focus — -//! alone (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), a user +//! `/interrupt`, or when a turn queued behind a detached body that is idle +//! or stalled has to reach the session. Ending the process is never ending +//! the session — sid, ledger and focus stay (AGENTS.md §三 "kill 的是进程, +//! 不是会话"). 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. use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -178,26 +182,31 @@ 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) { 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) { @@ -209,27 +218,55 @@ pub fn terminate(body: &SessionBody, sid: &str, grace: Duration) -> Result Ok(!body_is_alive(body, sid)) } -/// End a detached body's process and NOTHING else: one SIGKILL to the -/// verified pid, then wait for it to go. Unlike [`terminate`], this gives -/// the vendor no chance to clean up — on purpose. `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 to run later. -/// A SIGKILL leaves them running, reparented to init; ccteam signals only the -/// one process it recorded. Callers use it only once the body has stopped -/// writing, so no transcript line is cut. `Ok(true)` when the body is gone. -pub fn kill_body(body: &SessionBody, sid: &str) -> Result { - if !body_is_alive(body, sid) { - return Ok(true); +/// 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) } - signal(body.pid, libc::SIGKILL)?; - let started = Instant::now(); - while started.elapsed() < Duration::from_secs(2) { - if !body_is_alive(body, sid) { - return Ok(true); - } - std::thread::sleep(Duration::from_millis(50)); +} + +/// 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 +} + +/// 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}"); } - Ok(!body_is_alive(body, sid)) + // 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)] @@ -476,13 +513,20 @@ mod tests { } #[test] - fn kill_body_ends_the_body_and_leaves_its_background_children() { + 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!("sleep 60 & echo $! > {}; wait", pidfile.display())) + .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()) @@ -503,9 +547,30 @@ mod tests { 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!(kill_body(&body, "s13").unwrap()); + 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(); - assert!(process_exists(bg), "the background child keeps running"); - signal(bg, libc::SIGKILL).unwrap(); + // Still here: the runner's group was not signalled. } } diff --git a/crates/ccteam-harness/tests/claude_stream_json_test.rs b/crates/ccteam-harness/tests/claude_stream_json_test.rs index 7caadb2dc..20ecbbf65 100644 --- a/crates/ccteam-harness/tests/claude_stream_json_test.rs +++ b/crates/ccteam-harness/tests/claude_stream_json_test.rs @@ -2372,6 +2372,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-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index adc5e3b64..86e407164 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -2588,6 +2588,11 @@ 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 `auto_recovered` (a queued turn was waiting on a + /// body gone idle or stalled). The watcher records the exit under it. + pub ending: Option<&'static str>, } impl DetachedBody { @@ -4295,6 +4300,7 @@ impl Gateway { body, since: chrono::Utc::now(), reason, + ending: None, }; tracing::info!( session = %sid, @@ -4470,36 +4476,98 @@ impl Gateway { } /// An EXPLICIT user `/interrupt` of a detached body: end the PROCESS, keep - /// the SESSION. Returns `Ok(true)` when `sid` was detached and its body is - /// now gone; `Ok(false)` when `sid` was not detached. + /// the SESSION. Returns `Ok(true)` when `sid` was detached (its process is + /// now being ended); `Ok(false)` when `sid` was not detached. /// - /// A body that lost its stdin is never driveable again, and `claude` stays - /// alive after stdin EOF for as long as any background task it started is - /// running. The watcher bounds that wait on its own once a turn is queued - /// (see [`Self::detached_wait_bound`]); this is the user's way to cut it - /// short. Only the process is signalled ([`session_body::kill_body`]: its - /// background children keep running); the queue, the focus routes and the - /// DETACHED entry stay, and the watcher takes the same path as a body that - /// exited on its own — recover what it said, rebuild by sid (`--resume`, - /// same context), drain the queued turns. - pub(crate) async fn end_detached_body(&mut self, sid: &str) -> Result { - let Some(detached) = self.detached.get(sid).cloned() else { - return Ok(false); + /// The `&mut` door (IM commands, buttons) cannot drop the gateway lock, so + /// the kill — up to `TERMINATE_GRACE` plus the SIGKILL wait — runs on its + /// own task and wakes the watcher when done; nothing waits on it under the + /// lock. The REST door, which owes a truthful answer, awaits it off the + /// lock instead ([`Self::end_detached_body_shared`]). + pub(crate) fn end_detached_body(&mut self, sid: &str) -> bool { + 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 ended = tokio::task::spawn_blocking(move || session_body::kill_body(&body, &sid_owned)) - .await - .context("join body kill")??; - if !ended { - return Err(anyhow!( - "{sid}'s body (pid {}) is still alive after SIGKILL", - detached.body.pid - )); + let notify = Arc::clone(&self.body_watch_notify); + tokio::spawn(async move { + let outcome = tokio::task::spawn_blocking({ + let sid = sid_owned.clone(); + move || session_body::terminate(&body, &sid, Self::TERMINATE_GRACE) + }) + .await; + if !matches!(outcome, Ok(Ok(true))) { + tracing::warn!(session = %sid_owned, ?outcome, "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 + } + + /// How long a body is given to exit on SIGTERM before its group is + /// SIGKILLed. + const TERMINATE_GRACE: std::time::Duration = std::time::Duration::from_secs(5); + + /// 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; `Err` when the process outlived SIGTERM and SIGKILL + /// (the mark is cleared and the body stays watched). + pub(crate) async fn end_detached_body_shared( + gateway: &Arc>, + sid: &str, + why: &'static str, + ) -> Result { + let body = { + let mut g = gateway.lock().await; + let Some(detached) = g.detached.get_mut(sid) else { + return Ok(false); + }; + detached.ending = Some(why); + detached.body.clone() + }; + let sid_owned = sid.to_string(); + let ended = tokio::task::spawn_blocking(move || { + session_body::terminate(&body, &sid_owned, Self::TERMINATE_GRACE) + }) + .await + .context("join body terminate"); + let mut g = gateway.lock().await; + match ended { + Ok(Ok(true)) => { + g.body_watch_notify.notify_one(); + Ok(true) + } + other => { + if let Some(detached) = g.detached.get_mut(sid) { + detached.ending = None; + } + Err(match other { + Ok(Ok(_)) => { + anyhow!("{sid}'s process is still alive after SIGTERM and SIGKILL") + } + Ok(Err(error)) | Err(error) => error, + }) + } } - tracing::info!(session = %sid, pid = detached.body.pid, "ccteam-im: detached body ended by user interrupt; resuming by sid"); - self.body_watch_notify.notify_one(); - Ok(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 @@ -4535,9 +4603,9 @@ impl Gateway { let receipt = format!( "⏳ {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. `/interrupt {sid}` does that now; `/stop {sid}` ends the \ + message is queued (#{queued}). Once that process has gone quiet ccteam ends it, \ + with the background processes it started, and resumes {sid} on the same context \ + with your message. `/interrupt {sid}` does that now; `/stop {sid}` ends the \ session.", detached.body.pid, since ); @@ -4580,25 +4648,38 @@ impl Gateway { Self::recover_and_report_shared(gateway, &sid, &slug, &cwd, body.pid, "exited").await; } for d in detached { + // A stop owns its body's end. + if d.ending == Some("stopping") { + continue; + } // Off the lock: a pure OS probe. - let mut reason = "exited"; 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 { continue; }; - let body = d.body.clone(); - let sid = d.sid.clone(); - match tokio::task::spawn_blocking(move || session_body::kill_body(&body, &sid)) - .await - { - Ok(Ok(true)) => {} - outcome => { - tracing::warn!(session = %d.sid, pid = d.body.pid, ?outcome, "ccteam-im: could not end a detached body; still waiting for it"); + 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, "auto_recovered").await { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + tracing::warn!(session = %d.sid, pid = d.body.pid, %error, "ccteam-im: could not end a detached body; still waiting for it"); continue; } } - tracing::info!(session = %d.sid, pid = d.body.pid, reason = bound, "ccteam-im: ended a detached body a queued turn was waiting on; resuming by sid"); - reason = bound; + } + 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" { + continue; } Self::on_detached_body_exited_shared(gateway, d, reason).await; } @@ -4621,10 +4702,10 @@ impl Gateway { /// 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 + /// - `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 + /// - `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). /// @@ -4656,12 +4737,12 @@ impl Gateway { .await; let quiet_for = match activity { Some(a) if a.turn_ended && a.quiet_for >= Self::DETACHED_IDLE_QUIET => { - return Some("ended_idle") + return Some("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") + (quiet_for >= Self::DETACHED_STALL).then_some("stalled") } /// A detached body exited on its own: forget it, recover + report what it @@ -4681,7 +4762,7 @@ impl Gateway { } 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, reason).await; - Self::rebuild_one_shared(gateway, &d.sid).await; + 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"); @@ -4689,6 +4770,39 @@ 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. The queued turns stay in + // the FIFO and go out, in order, ahead of the next message that + // reaches this sid (see `submit_resolved`). + 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()), + }); } } @@ -4788,18 +4902,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" | "auto_recovered" => 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 { @@ -4921,40 +5048,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) => { @@ -4965,7 +5093,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(); @@ -4975,6 +5105,7 @@ impl Gateway { error = %err, "ccteam-im: restored gateway session rebuild failed; left for on-demand resume" ); + Some(format!("{err:#}")) } } } @@ -5752,7 +5883,7 @@ impl Gateway { self.interrupt_session(&sid).await?; if detached { return Ok(Some(format!( - "已结束 {sid} 重启前遗留的进程(它启动的后台进程继续运行);{sid} 正按原上下文恢复,排队的消息随后送达" + "正在结束 {sid} 重启前遗留的进程(连同它启动的后台进程);{sid} 随后按原上下文恢复,排队的消息接着送达" ))); } Ok(Some(format!( @@ -10890,35 +11021,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. @@ -10931,7 +11071,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() @@ -17508,6 +17659,11 @@ impl Gateway { 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 @@ -18071,7 +18227,7 @@ impl Gateway { /// 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).await? { + if self.end_detached_body(sid) { return Ok(()); } let session = self @@ -18615,6 +18771,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", + "auto_recovered" => { + "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") } @@ -22253,10 +22433,21 @@ mod tests { .await .unwrap(); assert!( - replies.iter().any(|r| r.contains("已结束")), + replies.iter().any(|r| r.contains("正在结束")), "the bare /interrupt reached the detached focus: {replies:?}" ); - let status = child.wait().expect("the ended body is waitable"); + // 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)); @@ -22412,6 +22603,157 @@ mod tests { ); } + 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 (`auto_recovered`), not that it + /// exited on its own. + #[tokio::test] + async fn an_auto_recovered_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":"auto_recovered""#).await, + "the exit is recorded under auto_recovered" + ); + 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)); + } + + /// 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(); + 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 queue is kept" + ); + 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" + ); + 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 @@ -23978,6 +24320,8 @@ 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>>, } @@ -24058,6 +24402,7 @@ mod tests { 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)), } } @@ -24366,6 +24711,15 @@ mod tests { ctx: &SpawnCtx, ) -> Result { self.starts.fetch_add(1, Ordering::SeqCst); + if self + .fail_starts + .fetch_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); @@ -39506,6 +39860,7 @@ mod tests { }, since: chrono::Utc::now(), reason: "test", + ending: None, }, ); diff --git a/crates/ccteam-im/src/pending_turns.rs b/crates/ccteam-im/src/pending_turns.rs index 5e065ebba..508b574ae 100644 --- a/crates/ccteam-im/src/pending_turns.rs +++ b/crates/ccteam-im/src/pending_turns.rs @@ -159,6 +159,38 @@ pub fn drain_pending_turns(project_dir: &Path, sid: &str) -> 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) +} + /// 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-web/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index ecec6bf3b..db9dc3caa 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -1767,15 +1767,16 @@ 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() } } } diff --git a/docs/usage-cn.md b/docs/usage-cn.md index a1a5f342e..e556756d9 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -559,7 +559,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,还有后台任务在跑的(dev server、watcher)活到那个任务结束。新 daemon 凭 body 记录(`/.ccteam/chat//body.json`)认出这样的幸存者,绝不为同一会话再起第二个进程:会话显示为 `detached`(web 侧栏、`agent_read activity:detached`、IM `/sessions`),发给它的消息排队、等该进程一退出就投递。等待有上限:有消息排队、且 Claude transcript 显示最后一个 turn 已结束并静默 30 秒(或 turn 未结束但已静默 30 分钟)时,ccteam 结束这个进程 —— 只结束它本身,它启动的后台进程继续运行、无人看管 —— 再按原上下文恢复同一会话并投递排队消息;`/interrupt `(REST `POST /api/v1/sessions/{sid}/interrupt`)立即这样做,`/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 结束这个进程及它启动的后台进程(Claude 进程自成一个进程组,组外进程一概不碰),再按原上下文恢复同一会话并投递排队消息;`/interrupt `(REST `POST /api/v1/sessions/{sid}/interrupt`)立即这样做,`/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 的主机。 ### 多机(卫星节点) diff --git a/docs/usage.md b/docs/usage.md index 9c99bdc33..b7babbc59 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -558,7 +558,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, 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 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, 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 together with the background processes it started (a Claude process leads its own process group; nothing outside it is signalled) 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, 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. From 3486e9ebfd001aeb1ee8f15c5254ff4c90ac95af Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 02:21:00 +0000 Subject: [PATCH 14/29] fix(daemon): release the mcp.sock gate on every web-task exit; pin the race Follow-ups to the mcp.sock-after-web-bind gate: - The gate was released only after the web serve future returned, so a panic in the web task skipped it: mcp.sock never opened and `start` timed out. A drop guard now releases it on every exit path, unwinding and abort included. - The regression assert only bit on a host whose /proc sweep is slow. A hidden hook (CCTEAM_TEST_WEB_PREBIND_DELAY_MS, read by the daemon binary) stretches the pre-bind window, and daemon_identity_test now fails deterministically with "connection refused" without the gate. - daemon_identity_test's sandbox stops its daemon on drop, so a failing assertion no longer leaks a daemon process. docs-local issue #214 Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-cli/src/main.rs | 45 +++++++++--- .../ccteam-cli/tests/daemon_identity_test.rs | 71 +++++++++++++++++-- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/crates/ccteam-cli/src/main.rs b/crates/ccteam-cli/src/main.rs index 8167722fd..470aba205 100644 --- a/crates/ccteam-cli/src/main.rs +++ b/crates/ccteam-cli/src/main.rs @@ -1825,7 +1825,7 @@ fn run_start(web: StartWebOpts, imd: StartImdOpts) -> Result<()> { } }; opts.bound = web_bound.clone(); - let web_exited = web_bound.clone(); + let web_exited = NotifyOnDrop(web_bound.clone()); let mut rx = shutdown_rx.clone(); let web_bridge = web_chat_bridge .as_ref() @@ -1856,7 +1856,17 @@ 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 { - let served = ccteam_web::serve_with_state_factory_and_shutdown( + // 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| { let mut state = ccteam_web::AppState::with_auth(paths, auth); @@ -1875,13 +1885,7 @@ fn run_start(web: StartWebOpts, imd: StartImdOpts) -> Result<()> { let _ = rx.changed().await; }, ) - .await; - // A web that failed before binding must not hold the socket - // closed forever: the daemon stays up without it, as before. - if let Some(bound) = web_exited { - bound.notify_one(); - } - served + .await })) }; @@ -2337,6 +2341,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 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}"); +} From 235db1d0c0a0cd06f7814b4aa4c8e0f370592178 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 02:27:44 +0000 Subject: [PATCH 15/29] fix(im): a body ended to keep its session leaves its work running, and says so Owner decision: ending a detached body to keep its session (`/interrupt`, or the automatic bound) ends ONLY the body's process. It is one SIGKILL to the fingerprint-checked pid (`session_body::kill_body`). claude answers SIGTERM by killing every background task it started, and those tasks are the user's dev servers, stacks and scheduled jobs. An explicit `/stop` still ends the whole process group: the session is over, and so is its work. The background processes are never signalled, but they are made visible. Before the kill, the body's descendants are walked in /proc and fingerprinted. Each one still running afterwards is recorded as `orphans` (pid + short cmdline) on the `session_body_exited` ledger event, and the reply target is told: "still running, unattended: pid 1234 `npm run dev`". A port the resumed session cannot bind is no longer a mystery. The ledger reasons are `interrupted`, `ended_idle` and `ended_stalled`. The receipt, the IM reply and the docs say the background processes keep running. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- .../src/execution/session_body.rs | 167 ++++++++++- crates/ccteam-im/src/gateway.rs | 280 ++++++++++++++---- docs/usage-cn.md | 2 +- docs/usage.md | 2 +- 4 files changed, 376 insertions(+), 75 deletions(-) diff --git a/crates/ccteam-harness/src/execution/session_body.rs b/crates/ccteam-harness/src/execution/session_body.rs index 6d8780dc6..135508358 100644 --- a/crates/ccteam-harness/src/execution/session_body.rs +++ b/crates/ccteam-harness/src/execution/session_body.rs @@ -29,14 +29,17 @@ //! forge a `body.json`. What this prevents is a *misattribution by ccteam //! itself* (two bodies for one sid), not an attack. //! - [`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), a user -//! `/interrupt`, or when a turn queued behind a detached body that is idle -//! or stalled has to reach the session. Ending the process is never ending -//! the session — sid, ledger and focus stay (AGENTS.md §三 "kill 的是进程, -//! 不是会话"). 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. +//! 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}; @@ -245,6 +248,110 @@ fn leads_own_group(_pid: u32) -> bool { /// kill(2) to the process group `pgid` (ESRCH — the group is gone — is /// success). +/// 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, shortened for a receipt. + pub cmd: String, +} + +/// Longest command line an [`Orphan`] carries. +const ORPHAN_CMD_MAX: usize = 80; + +/// Every live descendant of `pid`, each with its start fingerprint (Linux: +/// a `/proc` walk by parent pid; 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(); + }; + let mut parent_of: Vec<(u32, u32)> = 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 ppid = stat + .rsplit_once(')') + .and_then(|(_, rest)| rest.split_whitespace().nth(1)) + .and_then(|p| p.parse::().ok()); + if let Some(ppid) = ppid { + parent_of.push((child, ppid)); + } + } + let mut out = Vec::new(); + let mut frontier = vec![pid]; + while let Some(parent) = frontier.pop() { + for &(child, ppid) in &parent_of { + if ppid == parent && !out.iter().any(|(p, _)| *p == child) { + if let Ok(fingerprint) = process_fingerprint(child) { + out.push((child, fingerprint)); + frontier.push(child); + } + } + } + } + out +} + +fn short_cmdline(pid: u32) -> String { + let raw = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default(); + let cmd = raw + .split(|b| *b == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect::>() + .join(" "); + if cmd.chars().count() > ORPHAN_CMD_MAX { + let cut: String = cmd.chars().take(ORPHAN_CMD_MAX).collect(); + format!("{cut}…") + } else { + cmd + } +} + +/// 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. +/// Callers use it once the body has stopped writing, so no transcript line is +/// cut. `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); + 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() + .filter(|(pid, fingerprint)| { + !process_is_zombie(*pid) + && process_fingerprint(*pid).is_ok_and(|now| &now == fingerprint) + }) + .map(|(pid, _)| Orphan { + pid, + cmd: short_cmdline(pid), + }) + .collect(), + )) +} + #[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")?; @@ -573,4 +680,48 @@ mod tests { 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, + vec![Orphan { + pid: bg, + cmd: "sleep 61".to_string() + }] + ); + signal(bg, libc::SIGKILL).unwrap(); + } } diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 86e407164..55f5e1099 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -884,6 +884,9 @@ 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, + /// What each ended detached body left running (see + /// [`session_body::kill_body`]), keyed by sid, until the watcher reports it. + body_end_orphans: Arc>>>, projects: BTreeMap, /// The chats that speak for the box OWNER, per platform (`"telegram"` → /// `{"339498819"}`, `"lark"` → `{"ou_…"}`). Fed from each global bot's @@ -2590,7 +2593,7 @@ pub struct DetachedBody { 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 `auto_recovered` (a queued turn was waiting on 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>, } @@ -3583,6 +3586,7 @@ impl Gateway { detached: BTreeMap::new(), post_mortem_pending: Vec::new(), body_watch_notify: Arc::new(tokio::sync::Notify::new()), + body_end_orphans: Arc::new(std::sync::Mutex::new(HashMap::new())), projects, operator_chats: BTreeMap::new(), button_channels: BTreeSet::new(), @@ -4480,10 +4484,9 @@ impl Gateway { /// now being ended); `Ok(false)` when `sid` was not detached. /// /// The `&mut` door (IM commands, buttons) cannot drop the gateway lock, so - /// the kill — up to `TERMINATE_GRACE` plus the SIGKILL wait — runs on its - /// own task and wakes the watcher when done; nothing waits on it under the - /// lock. The REST door, which owes a truthful answer, awaits it off the - /// lock instead ([`Self::end_detached_body_shared`]). + /// 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 { let Some(detached) = self.detached.get_mut(sid) else { return false; @@ -4495,14 +4498,12 @@ impl Gateway { let body = detached.body.clone(); let sid_owned = sid.to_string(); let notify = Arc::clone(&self.body_watch_notify); + let orphans = Arc::clone(&self.body_end_orphans); tokio::spawn(async move { - let outcome = tokio::task::spawn_blocking({ - let sid = sid_owned.clone(); - move || session_body::terminate(&body, &sid, Self::TERMINATE_GRACE) - }) - .await; - if !matches!(outcome, Ok(Ok(true))) { - tracing::warn!(session = %sid_owned, ?outcome, "ccteam-im: could not end a detached body on interrupt"); + let outcome = + Self::end_body_process(body, sid_owned.clone(), "interrupted", orphans).await; + if let Err(error) = outcome { + tracing::warn!(session = %sid_owned, %error, "ccteam-im: could not end a detached body on interrupt"); } notify.notify_one(); }); @@ -4511,48 +4512,74 @@ impl Gateway { } /// How long a body is given to exit on SIGTERM before its group is - /// SIGKILLed. + /// 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 and parks what it left running in `orphans` for the + /// watcher's report ([`session_body::kill_body`]). + async fn end_body_process( + body: SessionBody, + sid: String, + why: &'static str, + orphans: Arc>>>, + ) -> Result<()> { + let pid = body.pid; + let sid_for_task = sid.clone(); + 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")??; + let Some(left) = left else { + return Err(anyhow!( + "{sid}'s process (pid {pid}) is still alive after SIGKILL" + )); + }; + if !left.is_empty() { + if let Ok(mut map) = orphans.lock() { + map.insert(sid, left); + } + } + Ok(()) + } + /// 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; `Err` when the process outlived SIGTERM and SIGKILL - /// (the mark is cleared and the body stays watched). + /// is not detached; `Err` when the process outlived the kill (the mark is + /// cleared and the body stays watched). pub(crate) async fn end_detached_body_shared( gateway: &Arc>, sid: &str, why: &'static str, ) -> Result { - let body = { + let (body, orphans) = { let mut g = gateway.lock().await; + let orphans = Arc::clone(&g.body_end_orphans); let Some(detached) = g.detached.get_mut(sid) else { return Ok(false); }; detached.ending = Some(why); - detached.body.clone() + (detached.body.clone(), orphans) }; - let sid_owned = sid.to_string(); - let ended = tokio::task::spawn_blocking(move || { - session_body::terminate(&body, &sid_owned, Self::TERMINATE_GRACE) - }) - .await - .context("join body terminate"); + let ended = Self::end_body_process(body, sid.to_string(), why, orphans).await; let mut g = gateway.lock().await; match ended { - Ok(Ok(true)) => { + Ok(()) => { g.body_watch_notify.notify_one(); Ok(true) } - other => { + Err(error) => { if let Some(detached) = g.detached.get_mut(sid) { detached.ending = None; } - Err(match other { - Ok(Ok(_)) => { - anyhow!("{sid}'s process is still alive after SIGTERM and SIGKILL") - } - Ok(Err(error)) | Err(error) => error, - }) + Err(error) } } } @@ -4603,10 +4630,10 @@ impl Gateway { let receipt = format!( "⏳ {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, \ - with the background processes it started, and resumes {sid} on the same context \ - with your message. `/interrupt {sid}` does that now; `/stop {sid}` ends the \ - session.", + 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 its background processes.", detached.body.pid, since ); Ok((receipt, format!("queued-behind-body:{sid}:{queued}"))) @@ -4645,7 +4672,8 @@ impl Gateway { ) }; for (sid, slug, cwd, body) in post_mortems { - Self::recover_and_report_shared(gateway, &sid, &slug, &cwd, body.pid, "exited").await; + Self::recover_and_report_shared(gateway, &sid, &slug, &cwd, body.pid, "exited", &[]) + .await; } for d in detached { // A stop owns its body's end. @@ -4660,7 +4688,7 @@ impl Gateway { continue; }; 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, "auto_recovered").await { + match Self::end_detached_body_shared(gateway, &d.sid, bound).await { Ok(true) => {} Ok(false) => continue, Err(error) => { @@ -4702,10 +4730,10 @@ impl Gateway { /// 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: - /// - `idle`: the vendor record says the last turn ended and it has been + /// - `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. - /// - `stalled`: a turn is still open but the record has been silent for + /// - `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). /// @@ -4737,12 +4765,12 @@ impl Gateway { .await; let quiet_for = match activity { Some(a) if a.turn_ended && a.quiet_for >= Self::DETACHED_IDLE_QUIET => { - return Some("idle") + 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("stalled") + (quiet_for >= Self::DETACHED_STALL).then_some("ended_stalled") } /// A detached body exited on its own: forget it, recover + report what it @@ -4761,7 +4789,55 @@ impl Gateway { session_body::clear(&d.cwd, &d.sid); } 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, reason).await; + let orphans = { + let g = gateway.lock().await; + let taken = g + .body_end_orphans + .lock() + .ok() + .and_then(|mut map| map.remove(&d.sid)) + .unwrap_or_default(); + taken + }; + 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) { @@ -4819,7 +4895,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(); @@ -4870,12 +4957,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(); @@ -4910,7 +4992,7 @@ impl Gateway { ), None => { let note = match reason { - "interrupted" | "auto_recovered" => format!( + "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.", @@ -4984,16 +5066,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}"), @@ -5883,7 +5956,7 @@ impl Gateway { self.interrupt_session(&sid).await?; if detached { return Ok(Some(format!( - "正在结束 {sid} 重启前遗留的进程(连同它启动的后台进程);{sid} 随后按原上下文恢复,排队的消息接着送达" + "正在结束 {sid} 重启前遗留的进程(它启动的后台进程继续运行,结束后会列出);{sid} 随后按原上下文恢复,排队的消息接着送达" ))); } Ok(Some(format!( @@ -18788,7 +18861,7 @@ fn undelivered_after_resume_failure(sid: &str, err: anyhow::Error) -> anyhow::Er fn unobserved_end_phrase(reason: &str) -> &'static str { match reason { "interrupted" => "was working while ccteam restarted, until /interrupt ended that process", - "auto_recovered" => { + "ended_idle" | "ended_stalled" => { "was working while ccteam restarted, until a waiting message ended that process" } _ => "finished this turn while ccteam was restarting", @@ -22632,10 +22705,10 @@ mod tests { } /// The automatic path records itself honestly: the ledger says the - /// process was ended for a waiting turn (`auto_recovered`), not that it + /// process was ended for a waiting turn (`ended_idle`), not that it /// exited on its own. #[tokio::test] - async fn an_auto_recovered_body_is_recorded_as_such() { + 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(); @@ -22650,8 +22723,8 @@ mod tests { Gateway::body_watch_tick(&gateway).await; child.wait().unwrap(); assert!( - ledger_has(&paths, r#""reason":"auto_recovered""#).await, - "the exit is recorded under auto_recovered" + 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!( @@ -22663,6 +22736,83 @@ mod tests { 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, + &format!(r#""orphans":[{{"cmd":"sleep 62","pid":{bg}}}]"#) + ) + .await + ); + assert!(gateway.lock().await.is_session_live(&sid)); + unsafe { + libc::kill(bg as i32, libc::SIGKILL); + } + } + /// 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, diff --git a/docs/usage-cn.md b/docs/usage-cn.md index e556756d9..031cd379c 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -559,7 +559,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,还有后台任务在跑的(dev server、watcher)活到那个任务结束。新 daemon 凭 body 记录(`/.ccteam/chat//body.json`)认出这样的幸存者,绝不为同一会话再起第二个进程:会话显示为 `detached`(web 侧栏、`agent_read activity:detached`、IM `/sessions`),发给它的消息排队、等该进程一退出就投递。等待有上限:有消息排队、且 Claude transcript 显示最后一个 turn 已结束并静默 30 秒(或 turn 未结束但已静默 30 分钟)时,ccteam 结束这个进程及它启动的后台进程(Claude 进程自成一个进程组,组外进程一概不碰),再按原上下文恢复同一会话并投递排队消息;`/interrupt `(REST `POST /api/v1/sessions/{sid}/interrupt`)立即这样做,`/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` 则结束会话及其后台进程;它退出后 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 b7babbc59..5a035578b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -558,7 +558,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, 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 together with the background processes it started (a Claude process leads its own process group; nothing outside it is signalled) 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, 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 background processes, 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. From 9e92aa48d9cfe6ab6167b1032a5bd318a02d963d Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 02:37:50 +0000 Subject: [PATCH 16/29] fix(im): a detached body's end has one owner, and its failures are not sticky D1. end_detached_body_shared no longer overwrites an existing end mark. The watcher decides off the lock, so it could reach a body already marked by a user's /stop and relabel that end as its own. The watcher would then rebuild the session the user had just stopped and drain the queue into it. Only a stop may take over an interrupt. The exit handler leaves a stop-marked body to the stop. A stop also drops the sid's rebuild reservation, so a rebuild already spawning off the lock is discarded rather than installed after the stop. D2. When the &mut /interrupt door's kill task fails, it flags the sid. The next reader of the mark lifts `interrupted`, so a later /interrupt really ends the body and a later natural exit is not recorded as an interrupt. Gap 1. When the watcher's rebuild fails, a waiting delegation parent now gets an honest failure through the normal completion path (vendor_error, error_kind resume_failed), not just the reply target. Gap 2. Project delete ends detached bodies off the gateway lock before stopping their sessions. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-im/src/gateway.rs | 229 ++++++++++++++++++++++- crates/ccteam-web/src/routes/projects.rs | 15 +- 2 files changed, 239 insertions(+), 5 deletions(-) diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 55f5e1099..a4419f73e 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -887,6 +887,10 @@ pub struct Gateway { /// What each ended detached body left running (see /// [`session_body::kill_body`]), keyed by sid, until the watcher reports it. body_end_orphans: Arc>>>, + /// Sids whose body end, started by the `&mut` interrupt door on its own + /// task, FAILED: their `interrupted` mark must be lifted (see + /// [`Self::settle_failed_body_ends`]). + body_end_failures: Arc>>, projects: BTreeMap, /// The chats that speak for the box OWNER, per platform (`"telegram"` → /// `{"339498819"}`, `"lark"` → `{"ou_…"}`). Fed from each global bot's @@ -3587,6 +3591,7 @@ impl Gateway { post_mortem_pending: Vec::new(), body_watch_notify: Arc::new(tokio::sync::Notify::new()), body_end_orphans: Arc::new(std::sync::Mutex::new(HashMap::new())), + body_end_failures: Arc::new(std::sync::Mutex::new(HashSet::new())), projects, operator_chats: BTreeMap::new(), button_channels: BTreeSet::new(), @@ -4488,6 +4493,7 @@ impl Gateway { /// 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_failed_body_ends(); let Some(detached) = self.detached.get_mut(sid) else { return false; }; @@ -4499,11 +4505,18 @@ impl Gateway { let sid_owned = sid.to_string(); let notify = Arc::clone(&self.body_watch_notify); let orphans = Arc::clone(&self.body_end_orphans); + let failures = Arc::clone(&self.body_end_failures); tokio::spawn(async move { let outcome = Self::end_body_process(body, sid_owned.clone(), "interrupted", orphans).await; if let Err(error) = outcome { tracing::warn!(session = %sid_owned, %error, "ccteam-im: could not end a detached body on interrupt"); + // Lift the mark (next lock holder settles it), or every later + // /interrupt would be a no-op and a natural exit would be + // recorded as interrupted. + if let Ok(mut set) = failures.lock() { + set.insert(sid_owned); + } } notify.notify_one(); }); @@ -4511,6 +4524,23 @@ impl Gateway { true } + /// Lift the `interrupted` mark of every body whose end failed on the + /// `&mut` door's own task, so the body is watched (and interruptible) + /// again. Called by every reader of the mark before it trusts it. + fn settle_failed_body_ends(&mut self) { + let failed: Vec = match self.body_end_failures.lock() { + Ok(mut set) => set.drain().collect(), + Err(_) => return, + }; + for sid in failed { + if let Some(detached) = self.detached.get_mut(&sid) { + if detached.ending == Some("interrupted") { + 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); @@ -4526,6 +4556,14 @@ impl Gateway { orphans: Arc>>>, ) -> 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")); + } let sid_for_task = sid.clone(); let left = tokio::task::spawn_blocking(move || { if why == "stopping" { @@ -4552,19 +4590,31 @@ impl Gateway { /// 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; `Err` when the process outlived the kill (the mark is - /// cleared and the body stays watched). - pub(crate) async fn end_detached_body_shared( + /// 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, orphans) = { let mut g = gateway.lock().await; + g.settle_failed_body_ends(); let orphans = Arc::clone(&g.body_end_orphans); 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); (detached.body.clone(), orphans) }; @@ -4666,6 +4716,7 @@ impl Gateway { pub async fn body_watch_tick(gateway: &Arc>) { let (detached, post_mortems) = { let mut g = gateway.lock().await; + g.settle_failed_body_ends(); ( g.detached.values().cloned().collect::>(), std::mem::take(&mut g.post_mortem_pending), @@ -4782,6 +4833,11 @@ impl Gateway { ) { { 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; + } if g.detached.remove(&d.sid).is_none() { // Stopped by a user between the snapshot and now. return; @@ -4879,6 +4935,39 @@ impl Gateway { 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. The task line itself stays queued. + if let Some(dtx) = g.delegation_tx.clone() { + let turn_id = format!( + "{sid}-resume-failed-{}", + chrono::Utc::now().timestamp_millis() + ); + let _ = dtx.send(crate::delegation::DelegationPulse::Signal( + crate::delegation::DelegationSignal { + child_sid: sid.clone(), + turn_id: turn_id.clone(), + // Never observed executing: resolves the requests + // still queued for this sid. + exec_turn_id: None, + tail: format!( + "{sid} could not be resumed after its old process ended: {error}. \ + The task is kept queued and runs when {sid} is next resumed." + ), + vendor: meta.vendor, + host: meta.host.clone(), + boundary: true, + continuation: ccteam_harness::TurnContinuation::Settled, + vendor_error: true, + interim_notes: 0, + covered_turns: vec![turn_id], + context_pct: None, + turn: meta.turn_count, + error_kind: Some("resume_failed".to_string()), + conclusion: None, + }, + )); + } } } @@ -18237,6 +18326,10 @@ 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); self.current_session.retain_values(|v| v != sid); self.record_explicit_stop(sid); if !already_stopped { @@ -20955,6 +21048,12 @@ 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()); + use super::*; use ccteam_harness::{ ChoiceOption, ContextSource, ContextUsage, ExecutionMode, HarnessError, ThreadItem, @@ -22813,6 +22912,113 @@ mod tests { } } + /// 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 !restarted.body_end_failures.lock().unwrap().is_empty() { + 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:?}"); + } + /// 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, @@ -22828,6 +23034,8 @@ mod tests { let paths = progress_paths(tmp.path()); restarted.enable_project_creation(paths.clone()); let mut events = restarted.subscribe_events(); + let (dtx, mut drx) = tokio::sync::mpsc::unbounded_channel(); + restarted.set_delegation_notifier_tx(dtx); restarted.submit_to_sid(&sid, "Q1".into()).await.unwrap(); restarted.submit_to_sid(&sid, "Q2".into()).await.unwrap(); let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); @@ -22856,6 +23064,21 @@ mod tests { told, "the reply target hears why the session did not come back" ); + let mut parent_told = false; + while let Ok(pulse) = drx.try_recv() { + if let crate::delegation::DelegationPulse::Signal(signal) = pulse { + if signal.child_sid == sid + && signal.error_kind.as_deref() == Some("resume_failed") + && signal.vendor_error + { + parent_told = true; + } + } + } + assert!( + parent_told, + "a waiting delegation parent gets an honest failure" + ); assert!(ledger_has(&paths, r#""reason":"interrupted""#).await); let id = gateway 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. From 0faf9976f80fab217893324862e9fe2d5a542e28 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 02:47:59 +0000 Subject: [PATCH 17/29] fix(im): orphan reports are complete, attributable and secret-free N1. A detached body's end is now one hand-over entry per sid: in progress, then ended (with what it left running) or failed. The watcher, and the exit handler in the same lock as it removes the entry, defer while a kill has not handed over. So the report never goes out without the orphans, and a late hand-over can no longer be reported for a later incarnation. Each orphan carries its start-time fingerprint and is re-verified when reported, so a reused pid is never named. A stop drops any stale entry for the sid. N2. Orphan command lines are redacted before they reach IM or the ledger: `key=value` and `--key value` for keys naming a token, secret, password, passwd, key, auth or credential; `Bearer` values; URL credentials; known token prefixes (sk-, ghp_, gho_, github_pat_, xox?-, AKIA); and runs of 32+ base64/hex characters. Control characters and backticks are replaced and the home directory reads as `~`. For claude's Bash wrapper, the eval payload is shown instead of the wrapper. N3. signal_group's doc is back on signal_group. kill_body re-verifies the body right before the SIGKILL, because the /proc walk widens the pid-reuse window. Descendants take their start time from the same /proc/N/stat read. The doc no longer claims that no transcript line is cut: /interrupt and ended_stalled can tear the last line, which claude resumes past. D1 stays as committed in 9e92aa48: an existing mark is never overwritten, except that a stop may take over an in-flight interrupt. Docs: /stop ends the session's current process and that process's background processes; ones left by an earlier /interrupt or automatic end are not touched. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- .../src/execution/session_body.rs | 265 ++++++++++++++--- crates/ccteam-im/src/gateway.rs | 272 ++++++++++++------ docs/usage-cn.md | 2 +- docs/usage.md | 2 +- 4 files changed, 417 insertions(+), 124 deletions(-) diff --git a/crates/ccteam-harness/src/execution/session_body.rs b/crates/ccteam-harness/src/execution/session_body.rs index 135508358..ddc51c38e 100644 --- a/crates/ccteam-harness/src/execution/session_body.rs +++ b/crates/ccteam-harness/src/execution/session_body.rs @@ -246,26 +246,38 @@ fn leads_own_group(_pid: u32) -> bool { false } -/// kill(2) to the process group `pgid` (ESRCH — the group is gone — is -/// success). /// 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, shortened for a receipt. + /// 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`, each with its start fingerprint (Linux: -/// a `/proc` walk by parent pid; empty where there is no `/proc`). +/// 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(); }; - let mut parent_of: Vec<(u32, u32)> = 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() @@ -277,37 +289,117 @@ fn descendants(pid: u32) -> Vec<(u32, String)> { let Ok(stat) = std::fs::read_to_string(format!("/proc/{child}/stat")) else { continue; }; - let ppid = stat - .rsplit_once(')') - .and_then(|(_, rest)| rest.split_whitespace().nth(1)) - .and_then(|p| p.parse::().ok()); - if let Some(ppid) = ppid { - parent_of.push((child, ppid)); - } + 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::new(); + let mut out: Vec<(u32, String)> = Vec::new(); let mut frontier = vec![pid]; while let Some(parent) = frontier.pop() { - for &(child, ppid) in &parent_of { - if ppid == parent && !out.iter().any(|(p, _)| *p == child) { - if let Ok(fingerprint) = process_fingerprint(child) { - out.push((child, fingerprint)); - frontier.push(child); - } + 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 short_cmdline(pid: u32) -> String { - let raw = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default(); - let cmd = raw +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::>() - .join(" "); + .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`; 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(|| { + const SENSITIVE: &str = + "[a-z0-9_.-]*(?:token|secret|password|passwd|key|auth|credential)[a-z0-9_.-]*"; + [ + (r#"bearer[ \t]+(?P[^ \t'"]+)"#.to_string(), true), + (r#"://(?P[^/ \t:@]+:[^/ \t@]+)@"#.to_string(), false), + ( + format!(r#"(?:^|[^a-z0-9_.-])-{{0,2}}{SENSITIVE}=(?P[^ \t'"]+)"#), + true, + ), + ( + format!(r#"(?:^|[ \t])--?{SENSITIVE}[ \t]+(?P[^ \t'"-][^ \t'"]*)"#), + true, + ), + ( + r"(?:^|[^A-Za-z0-9_])(?P(?:sk-|ghp_|gho_|github_pat_|xox[abposr]-|AKIA)[A-Za-z0-9_-]+)" + .to_string(), + false, + ), + (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") 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}…") @@ -316,19 +408,48 @@ fn short_cmdline(pid: u32) -> String { } } +/// 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. -/// Callers use it once the body has stopped writing, so no transcript line is -/// cut. `Ok(None)` when the body survived the SIGKILL. +/// +/// 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) { @@ -340,18 +461,19 @@ pub fn kill_body(body: &SessionBody, sid: &str) -> Result>> { Ok(Some( children .into_iter() - .filter(|(pid, fingerprint)| { - !process_is_zombie(*pid) - && process_fingerprint(*pid).is_ok_and(|now| &now == fingerprint) - }) - .map(|(pid, _)| Orphan { + .zip(cmds) + .map(|((pid, fingerprint), cmd)| Orphan { pid, - cmd: short_cmdline(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")?; @@ -715,13 +837,76 @@ mod tests { process_exists(bg), "the background child is never signalled" ); - assert_eq!( - orphans, - vec![Orphan { - pid: bg, - cmd: "sleep 61".to_string() - }] - ); + 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"), + ]; + 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); + } } diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index a4419f73e..8e424cc3b 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -884,13 +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, - /// What each ended detached body left running (see - /// [`session_body::kill_body`]), keyed by sid, until the watcher reports it. - body_end_orphans: Arc>>>, - /// Sids whose body end, started by the `&mut` interrupt door on its own - /// task, FAILED: their `interrupted` mark must be lifted (see - /// [`Self::settle_failed_body_ends`]). - body_end_failures: 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 @@ -2602,6 +2600,17 @@ pub struct DetachedBody { 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. + InProgress, + /// 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 { fn error(&self) -> GatewayRequestError { GatewayRequestError::SessionBodyDetached { @@ -3590,8 +3599,7 @@ impl Gateway { detached: BTreeMap::new(), post_mortem_pending: Vec::new(), body_watch_notify: Arc::new(tokio::sync::Notify::new()), - body_end_orphans: Arc::new(std::sync::Mutex::new(HashMap::new())), - body_end_failures: Arc::new(std::sync::Mutex::new(HashSet::new())), + body_ends: Arc::new(std::sync::Mutex::new(HashMap::new())), projects, operator_chats: BTreeMap::new(), button_channels: BTreeSet::new(), @@ -4458,6 +4466,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(); @@ -4493,7 +4506,7 @@ impl Gateway { /// 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_failed_body_ends(); + self.settle_body_ends(); let Some(detached) = self.detached.get_mut(sid) else { return false; }; @@ -4504,19 +4517,16 @@ impl Gateway { let body = detached.body.clone(); let sid_owned = sid.to_string(); let notify = Arc::clone(&self.body_watch_notify); - let orphans = Arc::clone(&self.body_end_orphans); - let failures = Arc::clone(&self.body_end_failures); + let ends = Arc::clone(&self.body_ends); + Self::set_body_end(&ends, sid, BodyEnd::InProgress); tokio::spawn(async move { - let outcome = - Self::end_body_process(body, sid_owned.clone(), "interrupted", orphans).await; - if let Err(error) = outcome { + // 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).await + { tracing::warn!(session = %sid_owned, %error, "ccteam-im: could not end a detached body on interrupt"); - // Lift the mark (next lock holder settles it), or every later - // /interrupt would be a no-op and a natural exit would be - // recorded as interrupted. - if let Ok(mut set) = failures.lock() { - set.insert(sid_owned); - } } notify.notify_one(); }); @@ -4524,17 +4534,45 @@ impl Gateway { true } - /// Lift the `interrupted` mark of every body whose end failed on the - /// `&mut` door's own task, so the body is watched (and interruptible) - /// again. Called by every reader of the mark before it trusts it. - fn settle_failed_body_ends(&mut self) { - let failed: Vec = match self.body_end_failures.lock() { - Ok(mut set) => set.drain().collect(), + 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) + } + + 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)| matches!(end, BodyEnd::Failed)) + .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("interrupted") { + if detached.ending != Some("stopping") { detached.ending = None; } } @@ -4547,14 +4585,32 @@ impl Gateway { /// 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 and parks what it left running in `orphans` for the - /// watcher's report ([`session_body::kill_body`]). + /// 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, - orphans: Arc>>>, + ends: Arc>>, ) -> Result<()> { + let outcome = Self::run_body_end(body, &sid, why).await; + Self::set_body_end( + &ends, + &sid, + 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 @@ -4564,7 +4620,7 @@ impl Gateway { { return Err(anyhow!("test: ending body pid {pid} failed")); } - let sid_for_task = sid.clone(); + 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) @@ -4575,17 +4631,7 @@ impl Gateway { }) .await .context("join body end")??; - let Some(left) = left else { - return Err(anyhow!( - "{sid}'s process (pid {pid}) is still alive after SIGKILL" - )); - }; - if !left.is_empty() { - if let Ok(mut map) = orphans.lock() { - map.insert(sid, left); - } - } - Ok(()) + 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 @@ -4603,10 +4649,10 @@ impl Gateway { sid: &str, why: &'static str, ) -> Result { - let (body, orphans) = { + let (body, ends) = { let mut g = gateway.lock().await; - g.settle_failed_body_ends(); - let orphans = Arc::clone(&g.body_end_orphans); + g.settle_body_ends(); + let ends = Arc::clone(&g.body_ends); let Some(detached) = g.detached.get_mut(sid) else { return Ok(false); }; @@ -4616,22 +4662,17 @@ impl Gateway { Some(_) => return Ok(false), } detached.ending = Some(why); - (detached.body.clone(), orphans) + let body = detached.body.clone(); + Self::set_body_end(&ends, sid, BodyEnd::InProgress); + (body, ends) }; - let ended = Self::end_body_process(body, sid.to_string(), why, orphans).await; + let ended = Self::end_body_process(body, sid.to_string(), why, ends).await; let mut g = gateway.lock().await; - match ended { - Ok(()) => { - g.body_watch_notify.notify_one(); - Ok(true) - } - Err(error) => { - if let Some(detached) = g.detached.get_mut(sid) { - detached.ending = None; - } - Err(error) - } + 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): @@ -4683,7 +4724,7 @@ impl Gateway { 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 its background processes.", + `/stop {sid}` ends the session and that process with its background processes.", detached.body.pid, since ); Ok((receipt, format!("queued-behind-body:{sid}:{queued}"))) @@ -4716,7 +4757,7 @@ impl Gateway { pub async fn body_watch_tick(gateway: &Arc>) { let (detached, post_mortems) = { let mut g = gateway.lock().await; - g.settle_failed_body_ends(); + g.settle_body_ends(); ( g.detached.values().cloned().collect::>(), std::mem::take(&mut g.post_mortem_pending), @@ -4731,6 +4772,11 @@ impl Gateway { if d.ending == Some("stopping") { continue; } + // 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 { + continue; + } // 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 @@ -4831,30 +4877,35 @@ impl Gateway { 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); - } - tracing::info!(session = %d.sid, pid = d.body.pid, "ccteam-im: detached body exited; recovering + rebuilding by sid"); - let orphans = { - let g = gateway.lock().await; - let taken = g - .body_end_orphans - .lock() - .ok() - .and_then(|mut map| map.remove(&d.sid)) - .unwrap_or_default(); - taken + 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"); + // 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, ) @@ -18330,6 +18381,9 @@ impl Gateway { // 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 { @@ -22623,7 +22677,16 @@ mod tests { assert!(status.code().is_none(), "signalled: {status:?}"); let gateway = Arc::new(tokio::sync::Mutex::new(restarted)); - Gateway::body_watch_tick(&gateway).await; + // 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)); @@ -22899,13 +22962,8 @@ mod tests { } } assert!(told, "the reply target is told what is still running"); - assert!( - ledger_has( - &paths, - &format!(r#""orphans":[{{"cmd":"sleep 62","pid":{bg}}}]"#) - ) - .await - ); + 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); @@ -22990,7 +23048,10 @@ mod tests { // Let the kill task run and fail. for _ in 0..50 { tokio::time::sleep(std::time::Duration::from_millis(20)).await; - if !restarted.body_end_failures.lock().unwrap().is_empty() { + if matches!( + restarted.body_ends.lock().unwrap().get(&sid), + Some(BodyEnd::Failed) + ) { break; } } @@ -23019,6 +23080,53 @@ mod tests { 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::set_body_end(&restarted.body_ends, &sid, BodyEnd::InProgress); + 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()); + } + /// 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, diff --git a/docs/usage-cn.md b/docs/usage-cn.md index 031cd379c..eff182101 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -559,7 +559,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,还有后台任务在跑的(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` 则结束会话及其后台进程;它退出后 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 5a035578b..2ccdcc20a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -558,7 +558,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, 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 background processes, 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. From c6cc869581e9cd3d93f5f9740b42301a4bf6e497 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 03:10:57 +0000 Subject: [PATCH 18/29] fix: racy meta stamps, replay attempt accounting, compaction headroom (#213 review) docs-local issue #213, checker findings on b76dd809 + 5aa0fac4. - list_session_metas cache missed rewrites: atomic_write_durable's temp can land on the inode the previous rename freed, a rewrite keeps its length, and mtime moves on a ~4 ms tick, so two quick rewrites left the stamp unchanged (checker: 170/200 on ext4). Git's racy-timestamp rule: a cached parse is served only if the file's mtime was already 2 s old when it was read; a fresher file is re-read every scan. - Replay to an unconfigured channel spent an attempt without sending, so a row could be given up after zero sends. It now keeps attempts and only refreshes the error (the age bound still ends it). - Compaction bound = max(8 MB, 2x what the last compaction kept): owed rows alone past 8 MB no longer rewrite (+fsync) the whole ledger on every failure. - `daemon logs -f` drains the old file's unread tail before switching to the rotated-in one. - turns_mirror::digest returns None for a present-but-unreadable file; the meta refresh keeps the stored turn_count instead of writing 0, and the other callers fall back as they did before the digest. - Docs: FoldCache states its append-only / never-rewritten-path contract; turn_gating says goal visibility past the 8 MB first-read bound depends on the cache's history. Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-cli/src/daemon_cli.rs | 35 ++--- .../claude_stream_json/turn_gating.rs | 7 +- .../ccteam-harness/src/execution/journal.rs | 14 +- .../src/execution/session_meta.rs | 109 +++++++++++++- .../src/execution/turns_mirror.rs | 50 ++++--- crates/ccteam-im/src/daemon.rs | 135 ++++++++++++++++-- crates/ccteam-im/src/gateway.rs | 15 +- crates/ccteam-im/src/mcp/dispatch.rs | 2 +- 8 files changed, 306 insertions(+), 61 deletions(-) diff --git a/crates/ccteam-cli/src/daemon_cli.rs b/crates/ccteam-cli/src/daemon_cli.rs index fbfb9d011..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; @@ -784,9 +784,11 @@ pub fn run_daemon_logs(lines: usize, follow: bool, json: bool) -> Result<()> { 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: 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). + // 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; @@ -795,23 +797,22 @@ pub fn run_daemon_logs(lines: usize, follow: bool, json: bool) -> Result<()> { 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)?; } } } +/// 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 { 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 index 7905422eb..4c10b5952 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/turn_gating.rs @@ -40,7 +40,12 @@ const STOP_HOOK_RECENT_WINDOW: chrono::Duration = chrono::Duration::hours(1); 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. +/// 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 diff --git a/crates/ccteam-harness/src/execution/journal.rs b/crates/ccteam-harness/src/execution/journal.rs index 931684e43..e2015d7f0 100644 --- a/crates/ccteam-harness/src/execution/journal.rs +++ b/crates/ccteam-harness/src/execution/journal.rs @@ -469,9 +469,17 @@ pub trait LineFold: Send + 'static { /// 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 file that shrank or whose inode changed -/// is a different file, and its fold restarts. A trailing line without `\n` -/// is left for the next read (its writer may still be extending it). +/// 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. diff --git a/crates/ccteam-harness/src/execution/session_meta.rs b/crates/ccteam-harness/src/execution/session_meta.rs index d29215fa3..1bf681a2e 100644 --- a/crates/ccteam-harness/src/execution/session_meta.rs +++ b/crates/ccteam-harness/src/execution/session_meta.rs @@ -395,6 +395,7 @@ pub fn list_session_metas_reporting( }; // 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()) @@ -421,7 +422,10 @@ pub fn list_session_metas_reporting( } }; let name = entry.file_name(); - if let Some(cached) = previous.remove(&name).filter(|c| c.stamp == stamp) { + if let Some(cached) = previous + .remove(&name) + .filter(|c| c.trusted && c.stamp == stamp) + { out.push(cached.meta.clone()); seen.insert(name, cached); continue; @@ -430,7 +434,15 @@ pub fn list_session_metas_reporting( Ok(raw) => match serde_json::from_str::(&raw) { Ok(meta) => { out.push(meta.clone()); - seen.insert(name, CachedMeta { stamp, meta }); + let trusted = stamp.settled_before(scan_start); + seen.insert( + name, + CachedMeta { + stamp, + trusted, + meta, + }, + ); } Err(err) => failures.push(( meta_path, @@ -454,14 +466,26 @@ pub fn list_session_metas_reporting( /// 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. -/// `meta.json` is only ever replaced by rename ([`write_session_meta`]), so a -/// new write is a new inode — the stamp cannot miss one. +/// +/// 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, } @@ -473,6 +497,13 @@ struct MetaStamp { } 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 = { @@ -734,7 +765,17 @@ mod title_tests { titles(), vec![("s1".into(), "one".into()), ("s2".into(), "two".into())] ); - // Same length, same second: only the rename (new inode) tells. + // 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(); @@ -744,6 +785,64 @@ mod title_tests { ); } + /// 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/turns_mirror.rs b/crates/ccteam-harness/src/execution/turns_mirror.rs index b04c42f44..d8427e66f 100644 --- a/crates/ccteam-harness/src/execution/turns_mirror.rs +++ b/crates/ccteam-harness/src/execution/turns_mirror.rs @@ -253,23 +253,29 @@ impl journal::LineFold for DigestFold { } } -/// The session's [`TurnsDigest`] — empty when it has no turns.jsonl yet. -pub fn digest(project_dir: &Path, sid: &str) -> TurnsDigest { +/// 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)); - CACHE - .read( - &turns_jsonl_path(project_dir, sid), - || DigestFold { - prefix: format!("{sid}-"), - digest: TurnsDigest::default(), - }, - |fold| fold.digest.clone(), - ) - .unwrap_or_default() + 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 @@ -400,9 +406,9 @@ mod tests { last_turn_id: turns.last().map(|t| t.turn_id.clone()), } }; - assert_eq!(digest(tmp.path(), "s9"), TurnsDigest::default()); + 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"), full(tmp.path())); + 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, @@ -417,13 +423,25 @@ mod tests { writeln!(file, "not-json").unwrap(); write!(file, "{{\"turn_id\":\"torn").unwrap(); drop(file); - let d = digest(tmp.path(), "s9"); + 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"); + 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 diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index ff86fd91d..c2c5315a4 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -2386,9 +2386,28 @@ fn compact_durable_outbox_locked(path: &Path) -> Result> 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 = { @@ -2436,6 +2455,21 @@ async fn replay_durable_row( 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, + 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, @@ -2445,17 +2479,6 @@ async fn replay_durable_row( error: None, ..row }; - let Some(channel) = channels.get(&queued.channel) else { - append_durable_outbound( - ccteam_root, - DurableOutboundRow { - state: DurableOutboundState::Failed, - error: Some("replay failed: channel is not configured".to_string()), - ..queued - }, - ); - return; - }; append_durable_outbound(ccteam_root, queued.clone()); let send = finish_durable_outbound_send(ccteam_root, queued.clone(), channel.as_ref(), call_timeout); @@ -2508,8 +2531,12 @@ fn append_durable_outbound_with_limit( 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. - if row.state != DurableOutboundState::Queued && file.metadata()?.len() > compact_bytes { + // 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 @@ -3101,6 +3128,88 @@ mod tests { assert_eq!(rows[0].id, "o-1"); } + 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 diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 0ac7bb4d6..90c61e40b 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -7737,7 +7737,7 @@ impl Gateway { let mut events = session.adapter.events(&session.thread); let digest = project_dir .as_ref() - .map(|dir| ccteam_harness::execution::turns_mirror::digest(dir, &session_id)) + .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 @@ -8603,13 +8603,13 @@ impl Gateway { // before this one; this one is the next. let durable = project_dir .as_ref() - .map(|dir| { + .and_then(|dir| { ccteam_harness::execution::turns_mirror::digest( dir, &session_id, ) - .status_turns }) + .map(|digest| digest.status_turns) .unwrap_or(completed_turns); completed_turns = durable.max(completed_turns).saturating_add(1); vendor_turn = completed_turns; @@ -18487,7 +18487,10 @@ fn refresh_session_activity_meta( return; }; meta.last_active = chrono::Utc::now().to_rfc3339(); - meta.turn_count = ccteam_harness::execution::turns_mirror::digest(project_dir, sid).user_turns; + // 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; @@ -28656,7 +28659,9 @@ mod tests { ]; let project = tempfile::TempDir::new().unwrap(); let digest = |sid: &str| { - ccteam_harness::execution::turns_mirror::digest(project.path(), sid).max_answer_seq + ccteam_harness::execution::turns_mirror::digest(project.path(), sid) + .unwrap() + .max_answer_seq }; assert_eq!(digest("s7"), 0); for row in &rows { diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index 9bdd04d8b..d42e018d1 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -4784,7 +4784,7 @@ 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::digest(&resolved.project_dir, &resolved.sid) + ccteam_harness::execution::turns_mirror::digest(&resolved.project_dir, &resolved.sid)? .last_turn_id } From 25e33fdf2ee3a4194ee8317fc1a2f4b929576a68 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 03:11:51 +0000 Subject: [PATCH 19/29] fix(im): a body end survives its caller; redaction covers quoted and header secrets C1. A body's kill and its hand-over now run on their own task. Shared callers (REST /interrupt, /stop, project delete) await the task's JoinHandle, so a web client that disconnects mid-kill no longer leaves `InProgress` behind forever, with the sid stuck detached and its queue undelivered. Each `InProgress` carries a token and a start time: only the kill that set it writes the outcome, and one older than 30s is settled as a failed end. C2. The redactor now also covers: - quoted values (`--password='x'`, `TOKEN="x"`, `--token "x"`); - header and JSON members (`X-Api-Key: x`, `{"token":"x"}`) and Basic credentials; - `curl -u user:x`, mysql/mariadb `-px` and `sshpass -p x`; - `pass` and a whole-word `pwd` as sensitive keys; - URL userinfo with an empty user (`redis://:pw@h`). Every example from the review is in the test table. Minors. If a stop finds its body already gone (an /interrupt's SIGKILL got there first), it still sweeps the body's process group, but only when a member still carries the sid in its environment. When the watcher's rebuild fails, the delegated tasks queued for the sid are withdrawn as their parents are told; a parent's resend is then the only run, never a late second completion. A human's queued lines stay. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- .../src/execution/session_body.rs | 169 ++++++++++++++- crates/ccteam-im/src/gateway.rs | 196 +++++++++++++++--- crates/ccteam-im/src/pending_turns.rs | 39 ++++ 3 files changed, 370 insertions(+), 34 deletions(-) diff --git a/crates/ccteam-harness/src/execution/session_body.rs b/crates/ccteam-harness/src/execution/session_body.rs index ddc51c38e..6641ad441 100644 --- a/crates/ccteam-harness/src/execution/session_body.rs +++ b/crates/ccteam-harness/src/execution/session_body.rs @@ -195,6 +195,10 @@ pub fn body_is_alive(body: &SessionBody, sid: &str) -> bool { /// `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); } let group = leads_own_group(body.pid); @@ -221,6 +225,39 @@ 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 { @@ -341,28 +378,52 @@ fn cmdline_argv(pid: u32) -> Vec { 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`; only that is replaced. ASCII lowercasing keeps byte + // 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(|| { - const SENSITIVE: &str = - "[a-z0-9_.-]*(?:token|secret|password|passwd|key|auth|credential)[a-z0-9_.-]*"; + // 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'"]+))"#; [ - (r#"bearer[ \t]+(?P[^ \t'"]+)"#.to_string(), true), - (r#"://(?P[^/ \t:@]+:[^/ \t@]+)@"#.to_string(), false), + // 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#"(?:^|[^a-z0-9_.-])-{{0,2}}{SENSITIVE}=(?P[^ \t'"]+)"#), + 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`. ( - format!(r#"(?:^|[ \t])--?{SENSITIVE}[ \t]+(?P[^ \t'"-][^ \t'"]*)"#), + r#"(?:mysql|mariadb|mysqldump|mysqladmin)(?:[ \t][^\n]*?)?[ \t]-p(?P[^ \t'"]+)"# + .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() @@ -383,7 +444,13 @@ pub fn redact_cmdline(argv: &[String], home: Option<&str>) -> String { 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") else { continue }; + 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(); @@ -888,6 +955,43 @@ mod tests { "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=***", + ), ]; for (input, want) in cases { assert_eq!(redact_cmdline(&argv(input), home), *want, "{input:?}"); @@ -909,4 +1013,53 @@ mod tests { 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-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 8e424cc3b..ff2e5fbf8 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -2604,7 +2604,9 @@ pub struct DetachedBody { #[derive(Debug, Clone)] enum BodyEnd { /// The kill is running; nothing about this body may be reported yet. - InProgress, + /// `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. @@ -4518,13 +4520,13 @@ impl Gateway { let sid_owned = sid.to_string(); let notify = Arc::clone(&self.body_watch_notify); let ends = Arc::clone(&self.body_ends); - Self::set_body_end(&ends, sid, BodyEnd::InProgress); + 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).await + 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"); } @@ -4538,10 +4540,29 @@ impl Gateway { let g = gateway.lock().await; g.body_ends .lock() - .map(|map| matches!(map.get(sid), Some(BodyEnd::InProgress))) + .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, @@ -4560,7 +4581,14 @@ impl Gateway { Ok(mut map) => { let failed: Vec = map .iter() - .filter(|(_, end)| matches!(end, BodyEnd::Failed)) + .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 { @@ -4593,16 +4621,22 @@ impl Gateway { sid: String, why: &'static str, ends: Arc>>, + token: u64, ) -> Result<()> { let outcome = Self::run_body_end(body, &sid, why).await; - Self::set_body_end( - &ends, - &sid, - match &outcome { - Ok(left) => BodyEnd::Ended(left.clone()), - Err(_) => BodyEnd::Failed, - }, - ); + // 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(|_| ()) } @@ -4620,6 +4654,14 @@ impl Gateway { { 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" { @@ -4649,7 +4691,7 @@ impl Gateway { sid: &str, why: &'static str, ) -> Result { - let (body, ends) = { + let (body, ends, token) = { let mut g = gateway.lock().await; g.settle_body_ends(); let ends = Arc::clone(&g.body_ends); @@ -4663,10 +4705,21 @@ impl Gateway { } detached.ending = Some(why); let body = detached.body.clone(); - Self::set_body_end(&ends, sid, BodyEnd::InProgress); - (body, ends) + let token = Self::start_body_end(&ends, sid); + (body, ends, token) }; - let ended = Self::end_body_process(body, sid.to_string(), why, ends).await; + // 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(); @@ -4888,7 +4941,7 @@ impl Gateway { // 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, + Some(BodyEnd::InProgress { .. }) => return, _ => map.remove(&d.sid), }, Err(_) => None, @@ -4955,9 +5008,16 @@ impl Gateway { 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. The queued turns stay in - // the FIFO and go out, in order, ahead of the next message that - // reaches this sid (see `submit_resolved`). + // 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; @@ -4988,8 +5048,8 @@ impl Gateway { }); // A delegation parent waiting on a task queued here would wait // forever: answer it through the normal completion path, as an - // honest failure. The task line itself stays queued. - if let Some(dtx) = g.delegation_tx.clone() { + // honest failure. Its task line was withdrawn above. + if let Some(dtx) = g.delegation_tx.clone().filter(|_| !withdrawn.is_empty()) { let turn_id = format!( "{sid}-resume-failed-{}", chrono::Utc::now().timestamp_millis() @@ -5003,7 +5063,7 @@ impl Gateway { exec_turn_id: None, tail: format!( "{sid} could not be resumed after its old process ended: {error}. \ - The task is kept queued and runs when {sid} is next resumed." + The task was withdrawn and did not run; send it again to retry." ), vendor: meta.vendor, host: meta.host.clone(), @@ -21107,6 +21167,9 @@ mod tests { /// 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::{ @@ -23095,7 +23158,7 @@ mod tests { 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::set_body_end(&restarted.body_ends, &sid, BodyEnd::InProgress); + 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); @@ -23127,6 +23190,75 @@ mod tests { 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()); + } + /// 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, @@ -23145,6 +23277,18 @@ mod tests { let (dtx, mut drx) = tokio::sync::mpsc::unbounded_channel(); restarted.set_delegation_notifier_tx(dtx); 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)); @@ -23158,7 +23302,7 @@ mod tests { assert_eq!( crate::pending_turns::pending_turn_count(&project_dir, &sid), 2, - "the queue is kept" + "the human lines are kept; the delegated task is withdrawn" ); let mut told = false; while let Ok(event) = events.try_recv() { diff --git a/crates/ccteam-im/src/pending_turns.rs b/crates/ccteam-im/src/pending_turns.rs index 508b574ae..7c65043a8 100644 --- a/crates/ccteam-im/src/pending_turns.rs +++ b/crates/ccteam-im/src/pending_turns.rs @@ -191,6 +191,45 @@ pub fn withdraw_last_pending_turn(project_dir: &Path, sid: &str, text: &str) -> 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); From a3485b437944c3b157cb0b3e67f7495528b20973 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Wed, 14 Oct 2026 03:27:29 +0000 Subject: [PATCH 20/29] fix(im): a withdrawn task's parent is answered through the completion path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the watcher cannot bring a session back, the delegated tasks queued for it are withdrawn. Their resume_failed signal used to carry no execution turn, and plan_delegation_delivery answers a boundary nobody's request is bound to with nobody: the parent was never told, and the request stayed outstanding in delegation.json forever. The withdrawn requests are now bound, durably and under the child's store claim, to a turn of their own. That turn is delivered through the ordinary completion path, so the notification has the same identity matching, the same two-phase durability and restart dedup, and the same submission (a live parent is steered, a released one gets the pending FIFO and a resume) as any completion. Each parent hears once that the task was withdrawn and did not run. A REST /stop now runs the whole stop on its own task. A client that disconnects mid-kill no longer leaves a dead body marked `stopping` forever with messages queued behind it. Redaction adds Cookie / Set-Cookie header values, `docker login … -p`, and `echo|printf SECRET | … --password-stdin`. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 --- .../src/execution/session_body.rs | 52 ++++ crates/ccteam-im/src/gateway.rs | 267 +++++++++++++++--- 2 files changed, 273 insertions(+), 46 deletions(-) diff --git a/crates/ccteam-harness/src/execution/session_body.rs b/crates/ccteam-harness/src/execution/session_body.rs index 6641ad441..f88cb0507 100644 --- a/crates/ccteam-harness/src/execution/session_body.rs +++ b/crates/ccteam-harness/src/execution/session_body.rs @@ -415,6 +415,22 @@ pub fn redact_cmdline(argv: &[String], home: Option<&str>) -> String { .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. @@ -992,6 +1008,42 @@ mod tests { &["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:?}"); diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index ff2e5fbf8..43c39ee55 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -5049,37 +5049,92 @@ impl Gateway { // 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. - if let Some(dtx) = g.delegation_tx.clone().filter(|_| !withdrawn.is_empty()) { - let turn_id = format!( - "{sid}-resume-failed-{}", - chrono::Utc::now().timestamp_millis() - ); - let _ = dtx.send(crate::delegation::DelegationPulse::Signal( - crate::delegation::DelegationSignal { - child_sid: sid.clone(), - turn_id: turn_id.clone(), - // Never observed executing: resolves the requests - // still queued for this sid. - exec_turn_id: None, - 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: meta.vendor, - host: meta.host.clone(), - boundary: true, - continuation: ccteam_harness::TurnContinuation::Settled, - vendor_error: true, - interim_notes: 0, - covered_turns: vec![turn_id], - context_pct: None, - turn: meta.turn_count, - error_kind: Some("resume_failed".to_string()), - conclusion: None, - }, - )); + 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 @@ -17930,6 +17985,21 @@ 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 @@ -23259,6 +23329,126 @@ mod tests { 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, @@ -23274,8 +23464,6 @@ mod tests { let paths = progress_paths(tmp.path()); restarted.enable_project_creation(paths.clone()); let mut events = restarted.subscribe_events(); - let (dtx, mut drx) = tokio::sync::mpsc::unbounded_channel(); - restarted.set_delegation_notifier_tx(dtx); 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. @@ -23316,21 +23504,8 @@ mod tests { told, "the reply target hears why the session did not come back" ); - let mut parent_told = false; - while let Ok(pulse) = drx.try_recv() { - if let crate::delegation::DelegationPulse::Signal(signal) = pulse { - if signal.child_sid == sid - && signal.error_kind.as_deref() == Some("resume_failed") - && signal.vendor_error - { - parent_told = true; - } - } - } - assert!( - parent_told, - "a waiting delegation parent gets an honest failure" - ); + // 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 From a4745a149a058ae341eb51c4a90eaf6f4fc346b4 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 16 Oct 2026 09:34:28 +0000 Subject: [PATCH 21/29] fix(web): embed SPA assets in debug binaries Resolve docs-local/issues/#215: keep copied debug binaries independent of their build worktree and guard embedding with a regression test. Validation: cargo fmt and make check pass; the embedding regression passes. make test-web remains blocked by the existing ws_last_client_disconnect_stops_pipe_pane FIFO assertion recorded in issues #214 and #215. --- crates/ccteam-web/Cargo.toml | 7 ++++++- crates/ccteam-web/src/routes/assets.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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/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" + ); + } +} From 466407ea491a13229a6e140fa56cff7bb634a201 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Mon, 19 Oct 2026 06:58:53 +0000 Subject: [PATCH 22/29] fix(web): give tenants the same gateway control face as the admin A tenant's web `/status` (and every other gateway command) fell through to the vendor because `submit_web_sid` gated the control face on `is_admin`; Claude then queued it behind the running turn. The gate's premise (IM chat ACL coarser than the web's) no longer holds since the v0.8.20 identity convergence: `Gateway::principal` resolves the caller's own web chat to Operator or Tenant and the fleet renders filter through the shared visibility policy. Commands now run on the caller's web chat (`Identity::web_chat_id`) instead of the shared admin chat, and a released session is focused too. HITL approval prompts were emitted without a project slug, so the project-filtered fleet stream showed them to the operator only; the prompt context now carries the session's project. Refs docs-local/issues/#216 Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-im/src/gateway.rs | 112 ++++++++++++++----- crates/ccteam-im/src/hitl.rs | 5 +- crates/ccteam-web/src/routes/sessions_api.rs | 2 +- 3 files changed, 86 insertions(+), 33 deletions(-) diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index c0b86b09e..1f11c160c 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -2389,6 +2389,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 @@ -14579,6 +14584,7 @@ impl Gateway { thread_ts, role: session.role.clone(), progress_path, + slug: session.project.clone(), }) } @@ -17400,16 +17406,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; } } @@ -17958,28 +17964,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); } @@ -27560,7 +27568,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}")); @@ -27600,7 +27608,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!( @@ -27611,25 +27619,67 @@ 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"); + } + + /// 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 ); } @@ -28484,7 +28534,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 { @@ -29439,7 +29489,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(); 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-web/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index db9dc3caa..0497cfd49 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -1164,7 +1164,7 @@ pub(crate) async fn handle_session_turn( Arc::clone(gw), &sid, text, - identity.is_admin, + &identity.web_chat_id(), deadline, ) .await; From 6ffc5b1ede6a773d34324efbbe97eef00011d2a6 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Mon, 19 Oct 2026 18:32:40 +0000 Subject: [PATCH 23/29] =?UTF-8?q?feat(web):=20two-layer=20step=20view=20?= =?UTF-8?q?=E2=80=94=20thinking=20text=20and=20full=20tool=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn's activity in the web console was one folded counter row; the per-step summaries reached the browser and were only counted, and the full content did not exist anywhere in ccteam: stream-json dropped every `tool_result`, and reasoning was cut to a 200-char preview. Adapters: tool items carry a vendor-neutral `output`. Claude stream-json pairs each `tool_result` with its open `tool_use` and completes the same item; the terminal transcript tail keeps the input in `args` and the result in `output`; ACP reads `rawOutput`/`content` and now reports the turn's whole accumulated thought instead of a throttled chunk; Codex reads aggregated output / MCP results. Gateway: every activity frame carries a `step_id` unique across pump lives, and the pump appends each finished step (tool with input+output, reasoning with its text) to `chat//steps.jsonl`, 256 KiB per field. SSE frames stay small; `GET /api/v1/sessions/{sid}/steps/{step_id}` serves a step whole, gated like every `/sessions/{sid}/*` route. SPA: a folded activity row expands into its step list (layer one); picking a step opens the StepInspector (layer two), docked right on wide screens and overlaid below 900px, which fetches the full record and re-asks while the step is not recorded yet. Refs docs-local/issues/#217 Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-harness/src/adapter.rs | 119 ++++++++++++ .../src/execution/acp/translate.rs | 133 +++++++++++++- .../execution/claude_stream_json/translate.rs | 143 +++++++++++++-- .../src/execution/codex_app_server.rs | 2 + .../src/execution/codex_exec.rs | 2 + .../src/execution/pi_rpc/translate.rs | 7 +- .../src/execution/transcript_tail.rs | 24 ++- .../src/execution/turns_mirror.rs | 151 ++++++++++++++++ crates/ccteam-harness/src/lib.rs | 29 +-- .../tests/codex_app_server_test.rs | 7 +- crates/ccteam-im/src/gateway.rs | 170 +++++++++++++++++- crates/ccteam-im/src/progress.rs | 95 +++++++++- crates/ccteam-im/tests/im_progress_test.rs | 2 + crates/ccteam-web/src/routes/openapi.rs | 1 + crates/ccteam-web/src/routes/sessions_api.rs | 72 ++++++++ crates/ccteam-web/tests/openapi_test.rs | 1 + crates/ccteam-web/tests/sessions_api_test.rs | 66 +++++++ .../web/src/components/StepInspector.tsx | 129 +++++++++++++ .../web/src/hooks/useAgentsEvents.ts | 1 + .../web/src/hooks/useSessionEvents.test.ts | 3 + .../web/src/hooks/useSessionEvents.ts | 9 +- crates/ccteam-web/web/src/index.css | 40 +++++ crates/ccteam-web/web/src/lib/i18n.ts | 24 +++ crates/ccteam-web/web/src/lib/sessionsApi.ts | 26 +++ .../ccteam-web/web/src/lib/stepLoad.test.ts | 28 +++ crates/ccteam-web/web/src/lib/stepLoad.ts | 35 ++++ .../web/src/pages/SessionView.steps.test.tsx | 121 +++++++++++++ .../ccteam-web/web/src/pages/SessionView.tsx | 75 +++++++- .../web/src/pages/chatTranscript.test.ts | 40 +++++ .../web/src/pages/chatTranscript.ts | 93 +++++++++- 30 files changed, 1594 insertions(+), 54 deletions(-) create mode 100644 crates/ccteam-web/web/src/components/StepInspector.tsx create mode 100644 crates/ccteam-web/web/src/lib/stepLoad.test.ts create mode 100644 crates/ccteam-web/web/src/lib/stepLoad.ts create mode 100644 crates/ccteam-web/web/src/pages/SessionView.steps.test.tsx diff --git a/crates/ccteam-harness/src/adapter.rs b/crates/ccteam-harness/src/adapter.rs index cebe6fa3e..67a3bfe6d 100644 --- a/crates/ccteam-harness/src/adapter.rs +++ b/crates/ccteam-harness/src/adapter.rs @@ -1604,6 +1604,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, @@ -1611,7 +1615,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, @@ -1619,6 +1630,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 { @@ -2860,4 +2936,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/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/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/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/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/.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::*; @@ -544,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 904a680a4..5e4eae7e5 100644 --- a/crates/ccteam-harness/src/lib.rs +++ b/crates/ccteam-harness/src/lib.rs @@ -47,20 +47,21 @@ 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, 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, + 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, ContextSource, + 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/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/gateway.rs b/crates/ccteam-im/src/gateway.rs index 1f11c160c..0ac423249 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -2104,6 +2104,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. @@ -8373,6 +8378,21 @@ impl Gateway { // 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; @@ -9962,7 +9982,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; } @@ -20051,6 +20086,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` @@ -20439,6 +20517,7 @@ mod open_work_items_tests { details: ThreadItemDetails::ToolCall { name: "Bash".into(), args: serde_json::json!({}), + output: None, }, }, }, @@ -20463,6 +20542,7 @@ mod open_work_items_tests { details: ThreadItemDetails::ToolCall { name: "Bash".into(), args: serde_json::json!({}), + output: None, }, }, }, @@ -20476,6 +20556,7 @@ mod open_work_items_tests { details: ThreadItemDetails::CommandExecution { cmd: "sleep 600".into(), status: "running".into(), + output: None, }, }, }, @@ -27627,6 +27708,92 @@ mod tests { 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 @@ -30606,6 +30773,7 @@ mod tests { details: ThreadItemDetails::ToolCall { name: "Bash".into(), args: serde_json::json!({"command": "make test"}), + output: None, }, }; if done { 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/tests/im_progress_test.rs b/crates/ccteam-im/tests/im_progress_test.rs index 25c029811..d83a437cd 100644 --- a/crates/ccteam-im/tests/im_progress_test.rs +++ b/crates/ccteam-im/tests/im_progress_test.rs @@ -194,6 +194,7 @@ fn tool_started(id: &str, name: &str, args: serde_json::Value) -> ThreadEvent { details: ThreadItemDetails::ToolCall { name: name.into(), args, + output: None, }, }, } @@ -206,6 +207,7 @@ fn tool_completed(id: &str, name: &str) -> ThreadEvent { details: ThreadItemDetails::ToolCall { name: name.into(), args: serde_json::json!("result"), + output: None, }, }, } diff --git a/crates/ccteam-web/src/routes/openapi.rs b/crates/ccteam-web/src/routes/openapi.rs index eb604f836..afca4d897 100644 --- a/crates/ccteam-web/src/routes/openapi.rs +++ b/crates/ccteam-web/src/routes/openapi.rs @@ -277,6 +277,7 @@ fn build_api_v1() -> OpenApiRouter { )) .routes(routes!(super::sessions_api::handle_session_status)) .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/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index 0497cfd49..a7b1eb27c 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -1781,6 +1781,75 @@ pub(crate) async fn handle_session_interrupt( } } +/// `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` @@ -2676,6 +2745,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); @@ -2692,6 +2762,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..979dd4dbf 100644 --- a/crates/ccteam-web/tests/openapi_test.rs +++ b/crates/ccteam-web/tests/openapi_test.rs @@ -151,6 +151,7 @@ fn expected_operations() -> BTreeSet<(&'static str, &'static str)> { // live statusline (model + context-window usage) for the SPA top bar. ("GET", "/api/v1/sessions/{sid}/status"), ("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..94d464771 100644 --- a/crates/ccteam-web/tests/sessions_api_test.rs +++ b/crates/ccteam-web/tests/sessions_api_test.rs @@ -1852,3 +1852,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/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..a3ac87877 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 { diff --git a/crates/ccteam-web/web/src/lib/i18n.ts b/crates/ccteam-web/web/src/lib/i18n.ts index 7b74a89b4..007445d3f 100644 --- a/crates/ccteam-web/web/src/lib/i18n.ts +++ b/crates/ccteam-web/web/src/lib/i18n.ts @@ -74,6 +74,18 @@ export const I18N: Record> = { noMatch: "没有匹配的会话", connLost: "连接已断开", historyLoadFailed: "历史加载失败,当前显示可能不完整", + stepsShow: "展开步骤", + stepsHide: "收起步骤", + stepInspector: "步骤详情", + stepInput: "输入", + stepOutput: "输出", + stepThinking: "思考", + stepRunning: "仍在运行 —— 完成后显示完整输出", + stepUnavailable: "该步骤没有完整记录(旧版守护进程或未落盘)", + stepNoOutput: "(无输出)", + stepTruncated: "内容过长,已截断", + stepLoading: "加载中…", + close: "关闭", retry: "重试", busyElapsed: "已运行", busyLastEvent: "最近事件", @@ -409,6 +421,18 @@ 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", busyElapsed: "running", busyLastEvent: "last event", 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.tsx b/crates/ccteam-web/web/src/pages/SessionView.tsx index acf0fc1b6..d2283121a 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.tsx @@ -23,6 +23,7 @@ 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 { @@ -58,6 +59,7 @@ import { mergeHistory, nextRowId, saveRows, + stepIcon, type TranscriptRow, } from "./chatTranscript"; import { railSessionLabel } from "./railHelpers"; @@ -210,6 +212,26 @@ 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); @@ -748,9 +770,57 @@ export default function SessionView({ ); } if (row.kind === "activity") { + const steps = row.fold?.steps ?? []; + if (steps.length === 0) { + return ( +
+
{row.content}
+ +
+ ); + } + const open = expandedFolds.has(row.id); return (
-
{row.content}
+ + {open ? ( +
    + {steps.map((step) => { + const selected = + inspected?.rowId === row.id && inspected.itemId === step.item_id; + return ( +
  1. + +
  2. + ); + })} +
+ ) : null}
); @@ -819,6 +889,9 @@ export default function SessionView({ 回到最新 ) : null} + {inspectedStep ? ( + + ) : 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`: From 8cd8dd32283d9cccb48646db7468ebe6afa7b02d Mon Sep 17 00:00:00 2001 From: randd1024 Date: Tue, 20 Oct 2026 07:44:12 +0000 Subject: [PATCH 24/29] fix(a2a): one completion per parent per turn; a silent turn settles its requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lines a parent steers into a child's running turn each become a request bound to that turn, and the planner built one notification per request: excore s1390 steered sixteen lines into one long codex turn of s1403 and got sixteen identical copies of the same answer in one second. A turn has one answer, so a parent is now told once — one notification per parent per boundary, its header naming every request it settles (`answers N requests: …`), the excerpt at the widest mode asked for. Requests are still resolved, recorded and ledgered one by one. A turn that ended without any text wrote no row, and the pump signals a delegation boundary only off a row, so the request bound to it (a "no reply needed" line codex acknowledged silently) stayed `executing` for good and was counted as "still queued" in every later notification. A settled turn that said nothing now gets the same empty, status-bearing closing row a turn released mid-turn gets; the boundary names that row, the parent reads "(no reply)", and the restart reconcile treats such a row as a turn's end too. Refs docs-local/issues/#218 (excore F-GOV-233) Co-Authored-By: Claude Opus 5.5 --- crates/ccteam-im/src/delegation.rs | 85 +++-- crates/ccteam-im/src/gateway.rs | 472 ++++++++++++++++++++++----- crates/ccteam-im/src/mcp/dispatch.rs | 7 +- docs/mcp-cn.md | 2 +- docs/mcp.md | 2 +- docs/orchestration-cn.md | 2 +- docs/orchestration.md | 2 +- 7 files changed, 462 insertions(+), 110 deletions(-) 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 0ac423249..cec3083b8 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -1277,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 { @@ -1295,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, } @@ -1530,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() @@ -9364,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 { @@ -9392,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" + ); + } } } } @@ -16784,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), @@ -16813,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, }) } @@ -17068,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()); } } } @@ -17151,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 { @@ -38264,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")), @@ -38306,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 @@ -38627,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 { @@ -41350,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 diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index d42e018d1..9af2d6502 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -4064,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, @@ -4084,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() 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". From e9b0e73694dd44f232a7281a27eb7bb4cbdfb1c8 Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:19:25 +0800 Subject: [PATCH 25/29] chore(im): rename deprecated AtomicUsize::fetch_update to try_update Rust 1.99 deprecates fetch_update in favour of try_update, which turns clippy -D warnings red on the gateway test double. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- crates/ccteam-im/src/gateway.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index cec3083b8..5cbadf07a 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -25921,7 +25921,7 @@ mod tests { self.starts.fetch_add(1, Ordering::SeqCst); if self .fail_starts - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) + .try_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) .is_ok() { return Err(HarnessError::SpawnFailed( From 9f363c71017df81dadef6c3aaf9fde438b942c8e Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:19:25 +0800 Subject: [PATCH 26/29] feat(context): expose what a session's context window is made of Users could only learn where a session's context goes by asking the model to guess. Claude's get_context_usage control_request already carries the data behind its own /context (categories, memory files, skills, MCP tools, agents, the conversation split by tool and attachment, last-request cache usage), and ccteam called it every turn but kept two numbers. - harness: vendor-neutral ContextBreakdown + optional HarnessAdapter::context_breakdown; the Claude stream-json parse is lenient (only occupancy and window are required) because the protocol is internal and already richer than the reference schema. - The turn-end get_context_usage answer is kept whole and persisted as .ccteam/chat//context.json beside status.json, so a released session still shows its last snapshot; no extra control requests. - GET /api/v1/sessions/{sid}/context: a resident session is asked live, otherwise the snapshot is served (live:false); null when the harness has no breakdown. Gated by gate_sid like every by-sid route. Refs docs-local/issues/#21. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- crates/ccteam-harness/src/adapter.rs | 133 ++++++ .../execution/claude_stream_json/context.rs | 393 ++++++++++++++++++ .../src/execution/claude_stream_json/mod.rs | 41 +- .../src/execution/session_status.rs | 61 ++- crates/ccteam-harness/src/lib.rs | 18 +- .../tests/claude_stream_json_test.rs | 51 ++- crates/ccteam-web/src/routes/openapi.rs | 1 + crates/ccteam-web/src/routes/sessions_api.rs | 64 +++ crates/ccteam-web/tests/openapi_test.rs | 2 + crates/ccteam-web/tests/sessions_api_test.rs | 114 +++++ 10 files changed, 856 insertions(+), 22 deletions(-) create mode 100644 crates/ccteam-harness/src/execution/claude_stream_json/context.rs diff --git a/crates/ccteam-harness/src/adapter.rs b/crates/ccteam-harness/src/adapter.rs index 67a3bfe6d..6aafde952 100644 --- a/crates/ccteam-harness/src/adapter.rs +++ b/crates/ccteam-harness/src/adapter.rs @@ -1406,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 @@ -1984,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 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 5d80f2123..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,6 +28,7 @@ //! - **No terminal scraping**: there is no terminal — naturally satisfied. pub mod bridge; +mod context; pub mod protocol; pub mod recovery; pub mod spawn_spec; @@ -51,7 +52,7 @@ 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, DetachedActivity, @@ -1183,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 @@ -1190,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 @@ -1588,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 { @@ -2856,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 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/lib.rs b/crates/ccteam-harness/src/lib.rs index 5e4eae7e5..0bdf0688a 100644 --- a/crates/ccteam-harness/src/lib.rs +++ b/crates/ccteam-harness/src/lib.rs @@ -51,14 +51,16 @@ pub use adapter::{ 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, ContextSource, - 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, + 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, diff --git a/crates/ccteam-harness/tests/claude_stream_json_test.rs b/crates/ccteam-harness/tests/claude_stream_json_test.rs index c80cffd48..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"}}) @@ -1803,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 diff --git a/crates/ccteam-web/src/routes/openapi.rs b/crates/ccteam-web/src/routes/openapi.rs index afca4d897..295ae161f 100644 --- a/crates/ccteam-web/src/routes/openapi.rs +++ b/crates/ccteam-web/src/routes/openapi.rs @@ -276,6 +276,7 @@ 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!( diff --git a/crates/ccteam-web/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index a7b1eb27c..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)] diff --git a/crates/ccteam-web/tests/openapi_test.rs b/crates/ccteam-web/tests/openapi_test.rs index 979dd4dbf..280d6141c 100644 --- a/crates/ccteam-web/tests/openapi_test.rs +++ b/crates/ccteam-web/tests/openapi_test.rs @@ -150,6 +150,8 @@ 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. diff --git a/crates/ccteam-web/tests/sessions_api_test.rs b/crates/ccteam-web/tests/sessions_api_test.rs index 94d464771..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 From ffbc2149b0ba55a889a423200237e5f0a52c3360 Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:28:29 +0800 Subject: [PATCH 27/29] =?UTF-8?q?feat(web):=20context=20panel=20=E2=80=94?= =?UTF-8?q?=20a=20docked=20right=20sidebar=20showing=20what=20a=20session'?= =?UTF-8?q?s=20window=20is=20made=20of?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `model · ctx N%` chip in the conversation header (and an always-present toggle next to the cost pill) opens a right sidebar beside the chat that reads `GET /api/v1/sessions/{sid}/context`: a header line (model, used / window · %, live vs snapshot · age, Refresh with a spinner state and an inline failure), one stacked bar of the window (used categories in fixed per-category colors → autocompact buffer → free; deferred tool schemas listed apart, muted and uncounted), the category table (tokens desc, rows expand into items with source / path / not-loaded marks, long paths middle-truncated with a tooltip), the Messages efficiency view (bucket split, per-tool call / result / total desc, attachment types desc) and the last request's cache-hit line. The docked state is remembered per browser (localStorage) so it survives a session switch; a phone viewport gets a right-edge overlay drawer with a backdrop and never auto-restores it. The panel re-fetches when a turn ends, keyed on the page's own SSE boundary frame id — no polling loop. Escape and × close it and hand focus back to the toggle. `breakdown: null` renders an honest explanation of which sessions report one; every sub-section degrades on its own; unknown fields are ignored. All copy goes through i18n (zh + en) and says harness, never vendor. Pure rules (sorting, bar segments, cache ratio, formatting, memory) live in lib/contextBreakdown.ts with unit tests; the panel has jsdom component tests; SessionView's SSR tests cover both entry points and the restored-open mount. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- .../web/src/components/ContextPanel.test.tsx | 419 +++++++++++ .../web/src/components/ContextPanel.tsx | 650 ++++++++++++++++++ crates/ccteam-web/web/src/index.css | 120 ++++ .../ccteam-web/web/src/lib/contextApi.test.ts | 89 +++ crates/ccteam-web/web/src/lib/contextApi.ts | 120 ++++ .../web/src/lib/contextBreakdown.test.ts | 346 ++++++++++ .../web/src/lib/contextBreakdown.ts | 333 +++++++++ crates/ccteam-web/web/src/lib/i18n.ts | 90 +++ .../web/src/pages/SessionView.test.tsx | 64 ++ .../ccteam-web/web/src/pages/SessionView.tsx | 597 +++++++++------- 10 files changed, 2570 insertions(+), 258 deletions(-) create mode 100644 crates/ccteam-web/web/src/components/ContextPanel.test.tsx create mode 100644 crates/ccteam-web/web/src/components/ContextPanel.tsx create mode 100644 crates/ccteam-web/web/src/lib/contextApi.test.ts create mode 100644 crates/ccteam-web/web/src/lib/contextApi.ts create mode 100644 crates/ccteam-web/web/src/lib/contextBreakdown.test.ts create mode 100644 crates/ccteam-web/web/src/lib/contextBreakdown.ts 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/index.css b/crates/ccteam-web/web/src/index.css index a3ac87877..07163d9b8 100644 --- a/crates/ccteam-web/web/src/index.css +++ b/crates/ccteam-web/web/src/index.css @@ -1476,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 007445d3f..69e7ab5a1 100644 --- a/crates/ccteam-web/web/src/lib/i18n.ts +++ b/crates/ccteam-web/web/src/lib/i18n.ts @@ -87,6 +87,51 @@ export const I18N: Record> = { 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)", @@ -434,6 +479,51 @@ export const I18N: Record> = { 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/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 d2283121a..7e3d9f056 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.tsx @@ -17,8 +17,9 @@ // 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"; @@ -28,6 +29,7 @@ import { TerminalView } from "../components/TerminalView"; import { VendorChip } from "../components/VendorChip"; import { foldSessionLiveness, + isTurnBoundary, turnInFlight, useSessionEvents, type TurnMark, @@ -64,6 +66,11 @@ import { } 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`; @@ -236,9 +243,42 @@ export default function SessionView({ 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; @@ -656,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}
@@ -682,279 +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) => ( - - ))} -
- {row.resolved ? ( -
- 已回应 -
- ) : null} -
- -
- ); - } - if (row.kind === "system") { - return ( -
-
{row.content}
- -
- ); - } - if (row.kind === "activity") { - const steps = row.fold?.steps ?? []; - if (steps.length === 0) { - return ( -
-
{row.content}
- -
- ); - } - const open = expandedFolds.has(row.id); - return ( -
- - {open ? ( -
    - {steps.map((step) => { - const selected = - inspected?.rowId === row.id && inspected.itemId === step.item_id; - return ( -
  1. + ) : null} + {rows.map((row) => { + if (row.kind === "approval") { + return ( +
    + {t("approve")} +
    + {row.content} +
    + {(row.options ?? []).map((opt, i) => ( -
  2. - ); - })} -
- ) : null} - -
- ); - } - 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(" · "); + ))} +
+ {row.resolved ? ( +
+ 已回应 +
+ ) : null} +
+ +
+ ); + } + if (row.kind === "system") { return ( - +
+
{row.content}
- {row.ts ? ` · ${tail}` : tail} - +
); - })()} -
- ); - })} - {busy ? ( -
-
- - -
+ } + if (row.kind === "activity") { + const steps = row.fold?.steps ?? []; + if (steps.length === 0) { + return ( +
+
{row.content}
+ +
+ ); + } + 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} + +
+ ); + } + 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} - {inspectedStep ? ( - - ) : 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} +
); } From ee29bccdbccd86d6bec14a0f3b1bbb5f949c1bce Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:40:28 +0800 Subject: [PATCH 28/29] docs(usage): the session context sidebar Refs docs-local/issues/#21. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- docs/usage-cn.md | 1 + docs/usage.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/usage-cn.md b/docs/usage-cn.md index 352dd1ff0..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(给集成方) diff --git a/docs/usage.md b/docs/usage.md index 52ef012a1..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 From d988bf92f98b7f47c4873ebc58216485b10f35bf Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:04:55 +0800 Subject: [PATCH 29/29] =?UTF-8?q?release:=20v0.13.0=20=E2=80=94=20the=20se?= =?UTF-8?q?ssion=20context=20sidebar,=20the=20two-layer=20step=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship gate for the post-v0.12.0 cycle (34 commits since the tag). New user-facing capability (minor bump): the web console shows what a session's context window is made of, and every step of a turn opens to its full thinking or tool input and output. README gains both in the console list; docs/usage*.md, docs/orchestration*.md and docs/mcp*.md were updated in their own commits. Version lockstep: workspace `Cargo.toml` 0.12.0 -> 0.13.0 (8 workspace crate lines in `Cargo.lock`, no full re-resolve); `@ccteam/ccteam-ui` version + `ccteam.engine` + the four `@ccteam/engine-*` optionalDependencies + lockfile + `PACKAGE_VERSION` / `ENGINE_VERSION`; embedded `ccteam-ui.tgz` repacked, two-path reproducible. Contents: - local #21 context sidebar: vendor-neutral `ContextBreakdown`, Claude stream-json keeps its turn-end `get_context_usage` answer whole as `chat//context.json`; `GET /api/v1/sessions/{sid}/context` (live or snapshot); a docked right sidebar with refresh and turn-end auto-refresh. - Two-layer step view: tool items carry their output across adapters, the gateway records each finished step in `chat//steps.jsonl`, served by `GET /api/v1/sessions/{sid}/steps/{step_id}`; tenants get the admin's gateway control face; SPA assets embedded in debug binaries. - Restart recovery and gateway resilience: one bad event or panic no longer stops delivery or a long-lived task; bodies left over from a restart are bounded, owned and ended honestly; outbox replay gives up; mcp.sock opens after the web /mcp listener; costs that grew with lifetime stay bounded. - Project creation writes no Claude hooks; re-init strips old ones. - A2A: one completion per parent per turn; a silent turn settles its requests. - build: `fetch_update` -> `try_update` for rustc 1.99 clippy. Local gate (rustc 1.99.1): fmt clean, clippy 0 warnings, test-baseline 2545/0 (up from 2492), SPA 850/850, plugin 289/289. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- Cargo.lock | 16 +++++++------- Cargo.toml | 2 +- README.md | 3 ++- .../execution/dsh_acp/assets/ccteam-ui.tgz | Bin 253524 -> 253523 bytes dsh-plugins/ccteam-ui/package-lock.json | 20 +++++++++--------- dsh-plugins/ccteam-ui/package.json | 12 +++++------ dsh-plugins/ccteam-ui/src/index.ts | 4 ++-- 7 files changed, 29 insertions(+), 28 deletions(-) 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 aceb4cc55..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 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 1277149e6b477ca10b01798677c245735a2a7ee9..70d83063b77dc57d590b0f46b95c4308b2d847b5 100644 GIT binary patch delta 143567 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

YNE?3*3aRlx%SZBOk#> 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%0DaIp)fN?~!GX}AS67lR5O`n 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 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 { /**