# Hive-Mind & Subagent Orchestration Fixes Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix eight concrete correctness/robustness gaps found in `hive_mind.rs`, `division.rs`, `auto.rs`, `tool/workflow.rs`, and `view/workflow.rs`/`actions/mod.rs`: missing node timeouts, a lost audit trail on synthesis failure, an unabortable manual `hive_mind` tool call, a hardcoded concurrency cap, a fragile message-count gate on the auto-pipeline, a stale TUI roster bug, uncancellable/overlapping background subagents with a buggy path filter, and missing regression tests. **Architecture:** No new modules or abstractions. Each fix is a targeted change to existing functions, following the codebase's existing conventions (`Settings::load()` called directly, no DI, `anyhow::Result` throughout, tests as inline `#[cfg(test)] mod tests` blocks). **Tech Stack:** Rust, tokio (mpsc for event draining only — the hive-mind/subagent execution itself is `std::thread`-based), serde/serde_json, anyhow. ## Global Constraints - Follow existing doc-comment conventions from `CLAUDE.md`: every `pub fn`/`pub struct` needs a `///` doc comment covering What/Flow/Why/Return where non-trivial. - Never use `#[allow(...)]` lint-bypass attributes. - Tests are inline `#[cfg(test)] mod tests` blocks in the same file, not a separate `tests/` dir. - Run `cargo build` and `cargo test ` after every task; do not proceed to the next task on a red build. - Commit after each task with a Conventional Commits message (Bahasa Indonesia) per the `commit-convention` skill. --- ## Task 1: Add `hive_mind_node_timeout_ms` setting **Files:** - Modify: `src/model/settings.rs:29-67` - Test: `src/model/settings.rs` (new `#[cfg(test)] mod tests` block at end of file) **Interfaces:** - Produces: `Settings.hive_mind_node_timeout_ms: u64` (default `600_000`), consumed by Task 2's `run_hive_mind`. - [ ] **Step 1: Write the failing test** Add this to the end of `src/model/settings.rs` (after the closing `}` of `impl Settings`): ```rust #[cfg(test)] mod tests { use super::*; #[test] fn default_hive_mind_node_timeout_is_ten_minutes() { let settings = Settings::default(); assert_eq!(settings.hive_mind_node_timeout_ms, 600_000); } #[test] fn missing_hive_mind_node_timeout_field_falls_back_to_default() { // Simulates loading a settings.json written before this field // existed — #[serde(default = ...)] must fill it in rather than // failing the whole parse (which would silently reset every // other saved setting to default too). let old_json = r#"{ "internet_mode": "Off", "provider": "zen", "model": "deepseek-v4-flash-free", "api_keys": {}, "max_tokens": null, "temperature": null, "review_enabled": true, "review_max_lessons_per_run": 5, "adaptive_review_max_skip": 3, "verify_command": null, "verify_timeout_ms": 30000, "workflow_max_concurrency": 5, "session_archive_enabled": true, "lsp_auto_provision": true, "lsp_languages": [] }"#; let parsed: Settings = serde_json::from_str(old_json) .expect("must parse even without the new field present"); assert_eq!(parsed.hive_mind_node_timeout_ms, 600_000); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test --lib model::settings::tests` Expected: FAIL — compile error, `hive_mind_node_timeout_ms` is not a field of `Settings`. - [ ] **Step 3: Write minimal implementation** In `src/model/settings.rs`, change the `Settings` struct (lines 28-45) to add the field with a `serde(default)` fallback so old `settings.json` files on disk stay forward-compatible: ```rust #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub internet_mode: InternetMode, pub provider: String, pub model: String, pub api_keys: std::collections::HashMap, pub max_tokens: Option, pub temperature: Option, pub review_enabled: bool, pub review_max_lessons_per_run: usize, pub adaptive_review_max_skip: u32, pub verify_command: Option, pub verify_timeout_ms: u64, pub workflow_max_concurrency: usize, pub session_archive_enabled: bool, pub lsp_auto_provision: bool, pub lsp_languages: Vec, /// Wall-clock deadline for a single hive-mind processing node (cycle /// node or synthesis node). Prevents one stuck node from hanging an /// entire hive-mind convergence forever. #[serde(default = "default_hive_mind_node_timeout_ms")] pub hive_mind_node_timeout_ms: u64, } /// Default per-node timeout for hive-mind nodes: 10 minutes. fn default_hive_mind_node_timeout_ms() -> u64 { 600_000 } ``` And update `impl Default for Settings` (lines 47-67) to add the new field: ```rust impl Default for Settings { fn default() -> Self { Settings { internet_mode: InternetMode::Off, provider: "zen".to_string(), model: "deepseek-v4-flash-free".to_string(), api_keys: std::collections::HashMap::new(), max_tokens: None, temperature: None, review_enabled: true, review_max_lessons_per_run: 5, adaptive_review_max_skip: 3, verify_command: None, verify_timeout_ms: 30000, workflow_max_concurrency: 5, session_archive_enabled: true, lsp_auto_provision: true, lsp_languages: Vec::new(), hive_mind_node_timeout_ms: default_hive_mind_node_timeout_ms(), } } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cargo test --lib model::settings::tests` Expected: PASS (2 tests) - [ ] **Step 5: Commit** ```bash git add src/model/settings.rs git commit -m "$(cat <<'EOF' feat(settings): tambah hive_mind_node_timeout_ms dengan fallback serde default Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 2: Wire per-node timeout, settings-driven concurrency, and guaranteed convergence documentation into `run_hive_mind` **Files:** - Modify: `src/app/workflow/hive_mind.rs:93-233` - Test: existing `#[cfg(test)] mod tests` block in the same file (lines 283-374) — no new tests added here (the change is only exercisable end-to-end via a live LLM call; verified by the existing empty-plan/abort-preset tests still passing plus `cargo build`). **Interfaces:** - Consumes: `Settings::load()` → `.hive_mind_node_timeout_ms: u64`, `.workflow_max_concurrency: usize` (Task 1). - Produces: `run_hive_mind`'s public signature is unchanged (still `(user_request, plan, session_dir, workspaces, turn_events, abort_flag) -> anyhow::Result<(String, Vec)>`), but it now **always** writes the `docs/runs/*.md` convergence file itself before returning (success or failure) — callers in Task 4 must stop writing it themselves. - [ ] **Step 1: Run the existing tests to confirm current green baseline** Run: `cargo test --lib app::workflow::hive_mind::tests` Expected: PASS (all existing tests green before this change) - [ ] **Step 2: Rewrite `run_hive_mind`** Replace the whole `run_hive_mind` function body (`src/app/workflow/hive_mind.rs:93-192`, from the doc comment starting `/// Run a hive-mind...` through the closing `}` of the function) with: ```rust /// Run a hive-mind: a Core-Intelligence-authored plan of cognitive cycles, /// where every node's complete output merges into a single collective /// state the instant it finishes, and a final synthesis node reconciles /// the whole collective state into one consensus assessment. /// /// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent` /// per directive, tagged with a system-assigned `node_id` (never an /// LLM-authored name) → run them as a `Parallel` block via /// `execute_primitive`, which merges each node's output into the shared /// collective-state Arc the instant that node completes, not after the /// whole cohort finishes → record `NodeReport`s → proceed to the next /// cycle. After all cycles: spawn one more read-only synthesis node whose /// directive is to reconcile the complete collective state into a single /// consensus, not list what each node said. /// /// Concurrency per cycle and the per-node timeout both come from /// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`) /// rather than a hardcoded cap/no-timeout — a stuck node can no longer hang /// the whole convergence forever. /// /// Return: `(consensus, all_node_reports)` on success. `consensus` is the /// synthesis node's reconciled output — what the Core Intelligence /// actually receives. `all_node_reports` is the complete per-node record. /// /// The convergence doc under `docs/runs/*.md` is written unconditionally /// before this function returns — even when synthesis itself fails — so a /// synthesis-node error never discards the work already done by cycle /// nodes. Callers must not write their own copy of this doc. pub fn run_hive_mind( user_request: &str, plan: &CognitiveCyclePlan, session_dir: &std::path::Path, workspaces: &[std::path::PathBuf], turn_events: Option<&Arc>>>, abort_flag: Option<&Arc>, ) -> anyhow::Result<(String, Vec)> { if plan.cycles.is_empty() { anyhow::bail!("cognitive cycle plan has no cycles"); } let settings = crate::model::settings::Settings::load(); let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms); let max_cycle_concurrency = settings.workflow_max_concurrency.max(1); let live = build_live(turn_events); let collective_state: Arc>> = Arc::new(Mutex::new(Vec::new())); let args: HashMap = HashMap::new(); let mut reports: Vec = Vec::new(); let abort_owned: Option> = abort_flag.cloned(); for (cycle_index, directives) in plan.cycles.iter().enumerate() { if directives.is_empty() { continue; } if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) { anyhow::bail!("hive-mind aborted by user before cycle {cycle_index}"); } let node_ids: Vec = (0..directives.len()) .map(|i| format!("Node-{cycle_index}-{i}")) .collect(); let nodes: Vec = directives.iter().zip(node_ids.iter()).map(|(d, node_id)| { ScriptPrimitive::ScopedAgent { prompt: format!( "You are {node_id}, a processing node of a distributed machine \ intelligence.\n\n\ Directive: {}\n\n\ Overall task: {user_request}\n\n\ Collective state accumulated so far:\n{{{{findings}}}}", d.directive, ), node_id: node_id.clone(), tool_scope: d.access.clone(), } }).collect(); let cycle_primitive = ScriptPrimitive::Phase { name: format!("cycle-{cycle_index}"), script: Box::new(ScriptPrimitive::Parallel(nodes)), }; let results = execute_primitive( &cycle_primitive, &args, directives.len().clamp(1, max_cycle_concurrency), true, &abort_owned, live.as_ref(), session_dir, workspaces, &collective_state, node_timeout_ms, )?; // engine::execute_primitive's ScopedAgent arm already merged each // node's output into `collective_state` the instant that node // completed (not after this whole cycle finished) — here we only // need the results to build the durable NodeReport record. for (node_id, output) in node_ids.iter().zip(results.iter()) { reports.push(NodeReport { node_id: node_id.clone(), cycle_index, output: output.clone(), }); } } let consensus_result = synthesize_consensus( user_request, session_dir, workspaces, &collective_state, live.as_ref(), abort_flag, node_timeout_ms, ); // Guaranteed documentation: write the convergence doc for whatever // reports/consensus we actually have, whether synthesis succeeded or // failed. A synthesis-node failure must not silently discard every // completed cycle node's work — this is the durable audit trail // CLAUDE.md promises for every convergence. let doc_consensus = match &consensus_result { Ok(c) => c.clone(), Err(e) => format!( "Synthesis failed: {e}. See individual node reports above for partial results.", ), }; if let Some(workspace_root) = workspaces.first() { match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &doc_consensus) { Ok(path) => tracing::info!("[hive-mind] convergence documented at {}", path.display()), Err(e) => tracing::warn!("[hive-mind] failed to write docs/runs report: {e}"), } } let consensus = consensus_result?; Ok((consensus, reports)) } ``` - [ ] **Step 3: Update `synthesize_consensus` to accept and propagate the node timeout** Replace the `synthesize_consensus` function (`src/app/workflow/hive_mind.rs:194-233` in the original file) with: ```rust /// Spawn a single read-only synthesis node that reads the complete /// collective state and reconciles it into one consensus assessment. /// /// Why a real node instead of string concatenation: the collective state /// may contain overlapping or conflicting node outputs (e.g. two nodes /// investigating the same file from different angles) — only genuine /// reasoning can reconcile that into a coherent answer; deterministic /// formatting can only concatenate, not resolve conflicts. /// /// `node_timeout_ms` is forwarded from `run_hive_mind`'s `Settings::load()` /// read so the synthesis node is bound by the same deadline as cycle nodes. /// /// Return: the synthesis node's reconciled consensus text. fn synthesize_consensus( user_request: &str, session_dir: &std::path::Path, workspaces: &[std::path::PathBuf], collective_state: &Arc>>, live: Option<&LiveStateFn>, abort_flag: Option<&Arc>, node_timeout_ms: Option, ) -> anyhow::Result { let synthesis = ScriptPrimitive::ScopedAgent { prompt: format!( "You are the synthesis process of a distributed machine intelligence. \ All processing nodes for the following task have completed and \ merged their output into the collective state below.\n\n\ Task: {user_request}\n\n\ Complete collective state:\n{{{{findings}}}}\n\n\ Produce ONE reconciled consensus assessment. Do not list what each \ node said — resolve any overlapping or conflicting node output into \ a single coherent answer for the task above." ), node_id: "Synthesis".to_string(), tool_scope: crate::app::subagent::division::tool_scope::READ.to_string(), }; let args: HashMap = HashMap::new(); let abort_owned: Option> = abort_flag.cloned(); let results = execute_primitive( &synthesis, &args, 1, false, &abort_owned, live, session_dir, workspaces, collective_state, node_timeout_ms, )?; Ok(results.into_iter().next().unwrap_or_default()) } ``` - [ ] **Step 4: Run tests to verify nothing broke** Run: `cargo test --lib app::workflow::hive_mind::tests` Expected: PASS (all existing tests, unchanged — the empty-plan and pre-aborted-flag tests both bail before reaching the new settings/doc-write code) Also run: `cargo build` to confirm `tool/workflow.rs` and `actions/mod.rs` (which currently double-write the doc — fixed in Task 4) still compile; a stray unused-`reports`-shadow warning there is expected until Task 4. - [ ] **Step 5: Commit** ```bash git add src/app/workflow/hive_mind.rs git commit -m "$(cat <<'EOF' fix(hive-mind): tambah timeout per-node dan jamin dokumentasi convergence tetap tertulis saat sintesis gagal Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 3: Add `abort_flag` to `ToolCtx` and wire it from session state **Files:** - Modify: `src/tool/mod.rs:1-140` - Modify: `src/app/state/rest.rs:267-288` - Test: new `#[cfg(test)] mod tests` at the end of `src/tool/mod.rs`, and a new `#[cfg(test)] mod tests` at the end of `src/app/state/rest.rs` **Interfaces:** - Produces: `ToolCtx.abort_flag: Option>`, populated by `AppStateRest::tool_ctx_for` with the session's `abort_flag`. Consumed by Task 4's `tool/workflow.rs` change. - [ ] **Step 1: Write the failing tests** Append to `src/tool/mod.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn tool_ctx_builder_defaults_abort_flag_to_none() { let ctx = ToolCtx::builder().build(); assert!(ctx.abort_flag.is_none()); } } ``` Append to `src/app/state/rest.rs` (end of file, after the closing `}` of `impl AppStateRest`): ```rust #[cfg(test)] mod tests { use super::*; #[test] fn tool_ctx_for_shares_the_session_abort_flag() { let tmp = std::env::temp_dir().join(format!("zesdex-rest-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&tmp).unwrap(); let state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory")); let ctx = state.tool_ctx_for(Origin::Main); assert!(ctx.abort_flag.is_some()); assert!(std::sync::Arc::ptr_eq( ctx.abort_flag.as_ref().unwrap(), &state.abort_flag, )); std::fs::remove_dir_all(&tmp).ok(); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test --lib tool::tests app::state::rest::tests` Expected: FAIL — `ToolCtx` has no field `abort_flag` (compile error in both files). - [ ] **Step 3: Add the field to `ToolCtx`/`ToolCtxBuilder`** In `src/tool/mod.rs`, change the imports at the top of the file from: ```rust use std::path::PathBuf; use std::sync::{Arc, Mutex}; use serde_json::Value; use anyhow::Result; ``` to: ```rust use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::sync::atomic::AtomicBool; use serde_json::Value; use anyhow::Result; ``` Change the `ToolCtx` struct (ends with `pub workflow_findings: Option>>>,` then `}`) to: ```rust #[derive(Clone)] pub struct ToolCtx { pub workspaces: Vec, pub session_dir: PathBuf, pub memory_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: std::sync::Arc>, pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, pub lsp_manager: Arc>, pub turn_events: Option>>>, /// Ephemeral findings shared between sibling subagents in a workflow run. /// Set by the workflow engine before spawning subagents; tools like /// `note_finding` write into this vec so later pipeline stages can /// reference earlier results. `None` means "not inside a workflow" — /// `note_finding` becomes a no-op. pub workflow_findings: Option>>>, /// The current turn's abort flag, threaded through so tools that /// delegate to long-running orchestration (e.g. the `hive_mind` tool) /// can be cancelled the same way the main agent loop is. `None` when /// no turn-level abort flag is available. pub abort_flag: Option>, } ``` Change `ToolCtxBuilder`'s struct definition (add the field after `workflow_findings`): ```rust pub struct ToolCtxBuilder { pub workspaces: Vec, pub session_dir: PathBuf, pub memory_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: std::sync::Arc>, pub origin: crate::app::state::types::Origin, pub graduated_checks: Vec, pub lsp_manager: Arc>, pub turn_events: Option>>>, pub workflow_findings: Option>>>, pub abort_flag: Option>, } ``` Change `impl Default for ToolCtxBuilder` to add `abort_flag: None,` after `workflow_findings: None,`, and change `ToolCtxBuilder::build()` to add `abort_flag: self.abort_flag,` after `workflow_findings: self.workflow_findings,`. - [ ] **Step 4: Wire the session abort flag in `AppStateRest::tool_ctx_for`** In `src/app/state/rest.rs`, change `tool_ctx_for` from: ```rust pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx { crate::tool::ToolCtx { workspaces: self.workspace_roots.clone(), session_dir: self.session_dir.clone(), memory_dir: self.memory_dir.clone(), worktrees_dir: self.worktrees_dir.clone(), dir_cache: self.dir_cache.clone(), origin, graduated_checks: Vec::new(), lsp_manager: self.lsp_manager.clone(), turn_events: Some(self.turn_events.clone()), workflow_findings: None, } } ``` to: ```rust pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx { crate::tool::ToolCtx { workspaces: self.workspace_roots.clone(), session_dir: self.session_dir.clone(), memory_dir: self.memory_dir.clone(), worktrees_dir: self.worktrees_dir.clone(), dir_cache: self.dir_cache.clone(), origin, graduated_checks: Vec::new(), lsp_manager: self.lsp_manager.clone(), turn_events: Some(self.turn_events.clone()), workflow_findings: None, abort_flag: Some(self.abort_flag.clone()), } } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `cargo test --lib tool::tests app::state::rest::tests` Expected: PASS (2 tests) - [ ] **Step 6: Commit** ```bash git add src/tool/mod.rs src/app/state/rest.rs git commit -m "$(cat <<'EOF' feat(tool): tambah abort_flag ke ToolCtx dan sambungkan dari session state Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 4: Stop double-writing the convergence doc and make the manual `hive_mind` tool abortable **Files:** - Modify: `src/tool/workflow.rs:210-238` - Modify: `src/app/runtime/actions/mod.rs:1107-1122` **Interfaces:** - Consumes: `ToolCtx.abort_flag` (Task 3), `run_hive_mind`'s now-guaranteed internal doc-write (Task 2). - [ ] **Step 1: Update `HiveMind::run` in `src/tool/workflow.rs`** Replace the tail of `HiveMind::run` (from `let (consensus, reports) = ...` through the final `Ok(consensus)`) — currently: ```rust let (consensus, reports) = crate::app::workflow::hive_mind::run_hive_mind( request, &plan, &ctx.session_dir, &ctx.workspaces, ctx.turn_events.as_ref(), None, )?; if let Some(workspace_root) = ctx.workspaces.first() { if let Err(e) = crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, request, &reports, &consensus) { tracing::warn!("[hive_mind] failed to write docs/runs report: {e}"); } } Ok(consensus) ``` with: ```rust // run_hive_mind now writes the docs/runs/*.md convergence report // itself (guaranteed, even if synthesis fails) — do not write it // again here. let (consensus, _reports) = crate::app::workflow::hive_mind::run_hive_mind( request, &plan, &ctx.session_dir, &ctx.workspaces, ctx.turn_events.as_ref(), ctx.abort_flag.as_ref(), )?; Ok(consensus) ``` - [ ] **Step 2: Update the auto-pipeline call site in `src/app/runtime/actions/mod.rs`** Replace this block (currently at `src/app/runtime/actions/mod.rs:1107-1122`): ```rust Ok((consensus, reports)) => { tracing::info!("[hive-mind] convergence completed successfully"); if let Some(workspace_root) = tc.workspace_roots.first() { match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &consensus) { Ok(path) => tracing::info!("[hive-mind] convergence documented at {}", path.display()), Err(e) => tracing::warn!("[hive-mind] failed to write docs/runs report: {e}"), } } let pipeline_msg = ChatMessage::system(format!( "[Hive-Mind Consensus]\n{consensus}", )); archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg); msgs.push(pipeline_msg); ``` with: ```rust Ok((consensus, _reports)) => { // run_hive_mind already wrote docs/runs/*.md internally // (guaranteed, even on synthesis failure) — nothing to do // here besides feeding the consensus back to the LLM. tracing::info!("[hive-mind] convergence completed successfully"); let pipeline_msg = ChatMessage::system(format!( "[Hive-Mind Consensus]\n{consensus}", )); archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg); msgs.push(pipeline_msg); ``` - [ ] **Step 3: Run tests to verify nothing broke** Run: `cargo build && cargo test --lib app::workflow::hive_mind::tests app::workflow::docs::tests` Expected: PASS, no warnings about unused `reports`/`write_hive_mind_convergence` imports in `tool/workflow.rs` (check `cargo build` output for any now-unused `crate::app::workflow::docs` reference in `tool/workflow.rs` — if the `docs` module is no longer referenced anywhere else in that file, no explicit `use` existed since it was fully qualified inline, so no import to remove). - [ ] **Step 4: Commit** ```bash git add src/tool/workflow.rs src/app/runtime/actions/mod.rs git commit -m "$(cat <<'EOF' fix(hive-mind): hapus penulisan docs/runs ganda dan sambungkan abort_flag ke tool hive_mind manual Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 5: Replace the message-count pipeline gate with a content-based check **Files:** - Modify: `src/app/workflow/hive_mind.rs` (add `HIVE_MIND_CONSENSUS_TAG` const and `hive_mind_already_ran` fn, plus tests) - Modify: `src/app/runtime/actions/mod.rs:993-1011,1118-1120` **Interfaces:** - Produces: `pub const HIVE_MIND_CONSENSUS_TAG: &str` and `pub fn hive_mind_already_ran<'a>(system_message_bodies: impl Iterator) -> bool` in `hive_mind.rs`. - Consumes (in `actions/mod.rs`): replaces the `user_msg_count <= 2` heuristic. - [ ] **Step 1: Write the failing tests** Add to the `#[cfg(test)] mod tests` block in `src/app/workflow/hive_mind.rs` (inside the existing `mod tests { use super::*; ... }`, after the last existing test): ```rust #[test] fn hive_mind_already_ran_detects_prior_consensus_tag() { let bodies = vec![ "you are a helpful assistant".to_string(), format!("{HIVE_MIND_CONSENSUS_TAG}\nthe bug is a null check"), ]; assert!(hive_mind_already_ran(bodies.iter().map(std::string::String::as_str))); } #[test] fn hive_mind_already_ran_false_when_no_prior_convergence() { let bodies = vec!["you are a helpful assistant".to_string()]; assert!(!hive_mind_already_ran(bodies.iter().map(std::string::String::as_str))); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cargo test --lib app::workflow::hive_mind::tests::hive_mind_already_ran` Expected: FAIL — `hive_mind_already_ran`/`HIVE_MIND_CONSENSUS_TAG` do not exist yet (compile error). - [ ] **Step 3: Add the const and function** In `src/app/workflow/hive_mind.rs`, add this right after the `NodeReport` struct definition (after its closing `}`, before `build_live`): ```rust /// Tag prefixing the system message `run_hive_mind`'s caller pushes into /// the conversation after a successful convergence. Shared between the /// push site (`actions/mod.rs`) and `hive_mind_already_ran` below so the /// two can never drift out of sync. pub const HIVE_MIND_CONSENSUS_TAG: &str = "[Hive-Mind Consensus]"; /// Detect whether a hive-mind convergence has already run earlier in this /// conversation, by checking prior system-message bodies for the /// consensus tag. /// /// Why: gates re-triggering the Core Intelligence pipeline more than once /// per session on message *content* actually observed, rather than an /// arbitrary "first two user messages" cutoff that silently disabled the /// pipeline for any complex request phrased later in a long conversation. /// /// Return: `true` if any prior system message starts with /// `HIVE_MIND_CONSENSUS_TAG`. pub fn hive_mind_already_ran<'a>(system_message_bodies: impl Iterator) -> bool { system_message_bodies.into_iter().any(|body| body.starts_with(HIVE_MIND_CONSENSUS_TAG)) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cargo test --lib app::workflow::hive_mind::tests` Expected: PASS (all tests including the 2 new ones) - [ ] **Step 5: Use the new check in `actions/mod.rs`** Replace (currently at `src/app/runtime/actions/mod.rs:993-1011`): ```rust // ── AUTO CEO PIPELINE ── // Before the main agent starts working, check if the pipeline should run. let user_msg_count = msgs.iter() .filter(|m| matches!(m.role, crate::dto::chat::message::Role::User)) .count(); let should_pipeline = if user_msg_count <= 2 { let user_request = msgs.iter() .rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User)) .and_then(|m| m.content.as_deref()) .unwrap_or(""); if user_request.is_empty() { false } else { crate::app::workflow::hive_mind::is_complex_request(user_request) } } else { false }; ``` with: ```rust // ── AUTO CEO PIPELINE ── // Before the main agent starts working, check if the pipeline should run. // Gated on whether a hive-mind convergence has already happened earlier // in this session (detected from message content), not an arbitrary // message-count cutoff — a complex request in message 5 deserves the // same treatment as one in message 1, as long as this session hasn't // already converged once. let already_ran_hive_mind = crate::app::workflow::hive_mind::hive_mind_already_ran( msgs.iter() .filter(|m| matches!(m.role, crate::dto::chat::message::Role::System)) .filter_map(|m| m.content.as_deref()) ); let should_pipeline = if already_ran_hive_mind { false } else { let user_request = msgs.iter() .rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User)) .and_then(|m| m.content.as_deref()) .unwrap_or(""); if user_request.is_empty() { false } else { crate::app::workflow::hive_mind::is_complex_request(user_request) } }; ``` - [ ] **Step 6: Use the shared tag constant when pushing the consensus message** Replace (this line was already touched in Task 4 — apply on top of that): ```rust let pipeline_msg = ChatMessage::system(format!( "[Hive-Mind Consensus]\n{consensus}", )); ``` with: ```rust let pipeline_msg = ChatMessage::system(format!( "{}\n{consensus}", crate::app::workflow::hive_mind::HIVE_MIND_CONSENSUS_TAG, )); ``` - [ ] **Step 7: Run full build to verify nothing broke** Run: `cargo build && cargo test --lib app::workflow::hive_mind::tests` Expected: PASS, no leftover references to the removed `user_msg_count` variable. - [ ] **Step 8: Commit** ```bash git add src/app/workflow/hive_mind.rs src/app/runtime/actions/mod.rs git commit -m "$(cat <<'EOF' fix(hive-mind): ganti gerbang pipeline berbasis jumlah pesan dengan deteksi konvergensi sebelumnya Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 6: Fix the stale TUI workflow-roster clear bug **Files:** - Modify: `src/app/runtime/actions/mod.rs:935-938` (add constant), `:386-391` (clear condition), `:1019-1026` (kickoff message push) **Interfaces:** - Produces: `const HIVE_MIND_KICKOFF_NOTE: &str`, used both where the kickoff `SystemNote` is pushed and where the roster-clear condition checks it — eliminates the substring-match that currently never fires (no real pipeline message contains the word "started"). - [ ] **Step 1: Add the shared constant** In `src/app/runtime/actions/mod.rs`, right after the existing constant (currently at line 938): ```rust /// Maximum number of auto inline reviews spawned per single agent turn. /// After N edits, the inline review is skipped to keep the turn fast; /// background subagents still fire at the end of the turn. const MAX_AUTO_REVIEWS_PER_TURN: usize = 2; ``` add: ```rust /// Exact text of the "pipeline started" `SystemNote` pushed once per /// hive-mind kickoff. Matched by exact equality (not a loose substring) /// when deciding whether to reset the workflow panel's agent roster — /// shared between the push site and the check site so they cannot drift /// out of sync the way the previous `.contains("started")` check did /// (no real pipeline message ever contained that word, so the roster /// never cleared and agent cards accumulated across every hive-mind run /// in a session). const HIVE_MIND_KICKOFF_NOTE: &str = "Core Intelligence is compiling a cognitive cycle plan..."; ``` - [ ] **Step 2: Fix the clear condition** Replace (currently at `src/app/runtime/actions/mod.rs:386-391`): ```rust } else if kind == "pipeline" { // Clear old workflow agents when a new pipeline starts. if message.contains("started") { state.workflow_engine.agents.clear(); state.workflow_engine.findings.clear(); } ``` with: ```rust } else if kind == "pipeline" { // Clear old workflow agents when a new pipeline starts. if message == HIVE_MIND_KICKOFF_NOTE { state.workflow_engine.agents.clear(); state.workflow_engine.findings.clear(); } ``` - [ ] **Step 3: Use the constant at the push site** Replace (currently at `src/app/runtime/actions/mod.rs:1021-1026`): ```rust if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::SystemNote { kind: "pipeline".to_string(), message: "Core Intelligence is compiling a cognitive cycle plan...".to_string(), }); } ``` with: ```rust if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::SystemNote { kind: "pipeline".to_string(), message: HIVE_MIND_KICKOFF_NOTE.to_string(), }); } ``` - [ ] **Step 4: Verify by inspection (no automated test — this is a TUI event-handling branch inside a large match over live `AppStateRest`, not practically unit-testable in isolation without disproportionate scaffolding)** Run: `cargo build` Expected: compiles clean. Manually re-read both edited call sites (`386-391` and `1021-1026`) to confirm the string is now identical (copy-paste, not retyped) between the two. - [ ] **Step 5: Commit** ```bash git add src/app/runtime/actions/mod.rs git commit -m "$(cat <<'EOF' fix(tui): perbaiki roster workflow yang tidak pernah ter-reset karena substring "started" tidak pernah cocok Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 7: Fix `is_production_code`'s substring bug and make background subagents cancellable/non-overlapping **Files:** - Modify: `src/app/subagent/auto.rs` (whole file: imports, `is_production_code`, all three `spawn_background_*` fns, `spawn_all_background`, new tests) - Modify: `src/app/runtime/actions/mod.rs:1494-1506` (pass `tc.abort_flag` through) **Interfaces:** - Produces: `spawn_background_test_gen`/`spawn_background_arch_review`/`spawn_background_security_review`/`spawn_all_background` all gain a trailing `abort_flag: Arc` parameter. - Consumes: `tc.abort_flag` (already exists on `TurnCtx`, used elsewhere in the same file). - [ ] **Step 1: Write the failing tests** Add to the end of `src/app/subagent/auto.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn reviewable_path_skips_lockfiles_and_known_extensions() { assert!(!is_reviewable_path("Cargo.lock")); assert!(!is_reviewable_path("package.json")); assert!(!is_reviewable_path("logo.svg")); } #[test] fn reviewable_path_skips_vendored_and_generated_dirs() { assert!(!is_reviewable_path("target/debug/build.rs")); assert!(!is_reviewable_path("node_modules/foo/index.js")); } #[test] fn reviewable_path_accepts_ordinary_source_files() { assert!(is_reviewable_path("src/main.rs")); } #[test] fn production_code_excludes_dedicated_test_directories() { assert!(!is_production_code("src/tests/foo.rs")); assert!(!is_production_code("__tests__/baz.test.ts")); } #[test] fn production_code_excludes_test_filename_conventions() { assert!(!is_production_code("src/foo_test.rs")); assert!(!is_production_code("src/test_foo.py")); assert!(!is_production_code("src/foo.spec.ts")); } #[test] fn production_code_does_not_false_positive_on_substring_test() { // Regression: a plain `.contains("test")` would wrongly exclude // these legitimate production files. assert!(is_production_code("src/attestation.rs")); assert!(is_production_code("src/latest/foo.rs")); } #[test] fn production_code_requires_known_source_extension() { assert!(!is_production_code("README.md")); assert!(is_production_code("src/main.rs")); } } ``` - [ ] **Step 2: Run tests to verify the regression tests fail** Run: `cargo test --lib app::subagent::auto::tests` Expected: FAIL on `production_code_does_not_false_positive_on_substring_test` (current `.contains("test")` wrongly excludes `src/attestation.rs` and `src/latest/foo.rs`). Other tests should already pass since they don't exercise the bug. - [ ] **Step 3: Fix `is_production_code`** Replace the current function: ```rust fn is_production_code(path: &str) -> bool { let lower = path.to_lowercase(); // Skip test files — they don't need test-gen from another agent if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") { return false; } // Only source files — use Path::extension() to avoid clippy // case_sensitive_file_extension_comparisons lint std::path::Path::new(&lower) .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| { matches!( ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift" | "c" | "cpp" | "h" | "hpp" ) }) } ``` with: ```rust /// Determine whether a file change looks like it modifies production logic /// (vs. tests, config, or documentation) — used to decide if a test-gen /// or security-review background subagent should fire. /// /// Matches test-ness by path *segment* (a directory literally named /// "test"/"tests"/"__tests__") or by filename convention /// (`foo_test.rs`, `foo.test.ts`, `test_foo.py`, `foo_spec.rb`), not by a /// raw substring check — a plain `.contains("test")` would wrongly exclude /// legitimate production files like `src/attestation.rs` or /// `src/latest/foo.rs`. fn is_production_code(path: &str) -> bool { let lower = path.to_lowercase(); let path_obj = std::path::Path::new(&lower); let in_test_dir = path_obj.components().any(|c| { matches!( c, std::path::Component::Normal(seg) if matches!(seg.to_str(), Some("test") | Some("tests") | Some("__tests__")) ) }); let file_stem = path_obj.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let is_test_filename = file_stem.starts_with("test_") || file_stem.ends_with("_test") || file_stem.ends_with(".test") || file_stem == "spec" || file_stem.ends_with("_spec") || file_stem.ends_with(".spec"); if in_test_dir || is_test_filename { return false; } // Only source files — use Path::extension() to avoid clippy // case_sensitive_file_extension_comparisons lint path_obj .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| { matches!( ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift" | "c" | "cpp" | "h" | "hpp" ) }) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cargo test --lib app::subagent::auto::tests` Expected: PASS (all 7 tests) - [ ] **Step 5: Add cancellation + overlap guards** Change the imports at the top of `src/app/subagent/auto.rs` from: ```rust use std::path::Path; use std::sync::{Arc, Mutex}; use std::collections::VecDeque; ``` to: ```rust use std::path::Path; use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicBool, Ordering}; use std::collections::VecDeque; ``` Add these statics right after the `SKIP_REVIEW_FILES` constant: ```rust /// Prevents a second background subagent of the same kind from spawning /// while one is already in flight. Without this, a chatty multi-turn edit /// session could stack overlapping test-gen/arch/security reviews of /// overlapping file sets, none of which could be told apart in the /// `SystemNote` toast stream. static TEST_GEN_RUNNING: AtomicBool = AtomicBool::new(false); static ARCH_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false); static SECURITY_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false); ``` Change `run_subagent_with_retry`'s signature and body from: ```rust fn run_subagent_with_retry( def: &AgentDefinition, session_dir: &Path, workspaces: &[std::path::PathBuf], label: &str, ) -> Result { let mut last_err = String::new(); for attempt in 1..=2 { let mut ctx = build_subagent_context(def); ctx.session_dir = session_dir.to_path_buf(); ctx.workspaces = workspaces.to_vec(); ``` to: ```rust fn run_subagent_with_retry( def: &AgentDefinition, session_dir: &Path, workspaces: &[std::path::PathBuf], label: &str, abort_flag: Option<&Arc>, ) -> Result { let mut last_err = String::new(); for attempt in 1..=2 { if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) { return Err("aborted by user".to_string()); } let mut ctx = build_subagent_context(def); ctx.session_dir = session_dir.to_path_buf(); ctx.workspaces = workspaces.to_vec(); ctx.abort_flag = abort_flag.cloned(); ``` (the rest of the function body — the `tokio::sync::mpsc::channel`, drain thread, and `match run_subagent(&ctx, &tx)` — stays unchanged). Change `spawn_background_test_gen` from: ```rust pub fn spawn_background_test_gen( file_paths: &[String], session_dir: &Path, workspaces: &[std::path::PathBuf], turn_events: &Arc>>, ) { if file_paths.is_empty() { return; } let paths = file_paths.to_vec(); let sd = session_dir.to_path_buf(); let ws = workspaces.to_vec(); let events = turn_events.clone(); std::thread::spawn(move || { tracing::info!( "[bg-test-gen] spawning for {} file(s): {:?}", paths.len(), paths, ); let file_list = paths.join("\n"); let prompt = format!( "{}\n\nModified files that need tests:\n{}", crate::resources::TEST_GENERATOR_PROMPT, file_list, ); let def = AgentDefinition::new( "test-generator".to_string(), "coder".to_string(), // needs write access ) .with_system_prompt(prompt) ; let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen"); let message = match &result { Ok(output) => { let first = output.lines().next().unwrap_or(output); format!("Auto test-gen: {first}") } Err(e) => format!("ESCALATED: Auto test-gen {e}"), }; if let Ok(mut q) = events.lock() { q.push_back(TurnEvent::SystemNote { kind: "bg-test-gen".to_string(), message, }); } }); } ``` to: ```rust pub fn spawn_background_test_gen( file_paths: &[String], session_dir: &Path, workspaces: &[std::path::PathBuf], turn_events: &Arc>>, abort_flag: Arc, ) { if file_paths.is_empty() { return; } if TEST_GEN_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { tracing::debug!("[bg-test-gen] skipped — a test-gen run is already in flight"); return; } let paths = file_paths.to_vec(); let sd = session_dir.to_path_buf(); let ws = workspaces.to_vec(); let events = turn_events.clone(); std::thread::spawn(move || { tracing::info!( "[bg-test-gen] spawning for {} file(s): {:?}", paths.len(), paths, ); let file_list = paths.join("\n"); let prompt = format!( "{}\n\nModified files that need tests:\n{}", crate::resources::TEST_GENERATOR_PROMPT, file_list, ); let def = AgentDefinition::new( "test-generator".to_string(), "coder".to_string(), // needs write access ) .with_system_prompt(prompt) ; let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag)); let message = match &result { Ok(output) => { let first = output.lines().next().unwrap_or(output); format!("Auto test-gen: {first}") } Err(e) if e.contains("aborted") => format!("Auto test-gen cancelled: {e}"), Err(e) => format!("ESCALATED: Auto test-gen {e}"), }; if let Ok(mut q) = events.lock() { q.push_back(TurnEvent::SystemNote { kind: "bg-test-gen".to_string(), message, }); } TEST_GEN_RUNNING.store(false, Ordering::SeqCst); }); } ``` Apply the identical pattern to `spawn_background_arch_review` (using `ARCH_REVIEW_RUNNING`, label `"bg-arch-review"`, message prefixes `"Architecture review: "` / `"ESCALATED: Architecture review "` / cancelled variant `"Architecture review cancelled: "`) and `spawn_background_security_review` (using `SECURITY_REVIEW_RUNNING`, label `"bg-security-review"`, message prefixes `"Security review: "` / `"ESCALATED: Security review "` / cancelled variant `"Security review cancelled: "`) — same added parameter, same `compare_exchange` guard at the top (after the existing `if file_paths.is_empty()` / `if prod_paths.is_empty()` early-returns), same `Some(&abort_flag)` passed to `run_subagent_with_retry`, same `TEST_GEN_RUNNING`-style flag reset at the end of the closure using each function's own static. Change `spawn_all_background` from: ```rust pub fn spawn_all_background( file_paths: &[String], session_dir: &Path, workspaces: &[std::path::PathBuf], turn_events: &Arc>>, ) { if file_paths.is_empty() { return; } // Background test-gen: only for non-test source files let source_paths: Vec = file_paths .iter() .filter(|p| is_production_code(p)) .cloned() .collect(); spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events); // Background arch review: for all files that are reviewable let reviewable: Vec = file_paths .iter() .filter(|p| is_reviewable_path(p)) .cloned() .collect(); spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events); // Background security review: only production source files spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events); } ``` to: ```rust pub fn spawn_all_background( file_paths: &[String], session_dir: &Path, workspaces: &[std::path::PathBuf], turn_events: &Arc>>, abort_flag: Arc, ) { if file_paths.is_empty() { return; } // Background test-gen: only for non-test source files let source_paths: Vec = file_paths .iter() .filter(|p| is_production_code(p)) .cloned() .collect(); spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events, abort_flag.clone()); // Background arch review: for all files that are reviewable let reviewable: Vec = file_paths .iter() .filter(|p| is_reviewable_path(p)) .cloned() .collect(); spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events, abort_flag.clone()); // Background security review: only production source files spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events, abort_flag); } ``` - [ ] **Step 6: Wire the caller in `actions/mod.rs`** Replace (currently at `src/app/runtime/actions/mod.rs:1494-1506`): ```rust // ── Background auto-subagents ── if !bg_paths.is_empty() { let bg_session_dir = tc.edit_log_session_dir.clone(); let bg_workspaces = tc.workspace_roots.clone(); let bg_events = events_q.clone(); std::thread::spawn(move || { crate::app::subagent::auto::spawn_all_background( &bg_paths, &bg_session_dir, &bg_workspaces, &bg_events, ); }); } ``` with: ```rust // ── Background auto-subagents ── if !bg_paths.is_empty() { let bg_session_dir = tc.edit_log_session_dir.clone(); let bg_workspaces = tc.workspace_roots.clone(); let bg_events = events_q.clone(); let bg_abort = tc.abort_flag.clone(); std::thread::spawn(move || { crate::app::subagent::auto::spawn_all_background( &bg_paths, &bg_session_dir, &bg_workspaces, &bg_events, bg_abort, ); }); } ``` - [ ] **Step 7: Run full test suite for the module and build** Run: `cargo build && cargo test --lib app::subagent::auto::tests` Expected: PASS (7 tests), clean build (no unused-variable warnings for `abort_flag` in any of the three spawn functions). - [ ] **Step 8: Commit** ```bash git add src/app/subagent/auto.rs src/app/runtime/actions/mod.rs git commit -m "$(cat <<'EOF' fix(subagent): perbaiki filter is_production_code berbasis substring dan tambah pembatalan/anti-tumpang-tindih pada background review Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Task 8: Add a tool-scope tier invariant test **Files:** - Modify: `src/app/subagent/division.rs:58-91` (existing `#[cfg(test)] mod tests` block) **Interfaces:** - Consumes: only the existing public `tools_for`/`READ`/`WRITE`/`FULL` — no production code changes in this task. - [ ] **Step 1: Write the failing test** Add to the existing `mod tests` block in `src/app/subagent/division.rs` (after `unknown_scope_falls_back_to_read`): ```rust #[test] fn read_tier_is_subset_of_write_tier_and_write_is_subset_of_full() { use std::collections::HashSet; let read: HashSet<_> = tools_for(READ).into_iter().collect(); let write: HashSet<_> = tools_for(WRITE).into_iter().collect(); let full: HashSet<_> = tools_for(FULL).into_iter().collect(); assert!(read.is_subset(&write), "read tier must be a subset of write tier"); assert!(write.is_subset(&full), "write tier must be a subset of full tier"); } ``` - [ ] **Step 2: Run test to verify it passes immediately** Run: `cargo test --lib app::subagent::division::tests` Expected: PASS (5 tests) — the current tier lists already satisfy the invariant; this test is a regression guard against a future edit accidentally breaking it (e.g. adding a tool to `WRITE_TOOLS` without also adding it to `FULL_TOOLS`). - [ ] **Step 3: Commit** ```bash git add src/app/subagent/division.rs git commit -m "$(cat <<'EOF' test(subagent): tambah pengujian invarian read⊆write⊆full pada tool_scope Co-Authored-By: Claude Sonnet 5 EOF )" ``` --- ## Final Verification - [ ] Run the full test suite: `cargo test --lib` - [ ] Run `cargo build --release` to confirm a clean release build - [ ] Skim `git log --oneline -8` to confirm all 8 commits landed with the expected messages