From 6790fe481b0c9d8296d830a6509132b3e2534858 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 14 Jul 2026 09:24:31 +0700 Subject: [PATCH] docs: tambah rencana implementasi perbaikan hive-mind dan subagent Co-Authored-By: Claude Sonnet 5 --- .../2026-07-14-hive-mind-subagent-fixes.md | 1472 +++++++++++++++++ 1 file changed, 1472 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md diff --git a/docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md b/docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md new file mode 100644 index 0000000..6258b80 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md @@ -0,0 +1,1472 @@ +# 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