1473 lines
53 KiB
Markdown
1473 lines
53 KiB
Markdown
# 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`):
|
|
|
|
```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<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:
|
|
|
|
```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 <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:
|
|
|
|
```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<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:
|
|
|
|
```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<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**
|
|
|
|
```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 <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`:
|
|
|
|
```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<Arc<Mutex<Vec<String>>>>,` then `}`) to:
|
|
|
|
```rust
|
|
#[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`):
|
|
|
|
```rust
|
|
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:
|
|
|
|
```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 <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:
|
|
|
|
```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 <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):
|
|
|
|
```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<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`):
|
|
|
|
```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 <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):
|
|
|
|
```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 <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`:
|
|
|
|
```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<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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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`):
|
|
|
|
```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 <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`):
|
|
|
|
```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 <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
|