Files
zesdex/docs/superpowers/plans/2026-07-14-hive-mind-subagent-fixes.md
T

53 KiB

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 <module_path> 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):

#[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:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
    pub internet_mode: InternetMode,
    pub provider: String,
    pub model: String,
    pub api_keys: std::collections::HashMap<String, String>,
    pub max_tokens: Option<u32>,
    pub temperature: Option<f32>,
    pub review_enabled: bool,
    pub review_max_lessons_per_run: usize,
    pub adaptive_review_max_skip: u32,
    pub verify_command: Option<String>,
    pub verify_timeout_ms: u64,
    pub workflow_max_concurrency: usize,
    pub session_archive_enabled: bool,
    pub lsp_auto_provision: bool,
    pub lsp_languages: Vec<String>,
    /// 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:

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
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 <noreply@anthropic.com>
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<NodeReport>)>), 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:

/// 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<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
    abort_flag: Option<&Arc<AtomicBool>>,
) -> anyhow::Result<(String, Vec<NodeReport>)> {
    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<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
    let args: HashMap<String, String> = HashMap::new();
    let mut reports: Vec<NodeReport> = Vec::new();
    let abort_owned: Option<Arc<AtomicBool>> = 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<String> = (0..directives.len())
            .map(|i| format!("Node-{cycle_index}-{i}"))
            .collect();

        let nodes: Vec<ScriptPrimitive> = 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:

/// 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<Mutex<Vec<String>>>,
    live: Option<&LiveStateFn>,
    abort_flag: Option<&Arc<AtomicBool>>,
    node_timeout_ms: Option<u64>,
) -> anyhow::Result<String> {
    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<String, String> = HashMap::new();
    let abort_owned: Option<Arc<AtomicBool>> = 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
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 <noreply@anthropic.com>
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<Arc<AtomicBool>>, 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:

#[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):

#[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:

use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use anyhow::Result;

to:

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<Arc<Mutex<Vec<String>>>>, then }) to:

#[derive(Clone)]
pub struct ToolCtx {
    pub workspaces: Vec<PathBuf>,
    pub session_dir: PathBuf,
    pub memory_dir: PathBuf,
    pub worktrees_dir: PathBuf,
    pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
    pub origin: crate::app::state::types::Origin,
    pub graduated_checks: Vec<GraduatedCheck>,
    pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
    pub turn_events: Option<Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
    /// 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<Arc<Mutex<Vec<String>>>>,
    /// 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<Arc<AtomicBool>>,
}

Change ToolCtxBuilder's struct definition (add the field after workflow_findings):

pub struct ToolCtxBuilder {
    pub workspaces: Vec<PathBuf>,
    pub session_dir: PathBuf,
    pub memory_dir: PathBuf,
    pub worktrees_dir: PathBuf,
    pub dir_cache: std::sync::Arc<tokio::sync::RwLock<super::app::state::misc::DirCache>>,
    pub origin: crate::app::state::types::Origin,
    pub graduated_checks: Vec<GraduatedCheck>,
    pub lsp_manager: Arc<Mutex<crate::app::lsp::LspManager>>,
    pub turn_events: Option<Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
    pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
    pub abort_flag: Option<Arc<AtomicBool>>,
}

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:

    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:

    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
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 <noreply@anthropic.com>
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:

        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:

        // 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):

            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:

            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
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 <noreply@anthropic.com>
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<Item = &'a str>) -> 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):

    #[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):

/// 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<Item = &'a str>) -> 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):

    // ── 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:

    // ── 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):

                let pipeline_msg = ChatMessage::system(format!(
                    "[Hive-Mind Consensus]\n{consensus}",
                ));

with:

                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
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 <noreply@anthropic.com>
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):

/// 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:

/// 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):

                        } 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:

                        } 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):

        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:

        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
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 <noreply@anthropic.com>
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<AtomicBool> 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:

#[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:

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:

/// 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:

use std::path::Path;
use std::sync::{Arc, Mutex};
use std::collections::VecDeque;

to:

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:

/// 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:

fn run_subagent_with_retry(
    def: &AgentDefinition,
    session_dir: &Path,
    workspaces: &[std::path::PathBuf],
    label: &str,
) -> Result<String, String> {
    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:

fn run_subagent_with_retry(
    def: &AgentDefinition,
    session_dir: &Path,
    workspaces: &[std::path::PathBuf],
    label: &str,
    abort_flag: Option<&Arc<AtomicBool>>,
) -> Result<String, String> {
    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:

pub fn spawn_background_test_gen(
    file_paths: &[String],
    session_dir: &Path,
    workspaces: &[std::path::PathBuf],
    turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
) {
    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:

pub fn spawn_background_test_gen(
    file_paths: &[String],
    session_dir: &Path,
    workspaces: &[std::path::PathBuf],
    turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
    abort_flag: Arc<AtomicBool>,
) {
    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:

pub fn spawn_all_background(
    file_paths: &[String],
    session_dir: &Path,
    workspaces: &[std::path::PathBuf],
    turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
) {
    if file_paths.is_empty() {
        return;
    }

    // Background test-gen: only for non-test source files
    let source_paths: Vec<String> = 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<String> = 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:

pub fn spawn_all_background(
    file_paths: &[String],
    session_dir: &Path,
    workspaces: &[std::path::PathBuf],
    turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
    abort_flag: Arc<AtomicBool>,
) {
    if file_paths.is_empty() {
        return;
    }

    // Background test-gen: only for non-test source files
    let source_paths: Vec<String> = 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<String> = 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):

        // ── 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:

        // ── 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
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 <noreply@anthropic.com>
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):

    #[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
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 <noreply@anthropic.com>
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