From 70f53d3c229beed019811d900a8d9d7be854361d Mon Sep 17 00:00:00 2001 From: David Hagerty Date: Thu, 12 Feb 2026 21:22:53 -0500 Subject: [PATCH] feat(agent): add previous_attempt and dependency_statuses to AgentContext Co-Authored-By: Claude Opus 4.6 --- .../2026-02-12-v2-phase5/phase_01.md | 116 ++++++++ .../2026-02-12-v2-phase5/phase_02.md | 192 ++++++++++++ .../2026-02-12-v2-phase5/phase_03.md | 257 ++++++++++++++++ .../2026-02-12-v2-phase5/phase_04.md | 204 +++++++++++++ .../2026-02-12-v2-phase5/phase_05.md | 191 ++++++++++++ .../2026-02-12-v2-phase5/phase_06.md | 277 ++++++++++++++++++ .../2026-02-12-v2-phase5/test-requirements.md | 192 ++++++++++++ src/agent/mod.rs | 8 + src/agent/orchestrator.rs | 102 +++---- src/agent/runtime.rs | 10 +- src/context/agents_md.rs | 10 +- src/context/mod.rs | 2 + src/daemon/api/agents.rs | 2 +- src/daemon/api/graph.rs | 36 +-- src/daemon/api/mod.rs | 2 +- src/daemon/api/projects.rs | 9 +- src/daemon/api/search.rs | 2 +- src/daemon/client.rs | 2 +- src/daemon/server.rs | 22 +- src/daemon/ws.rs | 7 +- src/main.rs | 184 +++++------- src/message.rs | 5 +- tests/agent_runtime_test.rs | 2 + tests/agent_tools_test.rs | 25 +- tests/agent_types_test.rs | 4 + tests/daemon_api_test.rs | 3 +- tests/daemon_client_test.rs | 12 +- tests/daemon_graph_api_test.rs | 13 +- tests/daemon_static_test.rs | 8 +- tests/daemon_test.rs | 5 +- tests/graph_tools_test.rs | 11 +- tests/orchestrator_test.rs | 13 +- tests/work_package_test.rs | 20 +- tests/worktree_test.rs | 3 +- 34 files changed, 1676 insertions(+), 275 deletions(-) create mode 100644 docs/implementation-plans/2026-02-12-v2-phase5/phase_01.md create mode 100644 docs/implementation-plans/2026-02-12-v2-phase5/phase_02.md create mode 100644 docs/implementation-plans/2026-02-12-v2-phase5/phase_03.md create mode 100644 docs/implementation-plans/2026-02-12-v2-phase5/phase_04.md create mode 100644 docs/implementation-plans/2026-02-12-v2-phase5/phase_05.md create mode 100644 docs/implementation-plans/2026-02-12-v2-phase5/phase_06.md create mode 100644 docs/implementation-plans/2026-02-12-v2-phase5/test-requirements.md diff --git a/docs/implementation-plans/2026-02-12-v2-phase5/phase_01.md b/docs/implementation-plans/2026-02-12-v2-phase5/phase_01.md new file mode 100644 index 0000000..8b00e74 --- /dev/null +++ b/docs/implementation-plans/2026-02-12-v2-phase5/phase_01.md @@ -0,0 +1,116 @@ +# V2 Phase 5 - AGENTS.md Parser Enhancement + +**Goal:** Enhance the AGENTS.md parser to count content lines per heading section and make `resolve_agents_md` return relative paths from the project root. + +**Architecture:** The existing `src/context/agents_md.rs` already handles hierarchy walking and heading extraction. This phase adds per-heading content line counting (for `{rule_count} rules` in context summaries) and switches path output from absolute to project-relative. The `ReadAgentsMdTool` in `src/context/mod.rs` is enhanced to accept a directory path and auto-locate the AGENTS.md within it. + +**Tech Stack:** Rust (standard library Path operations, no new dependencies) + +**Scope:** 1 of 6 phases from original design (Phase 5, item 1) + +**Codebase verified:** 2026-02-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### v2-phase5.AC1: AGENTS.md Resolution +- **v2-phase5.AC1.1 Success:** `resolve_agents_md` returns headings with line counts for each AGENTS.md in the hierarchy, ordered closest-to-file first +- **v2-phase5.AC1.2 Success:** Returned paths are relative to the project root (e.g., `src/auth/AGENTS.md`), not absolute +- **v2-phase5.AC1.3 Success:** Deduplication still works — same AGENTS.md is not returned twice when multiple files in scope share it + +### v2-phase5.AC2: ReadAgentsMdTool Enhancement +- **v2-phase5.AC2.1 Success:** `read_agents_md` tool accepts a directory path (e.g., `src/auth`) and reads the AGENTS.md within that directory +- **v2-phase5.AC2.2 Success:** `read_agents_md` tool still accepts a full file path (e.g., `src/auth/AGENTS.md`) for backward compatibility + +--- + + + + +### Task 1: Enhance heading extraction with content line counts + +**Verifies:** v2-phase5.AC1.1 + +**Files:** +- Modify: `src/context/agents_md.rs:60-72` (replace `extract_headings` with `extract_heading_summaries`) + +**Implementation:** + +Replace the `extract_headings` function with `extract_heading_summaries` that returns `Vec<(String, usize)>` — each entry is `(heading_text, line_count)` where `line_count` is the number of non-empty content lines under that heading (until the next heading of same or higher level, or EOF). + +Algorithm: +1. Split file content into lines +2. Walk lines: detect headings as lines starting with `# ` (single `#` followed by a space — top-level headings only, matching the existing `extract_headings` behavior at `agents_md.rs:63`). Do NOT match `##` or deeper headings. +3. For each heading, count subsequent non-empty, non-heading lines until the next `# ` line or EOF +4. Return `Vec<(String, usize)>` + +Update `resolve_agents_md` to use `extract_heading_summaries` and format the summary as `"Heading1 (N lines), Heading2 (M lines)"`. + +**Testing:** +Tests must verify: +- v2-phase5.AC1.1: Multiple headings return correct line counts; headings with no content return 0 + +**Verification:** +Run: `cargo test agents_md` +Expected: All tests pass + +**Commit:** `feat(context): add content line counts to AGENTS.md heading extraction` + + + +### Task 2: Return relative paths from resolve_agents_md + +**Verifies:** v2-phase5.AC1.2, v2-phase5.AC1.3 + +**Files:** +- Modify: `src/context/agents_md.rs:14-57` (update path construction in `resolve_agents_md`) + +**Implementation:** + +In `resolve_agents_md`, after finding an AGENTS.md file, strip the `project_root` prefix from the absolute path to produce a relative path string (e.g., `src/auth/AGENTS.md` instead of `/home/user/project/src/auth/AGENTS.md`). Use `Path::strip_prefix(project_root)` and fall back to the absolute path if stripping fails. + +**Testing:** +Tests must verify: +- v2-phase5.AC1.2: Returned paths are relative (don't start with `/tmp/` or whatever tempdir prefix the test uses) +- v2-phase5.AC1.3: Deduplication still works with relative paths + +**Verification:** +Run: `cargo test agents_md` +Expected: All tests pass + +**Commit:** `feat(context): return relative paths from resolve_agents_md` + + + +### Task 3: Enhance ReadAgentsMdTool to accept directory paths + +**Verifies:** v2-phase5.AC2.1, v2-phase5.AC2.2 + +**Files:** +- Modify: `src/context/mod.rs:130-149` (update `ReadAgentsMdTool::execute`) + +**Implementation:** + +Update the `execute` method to: +1. Check if the path ends with `AGENTS.md` — if so, read it directly (backward compat, AC2.2) +2. Otherwise, treat the path as a directory and append `/AGENTS.md` to it before reading +3. Keep the existing file-name validation for the direct path case + +Update the tool description to mention it accepts either a directory path or a direct AGENTS.md path. + +**Testing:** +Tests must verify: +- v2-phase5.AC2.1: Passing `src/auth` reads `src/auth/AGENTS.md` +- v2-phase5.AC2.2: Passing `src/auth/AGENTS.md` still works + +**Verification:** +Run: `cargo test context` +Expected: All tests pass + +**Commit:** `feat(context): ReadAgentsMdTool accepts directory paths` + + + diff --git a/docs/implementation-plans/2026-02-12-v2-phase5/phase_02.md b/docs/implementation-plans/2026-02-12-v2-phase5/phase_02.md new file mode 100644 index 0000000..745700d --- /dev/null +++ b/docs/implementation-plans/2026-02-12-v2-phase5/phase_02.md @@ -0,0 +1,192 @@ +# V2 Phase 5 - ContextBuilder Enhancement + +**Goal:** Enhance the ContextBuilder to implement the full context template from the design: dependency status, previous attempt outcomes, priority-based token budgeting, and overflow trimming. + +**Architecture:** `AgentContext` gains two new fields: `previous_attempt` (optional outcome description from a prior failed run) and `dependency_statuses` (list of dependency nodes with completion status). `ContextBuilder::build_system_prompt` is updated to render these sections in the design's compact format. A new `ContextBudget` struct manages the priority-based token allocation and overflow trimming described in the architecture doc. + +**Tech Stack:** Rust (no new dependencies — token counting is approximated as `text.len() / 4`) + +**Scope:** 1 of 6 phases from original design (Phase 5, item 2) + +**Codebase verified:** 2026-02-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### v2-phase5.AC3: ContextBuilder Sections +- **v2-phase5.AC3.1 Success:** System prompt includes `[DEP:DONE]` lines for completed dependencies and `[DEP:PENDING]` lines for pending ones +- **v2-phase5.AC3.2 Success:** System prompt includes `[PREV_ATTEMPT]` section when a previous attempt outcome is present +- **v2-phase5.AC3.3 Success:** System prompt omits `## Previous Attempt` section entirely when no previous attempt exists + +### v2-phase5.AC4: Token Budget +- **v2-phase5.AC4.1 Success:** When total context exceeds the token budget, lower-priority sections are trimmed (observations first, then AGENTS.md summaries, then decisions) +- **v2-phase5.AC4.2 Success:** Required sections (Role, Task, Rules) are never trimmed regardless of budget + +--- + + + + +### Task 1: Add new fields to AgentContext + +**Verifies:** v2-phase5.AC3.1, v2-phase5.AC3.2 + +**Files:** +- Modify: `src/agent/mod.rs:54-76` (add fields to `AgentContext` struct) + +**Implementation:** + +Add two new fields to `AgentContext`: + +```rust +/// Previous attempt outcome description (if this is a retry) +pub previous_attempt: Option, + +/// Dependency statuses: (node_id, title, is_completed) +pub dependency_statuses: Vec<(String, String, bool)>, +``` + +Update the `Debug` impl to include the new fields. Update all call sites that construct `AgentContext` to pass default values (`None` and `vec![]`). + +Call sites to update (verified via grep for `AgentContext {`): +- `src/agent/orchestrator.rs:798` (in `spawn_worker_with_id`) +- `src/context/mod.rs:422` (inline test) +- `tests/agent_runtime_test.rs:15` (in `make_test_context()`) +- `tests/agent_types_test.rs:76, 174` (two test functions) + +**Testing:** +No dedicated tests needed — compiler will enforce all call sites are updated. + +**Verification:** +Run: `cargo check` +Expected: Compiles without errors + +**Commit:** `feat(agent): add previous_attempt and dependency_statuses to AgentContext` + + + +### Task 2: Render dependency status and previous attempt in ContextBuilder + +**Verifies:** v2-phase5.AC3.1, v2-phase5.AC3.2, v2-phase5.AC3.3 + +**Files:** +- Modify: `src/context/mod.rs:17-95` (update `build_system_prompt` method) + +**Implementation:** + +Add two new sections to `build_system_prompt`, inserted after the Task section and before Session Continuity: + +1. **Dependency status** (after Task section, before Previous Attempt): +``` +// After the task [CRITERIA] lines, add dependency lines: +for (id, title, is_done) in &ctx.dependency_statuses { + if *is_done { + prompt.push_str(&format!("[DEP:DONE] {} → {} (completed)\n", id, title)); + } else { + prompt.push_str(&format!("[DEP:PENDING] {} → {} (pending)\n", id, title)); + } +} +``` + +2. **Previous attempt** (new section between dependencies and Session Continuity): +``` +if let Some(prev) = &ctx.previous_attempt { + prompt.push_str("\n## Previous Attempt\n"); + prompt.push_str(&format!("[PREV_ATTEMPT] {}\n\n", prev)); +} +``` + +**Testing:** +Tests must verify: +- v2-phase5.AC3.1: Prompt contains `[DEP:DONE]` and `[DEP:PENDING]` when dependencies are present +- v2-phase5.AC3.2: Prompt contains `## Previous Attempt` and `[PREV_ATTEMPT]` when previous_attempt is `Some` +- v2-phase5.AC3.3: Prompt does NOT contain `## Previous Attempt` when previous_attempt is `None` + +**Verification:** +Run: `cargo test context` +Expected: All tests pass + +**Commit:** `feat(context): render dependency status and previous attempt in system prompt` + + + + + + + +### Task 3: Implement ContextBudget for token-aware assembly + +**Verifies:** v2-phase5.AC4.1, v2-phase5.AC4.2 + +**Files:** +- Modify: `src/context/mod.rs` (add `ContextBudget` struct and `build_system_prompt_with_budget` method) + +**Implementation:** + +Add a `ContextBudget` struct that manages priority-based token allocation: + +```rust +pub struct ContextBudget { + /// Total token budget for the system prompt (default: 4000) + pub max_tokens: usize, +} + +impl Default for ContextBudget { + fn default() -> Self { + Self { max_tokens: 4000 } + } +} +``` + +Add a new method `build_system_prompt_with_budget(ctx: &AgentContext, budget: &ContextBudget) -> String` that: + +1. Builds required sections first (Role, Task + deps, Previous Attempt, Rules) — these are never trimmed. Previous Attempt is in the required set because an agent retrying without knowing why it failed defeats the purpose of the retry. The design's priority table lists it at priority 3, but since retries are rare and the content is short (a single error string), including it unconditionally is the right call. +2. Builds optional sections in priority order: Session Continuity, Active Decisions, Relevant Observations, Project Conventions +3. Estimates token count as `text.len() / 4` (rough approximation) +4. If the required + all optional sections fit within budget, return the full prompt +5. If over budget, trim from lowest priority up: drop Project Conventions, then Observations, then Decisions, then Session Continuity +6. The existing `build_system_prompt` method remains unchanged (no budget, includes everything) for backward compatibility + +**Testing:** +Tests must verify: +- v2-phase5.AC4.1: With a very small budget (e.g., 200 tokens), only required sections appear; optional sections are trimmed +- v2-phase5.AC4.2: Required sections (Role, Task, Rules) are always present even with a tiny budget + +**Verification:** +Run: `cargo test context` +Expected: All tests pass + +**Commit:** `feat(context): add token budget-aware context assembly` + + + +### Task 4: Wire budget-aware builder into AgentRuntime + +**Verifies:** v2-phase5.AC4.1 + +**Files:** +- Modify: `src/agent/runtime.rs` (in `AgentRuntime::run`, use `build_system_prompt_with_budget` instead of `build_system_prompt`) + +**Implementation:** + +Update `AgentRuntime::run` to use `ContextBuilder::build_system_prompt_with_budget` with a default `ContextBudget`. The budget can later be made configurable via `RuntimeConfig` if needed, but for now use the default (4000 tokens). + +```rust +let budget = ContextBudget::default(); +let system_prompt = ContextBuilder::build_system_prompt_with_budget(&ctx, &budget); +``` + +**Testing:** +No new tests needed — existing agent_runtime_test.rs tests cover that the runtime starts correctly. The budget is tested in Task 3. + +**Verification:** +Run: `cargo test agent_runtime` +Expected: All tests pass + +**Commit:** `feat(runtime): use budget-aware context builder` + + + diff --git a/docs/implementation-plans/2026-02-12-v2-phase5/phase_03.md b/docs/implementation-plans/2026-02-12-v2-phase5/phase_03.md new file mode 100644 index 0000000..ee4dca6 --- /dev/null +++ b/docs/implementation-plans/2026-02-12-v2-phase5/phase_03.md @@ -0,0 +1,257 @@ +# V2 Phase 5 - Autonomy Levels & Approval Gates + +**Goal:** Implement the autonomy level system that controls human oversight granularity, with approval gates that pause operations for user review at configurable points. + +**Architecture:** A new `src/autonomy.rs` module defines `AutonomyLevel` (Full/Supervised/Gated), `ApprovalGate` (6 variants), `ApprovalRequest`, and `ApprovalResponse` types. A `GateChecker` determines which gates are active for a given autonomy level. The design places this in `src/config/autonomy.rs` but since `config.rs` is a flat file (not a directory module), we create `src/autonomy.rs` as a top-level module instead — it's a distinct V2 concept, not part of V1 config. + +**Tech Stack:** Rust (serde for serialization, tokio oneshot for async approval responses) + +**Scope:** 1 of 6 phases from original design (Phase 5, item 3) + +**Codebase verified:** 2026-02-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### v2-phase5.AC5: Autonomy Level Types +- **v2-phase5.AC5.1 Success:** `AutonomyLevel::Full` has no active gates +- **v2-phase5.AC5.2 Success:** `AutonomyLevel::Supervised` activates PlanReview, PreCommit, TaskComplete, GoalComplete gates +- **v2-phase5.AC5.3 Success:** `AutonomyLevel::Gated` activates all 6 gates (the 4 from Supervised plus DecisionPoint and WorkerSpawn) +- **v2-phase5.AC5.4 Success:** Default autonomy level is `Supervised` + +### v2-phase5.AC6: Approval Request/Response +- **v2-phase5.AC6.1 Success:** `ApprovalRequest` contains gate type, context summary, and proposed action +- **v2-phase5.AC6.2 Success:** `ApprovalResponse` supports Approve, Reject(reason), and Modify(instructions) + +### v2-phase5.AC14: Gate Checking +- **v2-phase5.AC14.1 Success:** `check_gate` returns `None` when the gate is not active for the current autonomy level +- **v2-phase5.AC14.2 Success:** `check_gate` returns an `ApprovalRequest` when the gate is active, allowing the caller to pause and await a response + +--- + + + + +### Task 1: Create autonomy types module + +**Verifies:** v2-phase5.AC5.1, v2-phase5.AC5.2, v2-phase5.AC5.3, v2-phase5.AC5.4 + +**Files:** +- Create: `src/autonomy.rs` +- Modify: `src/lib.rs` (add `pub mod autonomy;`) + +**Implementation:** + +Create `src/autonomy.rs` with: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AutonomyLevel { + Full, + Supervised, + Gated, +} + +impl Default for AutonomyLevel { + fn default() -> Self { + Self::Supervised + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalGate { + PlanReview, + PreCommit, + TaskComplete, + DecisionPoint, + GoalComplete, + WorkerSpawn, +} + +impl AutonomyLevel { + /// Returns the set of gates that are active for this autonomy level. + pub fn active_gates(&self) -> Vec { + match self { + Self::Full => vec![], + Self::Supervised => vec![ + ApprovalGate::PlanReview, + ApprovalGate::PreCommit, + ApprovalGate::TaskComplete, + ApprovalGate::GoalComplete, + ], + Self::Gated => vec![ + ApprovalGate::PlanReview, + ApprovalGate::PreCommit, + ApprovalGate::TaskComplete, + ApprovalGate::DecisionPoint, + ApprovalGate::GoalComplete, + ApprovalGate::WorkerSpawn, + ], + } + } + + /// Check whether a specific gate is active for this autonomy level. + pub fn is_gate_active(&self, gate: ApprovalGate) -> bool { + self.active_gates().contains(&gate) + } +} +``` + +**Testing:** +Tests must verify: +- v2-phase5.AC5.1: `Full.active_gates()` returns empty vec +- v2-phase5.AC5.2: `Supervised.active_gates()` returns exactly PlanReview, PreCommit, TaskComplete, GoalComplete +- v2-phase5.AC5.3: `Gated.active_gates()` returns all 6 gates +- v2-phase5.AC5.4: `AutonomyLevel::default()` is `Supervised` + +**Verification:** +Run: `cargo test autonomy` +Expected: All tests pass + +**Commit:** `feat(autonomy): add AutonomyLevel and ApprovalGate types` + + + +### Task 2: Add ApprovalRequest and ApprovalResponse types + +**Verifies:** v2-phase5.AC6.1, v2-phase5.AC6.2 + +**Files:** +- Modify: `src/autonomy.rs` (add request/response types) + +**Implementation:** + +Add to `src/autonomy.rs`: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalRequest { + /// Unique ID for this request + pub id: String, + /// Which gate triggered this request + pub gate: ApprovalGate, + /// Human-readable summary of what's being approved + pub context_summary: String, + /// Description of the proposed action + pub proposed_action: String, + /// ID of the goal/task this relates to + pub related_node_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "lowercase")] +pub enum ApprovalResponse { + Approve, + Reject { reason: String }, + Modify { instructions: String }, +} +``` + +**Testing:** +Tests must verify: +- v2-phase5.AC6.1: ApprovalRequest can be constructed with all required fields and serialized to JSON +- v2-phase5.AC6.2: All three ApprovalResponse variants serialize/deserialize correctly (round-trip) + +**Verification:** +Run: `cargo test autonomy` +Expected: All tests pass + +**Commit:** `feat(autonomy): add ApprovalRequest and ApprovalResponse types` + + + +### Task 3: Add serde round-trip tests for autonomy types + +**Verifies:** v2-phase5.AC5.1, v2-phase5.AC5.2, v2-phase5.AC5.3, v2-phase5.AC6.1, v2-phase5.AC6.2 + +**Files:** +- Modify: `src/autonomy.rs` (add `#[cfg(test)]` module with serde tests) + +**Implementation:** + +Add an inline `#[cfg(test)]` module at the bottom of `src/autonomy.rs` with tests that verify: +1. `AutonomyLevel` serializes to lowercase strings (`"full"`, `"supervised"`, `"gated"`) and deserializes back +2. `ApprovalGate` serializes to snake_case (`"plan_review"`, `"pre_commit"`, etc.) and deserializes back +3. `ApprovalResponse::Approve` serializes as `{"action": "approve"}` and deserializes back +4. `ApprovalResponse::Reject` serializes as `{"action": "reject", "reason": "..."}` and deserializes back +5. `ApprovalResponse::Modify` serializes as `{"action": "modify", "instructions": "..."}` and deserializes back + +**Testing:** +These ARE the tests. They verify serde serialization matches the expected wire format that the daemon API and CLI will use. + +**Verification:** +Run: `cargo test autonomy` +Expected: All tests pass + +**Commit:** `test(autonomy): add serde round-trip tests` + + + +### Task 4: Add GateChecker for orchestrator gate integration + +**Verifies:** v2-phase5.AC14.1, v2-phase5.AC14.2 + +**Files:** +- Modify: `src/autonomy.rs` (add `GateChecker` struct) + +**Implementation:** + +Add a `GateChecker` struct that the orchestrator will use to check gates: + +```rust +/// Checks whether a gate requires approval for the current autonomy level. +pub struct GateChecker { + level: AutonomyLevel, +} + +impl GateChecker { + pub fn new(level: AutonomyLevel) -> Self { + Self { level } + } + + /// Check if a gate requires approval. Returns Some(ApprovalRequest) if the gate + /// is active and approval is needed, None if the gate is inactive. + pub fn check_gate( + &self, + gate: ApprovalGate, + related_node_id: &str, + context_summary: &str, + proposed_action: &str, + ) -> Option { + if self.level.is_gate_active(gate) { + Some(ApprovalRequest { + id: format!("approval-{}", uuid::Uuid::new_v4().simple().to_string().get(..8).unwrap_or("00000000")), + gate, + context_summary: context_summary.to_string(), + proposed_action: proposed_action.to_string(), + related_node_id: related_node_id.to_string(), + }) + } else { + None + } + } +} +``` + +Note: The orchestrator integration (calling `check_gate` at the right points in the state machine, pausing operations, and handling responses) is part of Phase 3 (Orchestrator) in the broader V2 architecture, not this phase. This phase provides the types and checker logic so the orchestrator can consume them. The design's orchestrator state machine already has the hookpoints (Planning, Scheduling, Monitoring). + +**Testing:** +Tests must verify: +- v2-phase5.AC14.1: `GateChecker::new(Full).check_gate(PlanReview, ...)` returns `None` +- v2-phase5.AC14.2: `GateChecker::new(Supervised).check_gate(PlanReview, ...)` returns `Some(ApprovalRequest)` with correct gate and fields + +**Verification:** +Run: `cargo test autonomy` +Expected: All tests pass + +**Commit:** `feat(autonomy): add GateChecker for orchestrator integration` + + + diff --git a/docs/implementation-plans/2026-02-12-v2-phase5/phase_04.md b/docs/implementation-plans/2026-02-12-v2-phase5/phase_04.md new file mode 100644 index 0000000..ad9111f --- /dev/null +++ b/docs/implementation-plans/2026-02-12-v2-phase5/phase_04.md @@ -0,0 +1,204 @@ +# V2 Phase 5 - Security Scope Enforcement + +**Goal:** Add enforcement logic to `SecurityScope` so that per-agent security boundaries are checked at tool execution time. Currently `SecurityScope` is a pure data struct with no validation methods. + +**Architecture:** Add `check_path` and `check_command` methods to `SecurityScope` that use glob pattern matching (via the `glob` crate's `Pattern::matches_path`) to validate file paths and shell commands against the scope's allowed/denied lists. The `read_only` and `can_create_files` flags are checked separately. Tool implementations (`ReadFileTool`, `WriteFileTool`, `RunCommandTool`) will use these methods in Phase 5 (wiring), but this phase focuses on the SecurityScope logic itself. + +**Tech Stack:** Rust, `glob` 0.3 (already in Cargo.toml) + +**Scope:** 1 of 6 phases from original design (Phase 5, item 4) + +**Codebase verified:** 2026-02-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### v2-phase5.AC7: Path Validation +- **v2-phase5.AC7.1 Success:** `check_path` allows a path matching an allowed pattern (e.g., `src/main.rs` matches `src/**`) +- **v2-phase5.AC7.2 Success:** `check_path` denies a path matching a denied pattern even if it also matches an allowed pattern (deny takes precedence) +- **v2-phase5.AC7.3 Success:** `check_path` denies all write operations when `read_only` is true +- **v2-phase5.AC7.4 Success:** `check_path` denies creating new files when `can_create_files` is false (but allows editing existing files) +- **v2-phase5.AC7.5 Success:** Wildcard `*` in allowed_paths matches everything + +### v2-phase5.AC8: Command Validation +- **v2-phase5.AC8.1 Success:** `check_command` allows a command matching an allowed pattern (e.g., `cargo test` matches `cargo *`) +- **v2-phase5.AC8.2 Success:** `check_command` denies a command not matching any allowed pattern +- **v2-phase5.AC8.3 Success:** Wildcard `*` in allowed_commands matches everything + +--- + + + + +### Task 1: Implement check_path on SecurityScope + +**Verifies:** v2-phase5.AC7.1, v2-phase5.AC7.2, v2-phase5.AC7.3, v2-phase5.AC7.4, v2-phase5.AC7.5 + +**Files:** +- Modify: `src/security/scope.rs` (add methods) + +**Implementation:** + +Add an enum and methods to `SecurityScope`: + +```rust +use glob::{MatchOptions, Pattern}; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileOperation { + Read, + Write, + Create, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScopeCheck { + Allowed, + Denied(String), +} + +/// Match options that allow `**` to match across directory separators. +fn glob_match_options() -> MatchOptions { + MatchOptions { + case_sensitive: true, + require_literal_separator: false, // allows `**` to match `/` + require_literal_leading_dot: false, + } +} + +impl SecurityScope { + /// Check whether a file operation is permitted for the given path. + pub fn check_path(&self, path: &str, operation: FileOperation) -> ScopeCheck { + // Check read_only constraint + if self.read_only && matches!(operation, FileOperation::Write | FileOperation::Create) { + return ScopeCheck::Denied("read-only scope".to_string()); + } + + // Check can_create_files constraint + if !self.can_create_files && operation == FileOperation::Create { + return ScopeCheck::Denied("file creation not allowed".to_string()); + } + + let opts = glob_match_options(); + let path_ref = Path::new(path); + + // Check denied patterns first (deny takes precedence) + for pattern_str in &self.denied_paths { + if let Ok(pattern) = Pattern::new(pattern_str) { + if pattern.matches_path_with(path_ref, opts) { + return ScopeCheck::Denied(format!("path matches denied pattern: {}", pattern_str)); + } + } + } + + // Check allowed patterns + for pattern_str in &self.allowed_paths { + if let Ok(pattern) = Pattern::new(pattern_str) { + if pattern.matches_path_with(path_ref, opts) { + return ScopeCheck::Allowed; + } + } + } + + ScopeCheck::Denied("path not in allowed patterns".to_string()) + } +} +``` + +**Important:** `Pattern::matches_path_with` with `require_literal_separator: false` allows `**` and `*` to match across `/` directory separators, so `src/**` correctly matches `src/auth/handler.rs`. Without this option, `*` would not match `/` and recursive patterns would fail. + +**Testing:** +Tests must verify each AC case listed above. Use inline `#[cfg(test)]` module in `scope.rs`. Must include a test that `src/**` matches `src/auth/handler.rs` (recursive directory matching). + +**Verification:** +Run: `cargo test scope` +Expected: All tests pass + +**Commit:** `feat(security): add check_path to SecurityScope` + + + +### Task 2: Implement check_command on SecurityScope + +**Verifies:** v2-phase5.AC8.1, v2-phase5.AC8.2, v2-phase5.AC8.3 + +**Files:** +- Modify: `src/security/scope.rs` (add `check_command` method) + +**Implementation:** + +Add to `SecurityScope`: + +```rust +impl SecurityScope { + /// Check whether a shell command is permitted. + pub fn check_command(&self, command: &str) -> ScopeCheck { + for pattern_str in &self.allowed_commands { + if let Ok(pattern) = Pattern::new(pattern_str) { + if pattern.matches(command) { + return ScopeCheck::Allowed; + } + } + } + + ScopeCheck::Denied(format!("command not in allowed patterns: {}", command)) + } + + /// Check whether network access is permitted. + pub fn check_network(&self) -> ScopeCheck { + if self.network_access { + ScopeCheck::Allowed + } else { + ScopeCheck::Denied("network access not allowed".to_string()) + } + } +} +``` + +**Testing:** +Tests must verify: +- v2-phase5.AC8.1: `cargo test` matches `cargo *` +- v2-phase5.AC8.2: `rm -rf /` does not match `cargo *` +- v2-phase5.AC8.3: any command matches `*` + +**Verification:** +Run: `cargo test scope` +Expected: All tests pass + +**Commit:** `feat(security): add check_command and check_network to SecurityScope` + + + +### Task 3: Integration tests for SecurityScope with built-in profiles + +**Verifies:** v2-phase5.AC7.1, v2-phase5.AC7.3, v2-phase5.AC8.1 + +**Files:** +- Create: `tests/security_scope_test.rs` + +**Implementation:** + +Write integration tests that verify the built-in profile security scopes work correctly: + +1. Load the `planner()` profile from `src/agent/builtin_profiles.rs` and verify its `SecurityScope` denies writes (`read_only: true`) +2. Load the `coder()` profile and verify its scope allows file writes within project paths +3. Load the `reviewer()` profile and verify its scope denies writes (`read_only: true`) +4. Load the `researcher()` profile and verify its scope denies writes (`read_only: true`) + +These tests import from `rustagent::agent::builtin_profiles` and call `check_path`/`check_command` on each profile's security scope. + +**Testing:** +These ARE the tests. They verify that built-in profiles integrate correctly with the new enforcement methods. + +**Verification:** +Run: `cargo test security_scope` +Expected: All tests pass + +**Commit:** `test(security): integration tests for SecurityScope with built-in profiles` + + + diff --git a/docs/implementation-plans/2026-02-12-v2-phase5/phase_05.md b/docs/implementation-plans/2026-02-12-v2-phase5/phase_05.md new file mode 100644 index 0000000..88383c1 --- /dev/null +++ b/docs/implementation-plans/2026-02-12-v2-phase5/phase_05.md @@ -0,0 +1,191 @@ +# V2 Phase 5 - Code Search Tool + +**Goal:** Build a file-content search tool that agents can use to search project source code. This is distinct from the existing `search_nodes` graph tool — this searches file contents on disk, like grep. + +**Architecture:** A new `src/tools/search.rs` module implements the `Tool` trait. It uses `walkdir` for directory traversal and `regex` for pattern matching. The tool accepts a search pattern, optional file glob filter, optional directory scope, and returns matching lines with file paths and line numbers. Results are capped to prevent flooding the agent's context. The tool respects the agent's `SecurityScope` path restrictions. + +**Tech Stack:** Rust, `walkdir` 2 (already in Cargo.toml), `regex` 1.10 (already in Cargo.toml) + +**Scope:** 1 of 6 phases from original design (Phase 5, item 5) + +**Codebase verified:** 2026-02-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### v2-phase5.AC9: Code Search Functionality +- **v2-phase5.AC9.1 Success:** Searching for a known string pattern returns matching lines with file paths and line numbers +- **v2-phase5.AC9.2 Success:** File glob filter limits search to matching files (e.g., `*.rs` searches only Rust files) +- **v2-phase5.AC9.3 Success:** Results are capped at a configurable limit (default 50 matches) to avoid flooding agent context +- **v2-phase5.AC9.4 Success:** Binary files are skipped (files that fail UTF-8 decoding) + +### v2-phase5.AC10: Code Search Registration +- **v2-phase5.AC10.1 Success:** The search tool is registered in the V2 tool registry and available to agents +- **v2-phase5.AC10.2 Success:** The tool's JSON schema describes `pattern` (required), `file_glob` (optional), `directory` (optional), and `max_results` (optional) parameters + +--- + + + + +### Task 1: Implement CodeSearchTool + +**Verifies:** v2-phase5.AC9.1, v2-phase5.AC9.2, v2-phase5.AC9.3, v2-phase5.AC9.4, v2-phase5.AC10.2 + +**Files:** +- Create: `src/tools/search.rs` +- Modify: `src/tools/mod.rs` (add `pub mod search;`) + +**Implementation:** + +Create `src/tools/search.rs` implementing the `Tool` trait: + +```rust +use crate::tools::Tool; +use anyhow::Result; +use async_trait::async_trait; +use glob::Pattern; +use regex::Regex; +use serde_json::json; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +pub struct CodeSearchTool { + project_root: PathBuf, +} + +impl CodeSearchTool { + pub fn new(project_root: PathBuf) -> Self { + Self { project_root } + } +} +``` + +The `execute` method should: +1. Parse parameters: `pattern` (required string — used as regex), `file_glob` (optional string — glob pattern for filenames), `directory` (optional string — subdirectory to scope search), `max_results` (optional number, default 50) +2. Compile the regex pattern (return error if invalid) +3. If `directory` is provided, scope the walk to `project_root/directory`; otherwise walk from `project_root` +4. Use `WalkDir` to iterate files, skipping hidden directories (`.git`, `.jj`, `node_modules`, `target`) +5. If `file_glob` is set, use `glob::Pattern::matches` to filter filenames +6. Read each file as UTF-8, skipping files that fail (binary files) +7. For each matching line, format as `{relative_path}:{line_number}: {line_content}` +8. Stop after `max_results` matches +9. Return the formatted results, plus a summary line like `"Found N matches (limited to max_results)"` if the cap was hit + +Parameters JSON schema: +```json +{ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regex pattern to search for in file contents" + }, + "file_glob": { + "type": "string", + "description": "Glob pattern to filter files (e.g., '*.rs', '*.ts')" + }, + "directory": { + "type": "string", + "description": "Subdirectory to scope the search (relative to project root)" + }, + "max_results": { + "type": "integer", + "description": "Maximum number of matching lines to return (default: 50)" + } + }, + "required": ["pattern"] +} +``` + +**Testing:** +Tests must verify: +- v2-phase5.AC9.1: Search finds known pattern, result includes file path and line number +- v2-phase5.AC9.2: File glob filters correctly +- v2-phase5.AC9.3: Results are capped at max_results +- v2-phase5.AC9.4: Binary files don't cause errors + +Use tempdir with test files for testing. + +**Verification:** +Run: `cargo test code_search` +Expected: All tests pass + +Note: Use `code_search` prefix for all test function names in the inline `#[cfg(test)]` module to avoid collision with existing `search_nodes` tests in graph_tools. + +**Commit:** `feat(tools): add CodeSearchTool for file content search` + + + +### Task 2: Register CodeSearchTool in the V2 registry + +**Verifies:** v2-phase5.AC10.1 + +**Files:** +- Modify: `src/tools/factory.rs` (add CodeSearchTool to `create_v2_registry`) + +**Implementation:** + +Add the CodeSearchTool to `create_v2_registry`. The tool needs the project root path, which should be passed as a new parameter to `create_v2_registry`. + +1. Add `project_root: PathBuf` parameter to `create_v2_registry` +2. Register the tool: `registry.register(Arc::new(CodeSearchTool::new(project_root)));` +3. Add the import: `use crate::tools::search::CodeSearchTool;` +4. Update all call sites of `create_v2_registry` to pass the project root path + +Call sites for `create_v2_registry` (verified via grep): +- `src/agent/orchestrator.rs:821` (in `spawn_worker_with_id`) +- `tests/agent_tools_test.rs:438, 473` (two test functions) +- `tests/graph_tools_test.rs:665` (test function) + +Note: `src/main.rs` does NOT call `create_v2_registry` — it uses `create_default_registry` for the CLI. Only the orchestrator and test files need updating. + +**Testing:** +No dedicated test needed — the compiler will enforce the new parameter is passed. + +**Verification:** +Run: `cargo check` +Expected: Compiles without errors + +**Commit:** `feat(tools): register CodeSearchTool in V2 registry` + + + +### Task 3: Integration test for CodeSearchTool + +**Verifies:** v2-phase5.AC9.1, v2-phase5.AC9.2, v2-phase5.AC9.3 + +**Files:** +- Create: `tests/code_search_test.rs` + +**Implementation:** + +Write integration tests using a tempdir with multiple test files: + +1. Create a tempdir with: + - `src/main.rs` containing `fn main() { println!("hello"); }` + - `src/lib.rs` containing `pub fn add(a: i32, b: i32) -> i32 { a + b }` + - `README.md` containing `# My Project` + - A subdirectory `src/utils/helper.rs` containing `pub fn helper() {}` + +2. Test cases: + - Search for `"fn main"` → finds `src/main.rs:1` + - Search for `"pub fn"` → finds both `.rs` files + - Search for `"pub fn"` with `file_glob: "*.rs"` → finds `.rs` files, not `README.md` + - Search with `max_results: 1` → returns exactly 1 result with cap notice + - Search for `"nonexistent_pattern"` → returns "No matches found" + +**Testing:** +These ARE the tests. + +**Verification:** +Run: `cargo test code_search` +Expected: All tests pass + +**Commit:** `test(tools): integration tests for CodeSearchTool` + + + diff --git a/docs/implementation-plans/2026-02-12-v2-phase5/phase_06.md b/docs/implementation-plans/2026-02-12-v2-phase5/phase_06.md new file mode 100644 index 0000000..8d33d2f --- /dev/null +++ b/docs/implementation-plans/2026-02-12-v2-phase5/phase_06.md @@ -0,0 +1,277 @@ +# V2 Phase 5 - Agent Error Recovery & Task Reassignment + +**Goal:** Enhance the orchestrator's error recovery to inject previous-attempt context into retried workers, cascade failure blocking to downstream tasks, and unblock tasks when a blocker resolves. + +**Architecture:** The orchestrator already has core retry logic (`handle_task_retry_or_fail` at `orchestrator.rs:1058-1141`) with retry count tracking and observation node creation on final failure. This phase adds three missing pieces: (1) when spawning a retry worker, populate `AgentContext.previous_attempt` with the failed attempt's error so the new worker can learn from it; (2) when a task fails permanently, find downstream `DependsOn` tasks and mark them Blocked; (3) in `handle_scheduling`, check for Blocked tasks whose blockers have been resolved and unblock them. + +**Tech Stack:** Rust (uses existing GraphStore trait, EdgeType::DependsOn, no new dependencies) + +**Scope:** 1 of 6 phases from original design (Phase 5, item 6) + +**Codebase verified:** 2026-02-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### v2-phase5.AC11: Previous Attempt Context +- **v2-phase5.AC11.1 Success:** When a task is retried, the new worker's `AgentContext.previous_attempt` contains the previous failure's error description +- **v2-phase5.AC11.2 Success:** On the first attempt (no retries), `previous_attempt` is `None` + +### v2-phase5.AC12: Failure Cascading +- **v2-phase5.AC12.1 Success:** When a task is permanently failed (retries exhausted), downstream tasks linked via `DependsOn` edges are marked Blocked with a `blocked_reason` referencing the failed task +- **v2-phase5.AC12.2 Success:** Tasks not dependent on the failed task are unaffected + +### v2-phase5.AC13: Blocked Task Recovery +- **v2-phase5.AC13.1 Success:** During scheduling, tasks that are Blocked but whose blocker task has since been completed are transitioned back to Ready +- **v2-phase5.AC13.2 Success:** Tasks whose blocker is still Failed/Blocked remain Blocked + +--- + + + + +### Task 1: Inject previous attempt context on retry + +**Verifies:** v2-phase5.AC11.1, v2-phase5.AC11.2 + +**Files:** +- Modify: `src/agent/orchestrator.rs:1057-1095` (enhance retry path in `handle_task_retry_or_fail`) +- Modify: `src/agent/orchestrator.rs:797-806` (enhance `spawn_worker_with_id` to accept and forward `previous_attempt`) + +**Implementation:** + +1. In `handle_task_retry_or_fail`, when retrying (retry_count < max), store the failure error in the task's metadata under key `"previous_attempt"`: +```rust +metadata.insert("previous_attempt".to_string(), error.to_string()); +``` + +2. In `spawn_worker_with_id`, when building the `AgentContext`, read `"previous_attempt"` from the task node's metadata: +```rust +let previous_attempt = task_nodes + .first() + .and_then(|t| t.metadata.get("previous_attempt").cloned()); + +let ctx = AgentContext { + // ... existing fields ... + previous_attempt, + dependency_statuses: vec![], // populated in Phase 2 +}; +``` + +This requires Phase 2 (which adds `previous_attempt` and `dependency_statuses` fields to `AgentContext`) to be implemented first. If Phase 2 is not yet done, this task should use `None` and `vec![]` placeholders and update when Phase 2 lands. + +**Testing:** +Tests must verify: +- v2-phase5.AC11.1: After a retry, the re-spawned AgentContext has `previous_attempt = Some("the error")` +- v2-phase5.AC11.2: On first attempt, `previous_attempt` is `None` + +Test in `tests/orchestrator_test.rs` by setting up a task with `retry_count` metadata and verifying the context construction. + +**Verification:** +Run: `cargo test orchestrator` +Expected: All tests pass + +**Commit:** `feat(orchestrator): inject previous attempt context on task retry` + + + +### Task 2: Cascade failure to downstream dependent tasks + +**Verifies:** v2-phase5.AC12.1, v2-phase5.AC12.2 + +**Files:** +- Modify: `src/agent/orchestrator.rs:1095-1141` (add cascade after marking task Failed) + +**Implementation:** + +After marking a task as Failed and creating the observation node (line ~1129), add a call to cascade the failure to dependent tasks: + +```rust +// Cascade failure: find downstream tasks that DependsOn this failed task +self.cascade_block_to_dependents(task_id).await?; +``` + +Implement `cascade_block_to_dependents` as a new method on `Orchestrator`: + +```rust +/// Mark all tasks that directly depend on `blocker_id` as Blocked. +/// Stores the blocker task ID in each blocked task's metadata under key +/// `"blocker_task_id"` for reliable lookup during unblock checks. +async fn cascade_block_to_dependents(&self, blocker_id: &str) -> Result<()> { + // DependsOn edge direction: if B DependsOn A, edge is from=B, to=A. + // So get_edges(A, Incoming) finds edges where to_node=A, returning + // the related from_node (B) — i.e., all tasks that depend on A. + let edges = self.graph_store + .get_edges(blocker_id, EdgeDirection::Incoming) + .await?; + + let reason = format!("blocked by failed task {}", blocker_id); + + for (edge, node) in edges { + if edge.edge_type == EdgeType::DependsOn + && node.node_type == NodeType::Task + && !matches!(node.status, NodeStatus::Completed | NodeStatus::Failed | NodeStatus::Cancelled) + { + // Store blocker ID in metadata for reliable lookup during unblock + let mut metadata = node.metadata.clone(); + metadata.insert("blocker_task_id".to_string(), blocker_id.to_string()); + + self.graph_store + .update_node( + &node.id, + Some(NodeStatus::Blocked), + None, // title unchanged + None, // description unchanged + Some(&reason), // blocked_reason + Some(&metadata), // metadata with blocker_task_id + ) + .await?; + + tracing::info!( + task = %node.id, + blocker = %blocker_id, + "Task blocked due to dependency failure" + ); + } + } + + Ok(()) +} +``` + +Note: `DependsOn` edge direction — if task B `DependsOn` task A, the edge is `from=B, to=A`. When A fails, `get_edges(A, Incoming)` returns edges where `to_node = A`, with the related node being `from_node` (B). Verify this by checking `get_edges` semantics in `store.rs:713-749`. + +**Testing:** +Tests must verify: +- v2-phase5.AC12.1: After task A fails permanently, task B (which DependsOn A) becomes Blocked with reason mentioning A +- v2-phase5.AC12.2: Task C (no dependency on A) remains unaffected + +**Verification:** +Run: `cargo test orchestrator` +Expected: All tests pass + +**Commit:** `feat(orchestrator): cascade failure blocking to dependent tasks` + + + + + + + +### Task 3: Unblock tasks when blocker resolves + +**Verifies:** v2-phase5.AC13.1, v2-phase5.AC13.2 + +**Files:** +- Modify: `src/agent/orchestrator.rs:418-538` (add unblock check in `handle_scheduling`) + +**Implementation:** + +At the start of `handle_scheduling`, before querying ready tasks, add a pass that checks Blocked tasks: + +```rust +// Check for blocked tasks that can be unblocked +self.try_unblock_tasks(&goal_id).await?; +``` + +Implement `try_unblock_tasks`: + +```rust +/// Check all Blocked tasks under a goal and unblock any whose blockers have resolved. +/// Uses `metadata["blocker_task_id"]` (set by `cascade_block_to_dependents`) to +/// reliably identify which task is the blocker — no string parsing of blocked_reason. +async fn try_unblock_tasks(&self, goal_id: &str) -> Result<()> { + let blocked_tasks = self.graph_store + .query_nodes(&NodeQuery { + node_type: Some(NodeType::Task), + status: Some(NodeStatus::Blocked), + project_id: Some(self.project_id.clone()), + parent_id: None, + query: None, + }) + .await?; + + for task in &blocked_tasks { + // Look up blocker task ID from metadata (set during cascade) + if let Some(blocker_id) = task.metadata.get("blocker_task_id") { + if let Some(blocker) = self.graph_store.get_node(blocker_id).await? { + // If the blocker has been retried and is now completed, unblock + if blocker.status == NodeStatus::Completed { + // Remove blocker_task_id from metadata when unblocking + let mut metadata = task.metadata.clone(); + metadata.remove("blocker_task_id"); + + self.graph_store + .update_node( + &task.id, + Some(NodeStatus::Ready), + None, // title unchanged + None, // description unchanged + None, // clear blocked_reason + Some(&metadata), // metadata with blocker_task_id removed + ) + .await?; + + tracing::info!( + task = %task.id, + blocker = %blocker_id, + "Task unblocked: dependency resolved" + ); + } + } + } + } + + Ok(()) +} +``` + +**Testing:** +Tests must verify: +- v2-phase5.AC13.1: A Blocked task referencing a failed task gets unblocked when that task later completes +- v2-phase5.AC13.2: A Blocked task whose blocker is still Failed stays Blocked + +**Verification:** +Run: `cargo test orchestrator` +Expected: All tests pass + +**Commit:** `feat(orchestrator): unblock tasks when blocker dependency resolves` + + + +### Task 4: Integration test for full retry-cascade-unblock lifecycle + +**Verifies:** v2-phase5.AC11.1, v2-phase5.AC12.1, v2-phase5.AC13.1 + +**Files:** +- Modify: `tests/orchestrator_test.rs` (add integration test) + +**Implementation:** + +Write a test that exercises the full lifecycle: +1. Create goal with task A and task B, where B DependsOn A +2. Simulate A failing (call `handle_task_retry_or_fail` with an error) +3. Verify A is reset to Ready with `previous_attempt` in metadata (retry) +4. Simulate A failing again (exceeding max_retries) +5. Verify A is marked Failed and an Observation node is created +6. Verify B is marked Blocked with reason referencing A +7. Manually complete A (simulate external fix) +8. Call `handle_scheduling` (which calls `try_unblock_tasks`) +9. Verify B is now Ready + +Use the existing test helpers from `tests/common/mod.rs` (MockGraphStore or real SQLite). + +**Testing:** +This IS the test. + +**Verification:** +Run: `cargo test orchestrator` +Expected: All tests pass + +**Commit:** `test(orchestrator): integration test for retry-cascade-unblock lifecycle` + + + diff --git a/docs/implementation-plans/2026-02-12-v2-phase5/test-requirements.md b/docs/implementation-plans/2026-02-12-v2-phase5/test-requirements.md new file mode 100644 index 0000000..e67e87f --- /dev/null +++ b/docs/implementation-plans/2026-02-12-v2-phase5/test-requirements.md @@ -0,0 +1,192 @@ +# V2 Phase 5 - Test Requirements + +Generated from Acceptance Criteria across all 6 implementation phases. + +## Summary + +- **38 acceptance criteria** mapped to automated tests +- **8 test locations** (inline modules + integration test files) +- **1 human verification** (end-to-end smoke test) + +--- + +## Phase 1: AGENTS.md Parser Enhancement + +### v2-phase5.AC1: AGENTS.md Resolution + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC1.1 | Unit | `src/context/agents_md.rs` (inline `#[cfg(test)]`) | `resolve_agents_md` returns headings with correct line counts, ordered closest-to-file first | +| v2-phase5.AC1.2 | Unit | `src/context/agents_md.rs` (inline `#[cfg(test)]`) | Returned paths are relative to project root (no absolute path prefix) | +| v2-phase5.AC1.3 | Unit | `src/context/agents_md.rs` (inline `#[cfg(test)]`) | Deduplication works — same AGENTS.md not returned twice | + +### v2-phase5.AC2: ReadAgentsMdTool Enhancement + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC2.1 | Unit | `src/context/mod.rs` (inline `#[cfg(test)]`) | Tool accepts directory path (e.g., `src/auth`) and reads AGENTS.md within it | +| v2-phase5.AC2.2 | Unit | `src/context/mod.rs` (inline `#[cfg(test)]`) | Tool still accepts full file path (`src/auth/AGENTS.md`) for backward compatibility | + +--- + +## Phase 2: ContextBuilder Enhancement + +### v2-phase5.AC3: ContextBuilder Sections + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC3.1 | Unit | `src/context/mod.rs` (inline `#[cfg(test)]`) | System prompt includes `[DEP:DONE]` for completed deps and `[DEP:PENDING]` for pending deps | +| v2-phase5.AC3.2 | Unit | `src/context/mod.rs` (inline `#[cfg(test)]`) | System prompt includes `[PREV_ATTEMPT]` section when previous attempt is present | +| v2-phase5.AC3.3 | Unit | `src/context/mod.rs` (inline `#[cfg(test)]`) | System prompt omits `## Previous Attempt` section entirely when no previous attempt exists | + +### v2-phase5.AC4: Token Budget + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC4.1 | Unit | `src/context/mod.rs` (inline `#[cfg(test)]`) | With small budget, lower-priority sections are trimmed (observations first, then AGENTS.md, then decisions) | +| v2-phase5.AC4.2 | Unit | `src/context/mod.rs` (inline `#[cfg(test)]`) | Required sections (Role, Task, Rules) are never trimmed regardless of budget | + +--- + +## Phase 3: Autonomy Levels & Approval Gates + +### v2-phase5.AC5: Autonomy Level Types + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC5.1 | Unit | `src/autonomy.rs` (inline `#[cfg(test)]`) | `AutonomyLevel::Full` has no active gates (empty vec) | +| v2-phase5.AC5.2 | Unit | `src/autonomy.rs` (inline `#[cfg(test)]`) | `AutonomyLevel::Supervised` activates PlanReview, PreCommit, TaskComplete, GoalComplete | +| v2-phase5.AC5.3 | Unit | `src/autonomy.rs` (inline `#[cfg(test)]`) | `AutonomyLevel::Gated` activates all 6 gates | +| v2-phase5.AC5.4 | Unit | `src/autonomy.rs` (inline `#[cfg(test)]`) | `AutonomyLevel::default()` is `Supervised` | + +### v2-phase5.AC6: Approval Request/Response + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC6.1 | Unit | `src/autonomy.rs` (inline `#[cfg(test)]`) | `ApprovalRequest` can be constructed and serialized to JSON with all required fields | +| v2-phase5.AC6.2 | Unit | `src/autonomy.rs` (inline `#[cfg(test)]`) | All three `ApprovalResponse` variants serialize/deserialize correctly (round-trip) | + +### v2-phase5.AC14: Gate Checking + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC14.1 | Unit | `src/autonomy.rs` (inline `#[cfg(test)]`) | `GateChecker::new(Full).check_gate(PlanReview, ...)` returns `None` | +| v2-phase5.AC14.2 | Unit | `src/autonomy.rs` (inline `#[cfg(test)]`) | `GateChecker::new(Supervised).check_gate(PlanReview, ...)` returns `Some(ApprovalRequest)` with correct gate and fields | + +--- + +## Phase 4: Security Scope Enforcement + +### v2-phase5.AC7: Path Validation + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC7.1 | Unit | `src/security/scope.rs` (inline `#[cfg(test)]`) | `check_path` allows path matching allowed pattern (e.g., `src/main.rs` matches `src/**`) | +| v2-phase5.AC7.2 | Unit | `src/security/scope.rs` (inline `#[cfg(test)]`) | `check_path` denies path matching denied pattern even if also matching allowed pattern | +| v2-phase5.AC7.3 | Unit | `src/security/scope.rs` (inline `#[cfg(test)]`) | `check_path` denies all write operations when `read_only` is true | +| v2-phase5.AC7.4 | Unit | `src/security/scope.rs` (inline `#[cfg(test)]`) | `check_path` denies creating new files when `can_create_files` is false | +| v2-phase5.AC7.5 | Unit | `src/security/scope.rs` (inline `#[cfg(test)]`) | Wildcard `*` in allowed_paths matches everything | + +Additional test: `src/**` matches `src/auth/handler.rs` (recursive directory matching with `matches_path_with`). + +### v2-phase5.AC8: Command Validation + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC8.1 | Unit | `src/security/scope.rs` (inline `#[cfg(test)]`) | `check_command` allows matching command (e.g., `cargo test` matches `cargo *`) | +| v2-phase5.AC8.2 | Unit | `src/security/scope.rs` (inline `#[cfg(test)]`) | `check_command` denies command not matching any allowed pattern | +| v2-phase5.AC8.3 | Unit | `src/security/scope.rs` (inline `#[cfg(test)]`) | Wildcard `*` in allowed_commands matches everything | + +### Integration: Built-in Profile Scope Tests + +| Test Type | Test Location | Description | +|-----------|---------------|-------------| +| Integration | `tests/security_scope_test.rs` | Planner/reviewer/researcher profiles deny writes (read_only), coder profile allows writes | + +--- + +## Phase 5: Code Search Tool + +### v2-phase5.AC9: Code Search Functionality + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC9.1 | Unit | `src/tools/search.rs` (inline `#[cfg(test)]`) | Search finds known pattern, result includes file path and line number | +| v2-phase5.AC9.2 | Unit | `src/tools/search.rs` (inline `#[cfg(test)]`) | File glob filter limits search to matching files | +| v2-phase5.AC9.3 | Unit | `src/tools/search.rs` (inline `#[cfg(test)]`) | Results capped at max_results | +| v2-phase5.AC9.4 | Unit | `src/tools/search.rs` (inline `#[cfg(test)]`) | Binary files skipped without error | + +### v2-phase5.AC10: Code Search Registration + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC10.1 | Build check | `cargo check` | Search tool registered in V2 registry (compiler enforces new parameter) | +| v2-phase5.AC10.2 | Unit | `src/tools/search.rs` (inline `#[cfg(test)]`) | Tool's JSON schema describes pattern (required), file_glob, directory, max_results (optional) | + +### Integration: Code Search Tests + +| Test Type | Test Location | Description | +|-----------|---------------|-------------| +| Integration | `tests/code_search_test.rs` | End-to-end search with tempdir, glob filtering, max_results cap, no-match case | + +--- + +## Phase 6: Agent Error Recovery & Task Reassignment + +### v2-phase5.AC11: Previous Attempt Context + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC11.1 | Integration | `tests/orchestrator_test.rs` | After retry, re-spawned AgentContext has `previous_attempt = Some("the error")` | +| v2-phase5.AC11.2 | Integration | `tests/orchestrator_test.rs` | On first attempt, `previous_attempt` is `None` | + +### v2-phase5.AC12: Failure Cascading + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC12.1 | Integration | `tests/orchestrator_test.rs` | After task A fails permanently, task B (DependsOn A) becomes Blocked with reason and `blocker_task_id` in metadata | +| v2-phase5.AC12.2 | Integration | `tests/orchestrator_test.rs` | Task C (no dependency on A) remains unaffected | + +### v2-phase5.AC13: Blocked Task Recovery + +| Criterion | Test Type | Test Location | Description | +|-----------|-----------|---------------|-------------| +| v2-phase5.AC13.1 | Integration | `tests/orchestrator_test.rs` | Blocked task with `blocker_task_id` metadata gets unblocked when blocker completes | +| v2-phase5.AC13.2 | Integration | `tests/orchestrator_test.rs` | Blocked task whose blocker is still Failed stays Blocked | + +### Integration: Full Lifecycle Test + +| Test Type | Test Location | Description | +|-----------|---------------|-------------| +| Integration | `tests/orchestrator_test.rs` | Full retry-cascade-unblock lifecycle: retry with previous_attempt -> permanent failure -> cascade block -> manual completion -> unblock | + +--- + +## Human Verification + +### End-to-End Smoke Test + +**Justification:** The implementation phases build types, enforcement logic, and orchestrator extensions independently. A human should verify that an agent running with a `Gated` autonomy level and a restricted `SecurityScope` correctly receives context with all new sections (dependency status, previous attempt) and that the code search tool returns results when invoked by the agent. This requires a running LLM provider and is not feasible to automate in CI. + +**Verification approach:** +1. Start the daemon with a test project +2. Create a goal with `--profile coder` and `--autonomy gated` +3. Verify that gate prompts appear at configured points (PlanReview, PreCommit, etc.) +4. Verify that the agent can use the `code_search` tool and receive results +5. Verify that SecurityScope enforcement blocks operations outside allowed paths +6. Simulate a task failure and verify the retry includes previous attempt context + +--- + +## Test File Summary + +| File | Type | Phases Covered | +|------|------|----------------| +| `src/context/agents_md.rs` | Unit (inline) | Phase 1 (AC1) | +| `src/context/mod.rs` | Unit (inline) | Phase 1 (AC2), Phase 2 (AC3, AC4) | +| `src/autonomy.rs` | Unit (inline) | Phase 3 (AC5, AC6, AC14) | +| `src/security/scope.rs` | Unit (inline) | Phase 4 (AC7, AC8) | +| `src/tools/search.rs` | Unit (inline) | Phase 5 (AC9, AC10.2) | +| `tests/security_scope_test.rs` | Integration | Phase 4 (AC7, AC8) | +| `tests/code_search_test.rs` | Integration | Phase 5 (AC9) | +| `tests/orchestrator_test.rs` | Integration | Phase 6 (AC11, AC12, AC13) | diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 9974732..25f7708 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -73,6 +73,12 @@ pub struct AgentContext { /// Graph store for querying and updating nodes pub graph_store: Arc, + + /// Previous attempt outcome description (if this is a retry) + pub previous_attempt: Option, + + /// Dependency statuses: (node_id, title, is_completed) + pub dependency_statuses: Vec<(String, String, bool)>, } impl std::fmt::Debug for AgentContext { @@ -84,6 +90,8 @@ impl std::fmt::Debug for AgentContext { .field("agents_md_summaries", &self.agents_md_summaries.len()) .field("profile", &self.profile) .field("project_path", &self.project_path) + .field("previous_attempt", &self.previous_attempt) + .field("dependency_statuses", &self.dependency_statuses.len()) .finish() } } diff --git a/src/agent/orchestrator.rs b/src/agent/orchestrator.rs index b3a5904..3924412 100644 --- a/src/agent/orchestrator.rs +++ b/src/agent/orchestrator.rs @@ -1,3 +1,4 @@ +use crate::agent::profile::resolve_profile; use crate::agent::runtime::{AgentRuntime, RuntimeConfig}; use crate::agent::work_package::{ FileOwnershipMap, TaskForGrouping, WorkPackage, WorkerHandle, WorkerState, @@ -5,7 +6,6 @@ use crate::agent::work_package::{ }; use crate::agent::worktree::WorktreeManager; use crate::agent::{AgentContext, AgentId, AgentOutcome}; -use crate::agent::profile::resolve_profile; use crate::context::resolve_agents_md; use crate::graph::store::{GraphStore, NodeQuery}; use crate::graph::{ @@ -305,7 +305,10 @@ impl Orchestrator { if let Some(ref wm) = self.worktree_manager && let Err(e) = wm.create_goal_branch(&goal.id) { - tracing::warn!("Failed to create goal branch (continuing without worktrees): {}", e); + tracing::warn!( + "Failed to create goal branch (continuing without worktrees): {}", + e + ); } // Check if tasks already exist under this goal @@ -347,7 +350,10 @@ impl Orchestrator { if let Some(ref wm) = self.worktree_manager && let Err(e) = wm.create_goal_branch(&goal_id) { - tracing::warn!("Failed to create goal branch (continuing without worktrees): {}", e); + tracing::warn!( + "Failed to create goal branch (continuing without worktrees): {}", + e + ); } Ok(OrchestratorState::Planning) @@ -556,7 +562,8 @@ impl Orchestrator { // Check if we should reschedule if self.active_workers.is_empty() { // All workers done — go back to scheduling to check for more work - self.message_bus.remove_subscriber(&"orchestrator".to_string()); + self.message_bus + .remove_subscriber(&"orchestrator".to_string()); return Ok(OrchestratorState::Scheduling); } } @@ -662,19 +669,13 @@ impl Orchestrator { "Tasks: {} completed, {} failed, {} blocked\n", completed, failed, blocked )); - summary.push_str(&format!( - "Total tokens used: {}\n", - self.cumulative_tokens - )); + summary.push_str(&format!("Total tokens used: {}\n", self.cumulative_tokens)); } // If multi-agent mode, report the goal branch if self.worktree_manager.is_some() { let branch = WorktreeManager::goal_branch_name(&goal_id); - summary.push_str(&format!( - "Changes are on branch: {}\n", - branch - )); + summary.push_str(&format!("Changes are on branch: {}\n", branch)); } Ok(OrchestratorResult { @@ -704,19 +705,11 @@ impl Orchestrator { { for node in &subtree { if node.node_type == NodeType::Task - && (node.status == NodeStatus::InProgress - || node.status == NodeStatus::Claimed) + && (node.status == NodeStatus::InProgress || node.status == NodeStatus::Claimed) { let _ = self .graph_store - .update_node( - &node.id, - Some(NodeStatus::Ready), - None, - None, - None, - None, - ) + .update_node(&node.id, Some(NodeStatus::Ready), None, None, None, None) .await; } } @@ -776,23 +769,22 @@ impl Orchestrator { // Resolve AGENTS.md summaries (empty file_scope for planner, actual scope for workers) let file_scope: Vec = package.file_scope.clone(); - let agents_md_summaries = resolve_agents_md(&self.project_path, &file_scope) - .unwrap_or_default(); + let agents_md_summaries = + resolve_agents_md(&self.project_path, &file_scope).unwrap_or_default(); // Determine worker's project path (worktree in multi-agent, original in single-agent) - let worker_project_path = if let (Some(wm), Some(goal_id)) = - (&self.worktree_manager, &self.goal_id) - { - match wm.create_worktree(goal_id, &package.id) { - Ok(path) => path, - Err(e) => { - tracing::warn!("Failed to create worktree, falling back to main: {}", e); - self.project_path.clone() + let worker_project_path = + if let (Some(wm), Some(goal_id)) = (&self.worktree_manager, &self.goal_id) { + match wm.create_worktree(goal_id, &package.id) { + Ok(path) => path, + Err(e) => { + tracing::warn!("Failed to create worktree, falling back to main: {}", e); + self.project_path.clone() + } } - } - } else { - self.project_path.clone() - }; + } else { + self.project_path.clone() + }; // Build AgentContext let ctx = AgentContext { @@ -803,6 +795,8 @@ impl Orchestrator { profile: profile.clone(), project_path: worker_project_path, graph_store: self.graph_store.clone(), + previous_attempt: None, + dependency_statuses: vec![], }; // Build RuntimeConfig from OrchestratorConfig @@ -950,9 +944,7 @@ impl Orchestrator { } // Merge and cleanup worktree on success; preserve on failure - if let (Some(wm), Some(goal_id)) = - (&self.worktree_manager, &self.goal_id) - { + if let (Some(wm), Some(goal_id)) = (&self.worktree_manager, &self.goal_id) { if succeeded { if let Err(e) = wm.merge_work_package(goal_id, &wp_id) { tracing::error!( @@ -996,14 +988,7 @@ impl Orchestrator { for task_id in task_ids { let _ = self .graph_store - .update_node( - task_id, - Some(NodeStatus::Completed), - None, - None, - None, - None, - ) + .update_node(task_id, Some(NodeStatus::Completed), None, None, None, None) .await; } tracing::info!( @@ -1033,14 +1018,7 @@ impl Orchestrator { for task_id in task_ids { let _ = self .graph_store - .update_node( - task_id, - Some(NodeStatus::Ready), - None, - None, - None, - None, - ) + .update_node(task_id, Some(NodeStatus::Ready), None, None, None, None) .await; } tracing::warn!( @@ -1055,11 +1033,7 @@ impl Orchestrator { } /// Handle retry logic for a failed task. - pub async fn handle_task_retry_or_fail( - &mut self, - task_id: &str, - error: &str, - ) -> Result<()> { + pub async fn handle_task_retry_or_fail(&mut self, task_id: &str, error: &str) -> Result<()> { let node = self.graph_store.get_node(task_id).await?; let retry_count: usize = node .as_ref() @@ -1069,9 +1043,7 @@ impl Orchestrator { if retry_count < self.config.max_retries_per_task { // Retry: increment count and reset to Ready - let mut metadata = node - .map(|n| n.metadata.clone()) - .unwrap_or_default(); + let mut metadata = node.map(|n| n.metadata.clone()).unwrap_or_default(); metadata.insert("retry_count".to_string(), (retry_count + 1).to_string()); self.graph_store @@ -1212,7 +1184,9 @@ impl Orchestrator { .map(|p| PathBuf::from(p.trim())) .collect(); - let can_expand = files.iter().all(|f| self.file_locks.can_write(&agent_id, f)); + let can_expand = files + .iter() + .all(|f| self.file_locks.can_write(&agent_id, f)); if can_expand { let _ = self.file_locks.acquire(&agent_id, &files); let _ = self diff --git a/src/agent/runtime.rs b/src/agent/runtime.rs index 82ffe0e..f414aa8 100644 --- a/src/agent/runtime.rs +++ b/src/agent/runtime.rs @@ -31,8 +31,14 @@ impl std::fmt::Debug for RuntimeConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RuntimeConfig") .field("max_turns", &self.max_turns) - .field("max_consecutive_llm_failures", &self.max_consecutive_llm_failures) - .field("max_consecutive_tool_failures", &self.max_consecutive_tool_failures) + .field( + "max_consecutive_llm_failures", + &self.max_consecutive_llm_failures, + ) + .field( + "max_consecutive_tool_failures", + &self.max_consecutive_tool_failures, + ) .field("token_budget", &self.token_budget) .field("token_budget_warning_pct", &self.token_budget_warning_pct) .field("message_bus", &self.message_bus.is_some()) diff --git a/src/context/agents_md.rs b/src/context/agents_md.rs index 7071859..098b5a1 100644 --- a/src/context/agents_md.rs +++ b/src/context/agents_md.rs @@ -139,10 +139,7 @@ mod tests { fn test_extract_heading_summaries_empty_sections() -> Result<()> { let tmpdir = TempDir::new()?; let agents_md_path = tmpdir.path().join("AGENTS.md"); - fs::write( - &agents_md_path, - "# First\n\n\n# Second\nContent\n# Third\n", - )?; + fs::write(&agents_md_path, "# First\n\n\n# Second\nContent\n# Third\n")?; let summaries = extract_heading_summaries(&agents_md_path)?; assert_eq!(summaries.len(), 3); @@ -158,7 +155,10 @@ mod tests { let project_root = tmpdir.path(); // Create AGENTS.md at root - fs::write(project_root.join("AGENTS.md"), "# Root\nroot content\n# Guidelines")?; + fs::write( + project_root.join("AGENTS.md"), + "# Root\nroot content\n# Guidelines", + )?; // Create a file to scope fs::write(project_root.join("main.rs"), "fn main() {}")?; diff --git a/src/context/mod.rs b/src/context/mod.rs index 96adb97..5e1cb76 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -483,6 +483,8 @@ mod tests { profile, project_path: PathBuf::from("/test/project"), graph_store: Arc::new(TestGraphStore), + previous_attempt: None, + dependency_statuses: vec![], }; // Build system prompt diff --git a/src/daemon/api/agents.rs b/src/daemon/api/agents.rs index 209e2b7..1c36cdb 100644 --- a/src/daemon/api/agents.rs +++ b/src/daemon/api/agents.rs @@ -1,7 +1,7 @@ use super::{ApiError, AppState}; use crate::graph::{NodeStatus, NodeType}; -use axum::extract::{Path, State}; use axum::Json; +use axum::extract::{Path, State}; use serde::Serialize; #[derive(Serialize)] diff --git a/src/daemon/api/graph.rs b/src/daemon/api/graph.rs index ea9101c..db05d5f 100644 --- a/src/daemon/api/graph.rs +++ b/src/daemon/api/graph.rs @@ -2,13 +2,16 @@ use super::{ApiError, AppState}; use crate::graph::store::{EdgeDirection, NodeQuery}; use crate::graph::{self, GraphEdge, GraphNode, NodeStatus, NodeType, Priority}; use crate::graph::{interchange, session::SessionStore}; +use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::Json; use serde::{Deserialize, Serialize}; /// Resolve a project path parameter (name or ID) to the actual project ID -pub(super) async fn resolve_project_id(state: &AppState, id_or_name: &str) -> Result { +pub(super) async fn resolve_project_id( + state: &AppState, + id_or_name: &str, +) -> Result { if let Some(p) = state.project_store.get_by_name(id_or_name).await? { return Ok(p.id); } @@ -280,9 +283,7 @@ pub async fn create_edge( .graph_store .get_node(&body.from_node) .await? - .ok_or_else(|| { - ApiError::BadRequest(format!("From node '{}' not found", body.from_node)) - })?; + .ok_or_else(|| ApiError::BadRequest(format!("From node '{}' not found", body.from_node)))?; state .graph_store .get_node(&body.to_node) @@ -372,10 +373,7 @@ pub async fn list_decisions( Path(id_or_name): Path, ) -> Result>, ApiError> { let project_id = resolve_project_id(&state, &id_or_name).await?; - let decisions = state - .graph_store - .get_active_decisions(&project_id) - .await?; + let decisions = state.graph_store.get_active_decisions(&project_id).await?; Ok(Json(decisions)) } @@ -436,12 +434,9 @@ pub async fn export_decisions( .ok_or_else(|| ApiError::NotFound(format!("Project '{}' not found", id_or_name)))?; let output_dir = project.path.join("decisions"); - let files = crate::graph::export::export_adrs( - state.graph_store.as_ref(), - &project.id, - &output_dir, - ) - .await?; + let files = + crate::graph::export::export_adrs(state.graph_store.as_ref(), &project.id, &output_dir) + .await?; let paths: Vec = files.iter().map(|p| p.display().to_string()).collect(); Ok(Json(paths)) @@ -489,12 +484,8 @@ pub async fn export_all_goals( let mut results = Vec::new(); for goal in goals { - let toml_content = interchange::export_goal( - state.graph_store.as_ref(), - &goal.id, - &project_id, - ) - .await?; + let toml_content = + interchange::export_goal(state.graph_store.as_ref(), &goal.id, &project_id).await?; results.push(ExportResult { goal_id: goal.id, toml: toml_content, @@ -536,8 +527,7 @@ pub async fn import_graph( _ => interchange::ImportStrategy::Merge, }; - let result = - interchange::import_goal(state.graph_store.as_ref(), &body.toml, strategy).await?; + let result = interchange::import_goal(state.graph_store.as_ref(), &body.toml, strategy).await?; Ok(Json(result)) } diff --git a/src/daemon/api/mod.rs b/src/daemon/api/mod.rs index e0edf79..b6a5f82 100644 --- a/src/daemon/api/mod.rs +++ b/src/daemon/api/mod.rs @@ -7,9 +7,9 @@ use crate::db::Database; use crate::graph::store::GraphStore; use crate::message::MessageBus; use crate::project::ProjectStore; +use axum::Json; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use axum::Json; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; diff --git a/src/daemon/api/projects.rs b/src/daemon/api/projects.rs index a667d20..a0d1888 100644 --- a/src/daemon/api/projects.rs +++ b/src/daemon/api/projects.rs @@ -1,8 +1,8 @@ use super::{ApiError, AppState}; use crate::project::Project; +use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::Json; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] @@ -52,9 +52,10 @@ pub async fn create_project( match state.project_store.add(&body.name, &canonical).await { Ok(project) => Ok((StatusCode::CREATED, Json(ProjectResponse::from(project)))), - Err(e) if e.to_string().contains("UNIQUE constraint") => Err(ApiError::Conflict( - format!("Project '{}' already exists", body.name), - )), + Err(e) if e.to_string().contains("UNIQUE constraint") => Err(ApiError::Conflict(format!( + "Project '{}' already exists", + body.name + ))), Err(e) => Err(ApiError::Internal(e.to_string())), } } diff --git a/src/daemon/api/search.rs b/src/daemon/api/search.rs index a8e1908..60a1d6d 100644 --- a/src/daemon/api/search.rs +++ b/src/daemon/api/search.rs @@ -1,7 +1,7 @@ use super::{ApiError, AppState}; use crate::graph::{GraphNode, NodeType}; -use axum::extract::{Path, State}; use axum::Json; +use axum::extract::{Path, State}; use serde::Deserialize; #[derive(Deserialize)] diff --git a/src/daemon/client.rs b/src/daemon/client.rs index aa5c0fb..79653f2 100644 --- a/src/daemon/client.rs +++ b/src/daemon/client.rs @@ -1,5 +1,5 @@ -use crate::daemon::api::projects::ProjectResponse; use crate::daemon::DaemonConfig; +use crate::daemon::api::projects::ProjectResponse; use crate::graph::GraphNode; use anyhow::Result; use reqwest::Client; diff --git a/src/daemon/server.rs b/src/daemon/server.rs index f2b93f3..4c1424b 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -1,7 +1,7 @@ +use crate::daemon::DaemonConfig; use crate::daemon::api::AppState; use crate::daemon::api::{agents, graph, projects, search}; use crate::daemon::ws; -use crate::daemon::DaemonConfig; use axum::routing::{delete, get, post}; use axum::{Json, Router}; use tokio_util::sync::CancellationToken; @@ -47,10 +47,7 @@ pub fn create_router(state: AppState) -> Router { .route("/api/goals/{id}/tasks/ready", get(graph::list_ready_tasks)) .route("/api/goals/{id}/tasks/next", get(graph::next_task)) // Decisions - .route( - "/api/projects/{id}/decisions", - get(graph::list_decisions), - ) + .route("/api/projects/{id}/decisions", get(graph::list_decisions)) .route( "/api/projects/{id}/decisions/history", get(graph::decisions_history), @@ -60,10 +57,7 @@ pub fn create_router(state: AppState) -> Router { post(graph::export_decisions), ) // Search - .route( - "/api/projects/{id}/search", - post(search::search_nodes), - ) + .route("/api/projects/{id}/search", post(search::search_nodes)) // Sessions .route("/api/goals/{id}/sessions", get(graph::list_sessions)) .route("/api/sessions/{id}", get(graph::get_session)) @@ -75,14 +69,8 @@ pub fn create_router(state: AppState) -> Router { get(graph::export_all_goals), ) .route("/api/goals/{id}/export", get(graph::export_goal_toml)) - .route( - "/api/projects/{id}/graph/import", - post(graph::import_graph), - ) - .route( - "/api/projects/{id}/graph/diff", - post(graph::diff_graph), - ) + .route("/api/projects/{id}/graph/import", post(graph::import_graph)) + .route("/api/projects/{id}/graph/diff", post(graph::diff_graph)) // WebSocket .route("/ws", get(ws::ws_handler)) // Static file serving (fallback for non-API routes) diff --git a/src/daemon/ws.rs b/src/daemon/ws.rs index 15e2430..6b55fe7 100644 --- a/src/daemon/ws.rs +++ b/src/daemon/ws.rs @@ -1,17 +1,14 @@ use crate::agent::AgentId; use crate::daemon::api::{AppState, WsEvent}; use crate::message::{MessageBus, WorkerMessage}; -use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::response::IntoResponse; use std::sync::Arc; use tokio::sync::broadcast; /// WS /ws — WebSocket upgrade handler -pub async fn ws_handler( - ws: WebSocketUpgrade, - State(state): State, -) -> impl IntoResponse { +pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { ws.on_upgrade(move |socket| handle_socket(socket, state.ws_tx.subscribe())) } diff --git a/src/main.rs b/src/main.rs index b80fd33..fabfc45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -432,10 +432,9 @@ async fn main() -> anyhow::Result<()> { })?; // Create shared dependencies - let graph_store: std::sync::Arc = - std::sync::Arc::new(rustagent::graph::store::SqliteGraphStore::new( - database.clone(), - )); + let graph_store: std::sync::Arc = std::sync::Arc::new( + rustagent::graph::store::SqliteGraphStore::new(database.clone()), + ); let llm_client = rustagent::llm::factory::create_client(&config, &config.llm)?; let security_validator = std::sync::Arc::new( rustagent::security::SecurityValidator::new(config.security.clone())?, @@ -507,23 +506,19 @@ async fn main() -> anyhow::Result<()> { let projects = client.projects_list().await?; display_project_list(&projects); } - ProjectAction::Show { name } => { - match client.project_get(&name).await { - Ok(proj) => { - println!("Project: {}", proj.name); - println!(" ID: {}", proj.id); - println!(" Path: {}", proj.path); - println!(" Registered: {}", proj.registered_at); - } - Err(_) => println!("Project '{}' not found", name), - } - } - ProjectAction::Remove { name } => { - match client.project_remove(&name).await { - Ok(()) => println!("Removed project '{}'", name), - Err(_) => println!("Project '{}' not found", name), + ProjectAction::Show { name } => match client.project_get(&name).await { + Ok(proj) => { + println!("Project: {}", proj.name); + println!(" ID: {}", proj.id); + println!(" Path: {}", proj.path); + println!(" Registered: {}", proj.registered_at); } - } + Err(_) => println!("Project '{}' not found", name), + }, + ProjectAction::Remove { name } => match client.project_remove(&name).await { + Ok(()) => println!("Removed project '{}'", name), + Err(_) => println!("Project '{}' not found", name), + }, } return Ok(()); } @@ -638,10 +633,7 @@ async fn main() -> anyhow::Result<()> { let tasks = client.tasks_list(&goal.id).await?; println!("Task tree for {}:", goal.id); for node in &tasks { - println!( - " - {} ({}): {}", - node.id, node.status, node.title - ); + println!(" - {} ({}): {}", node.id, node.status, node.title); } } else { println!("No goals found for project"); @@ -651,9 +643,7 @@ async fn main() -> anyhow::Result<()> { } } None => { - println!( - "Please specify a task action: list, ready, next, or tree" - ); + println!("Please specify a task action: list, ready, next, or tree"); } } return Ok(()); @@ -896,15 +886,11 @@ async fn main() -> anyhow::Result<()> { let total = tasks.len(); let completed = tasks .iter() - .filter(|t| { - t.status == rustagent::graph::NodeStatus::Completed - }) + .filter(|t| t.status == rustagent::graph::NodeStatus::Completed) .count(); let in_progress = tasks .iter() - .filter(|t| { - t.status == rustagent::graph::NodeStatus::InProgress - }) + .filter(|t| t.status == rustagent::graph::NodeStatus::InProgress) .count(); let ready = tasks .iter() @@ -912,9 +898,7 @@ async fn main() -> anyhow::Result<()> { .count(); let blocked = tasks .iter() - .filter(|t| { - t.status == rustagent::graph::NodeStatus::Blocked - }) + .filter(|t| t.status == rustagent::graph::NodeStatus::Blocked) .count(); let failed = tasks .iter() @@ -1138,44 +1122,47 @@ async fn main() -> anyhow::Result<()> { } Err(e) => println!("Failed to read file: {}", e), }, - GraphAction::Diff { path: diff_path } => match std::fs::read_to_string(&diff_path) { - Ok(content) => { - match rustagent::graph::interchange::diff_goal(&graph_store, &content).await - { - Ok(result) => { - println!("Diff results for {}:", diff_path); - if !result.added_nodes.is_empty() { - println!(" Added nodes: {}", result.added_nodes.len()); - for node_id in &result.added_nodes { - println!(" + {}", node_id); + GraphAction::Diff { path: diff_path } => { + match std::fs::read_to_string(&diff_path) { + Ok(content) => { + match rustagent::graph::interchange::diff_goal(&graph_store, &content) + .await + { + Ok(result) => { + println!("Diff results for {}:", diff_path); + if !result.added_nodes.is_empty() { + println!(" Added nodes: {}", result.added_nodes.len()); + for node_id in &result.added_nodes { + println!(" + {}", node_id); + } } - } - if !result.changed_nodes.is_empty() { - println!(" Changed nodes: {}", result.changed_nodes.len()); - for (node_id, fields) in &result.changed_nodes { - println!(" ~ {} ({})", node_id, fields.join(", ")); + if !result.changed_nodes.is_empty() { + println!(" Changed nodes: {}", result.changed_nodes.len()); + for (node_id, fields) in &result.changed_nodes { + println!(" ~ {} ({})", node_id, fields.join(", ")); + } } - } - if !result.removed_nodes.is_empty() { - println!(" Removed nodes: {}", result.removed_nodes.len()); - for node_id in &result.removed_nodes { - println!(" - {}", node_id); + if !result.removed_nodes.is_empty() { + println!(" Removed nodes: {}", result.removed_nodes.len()); + for node_id in &result.removed_nodes { + println!(" - {}", node_id); + } } + if !result.added_edges.is_empty() { + println!(" Added edges: {}", result.added_edges.len()); + } + if !result.removed_edges.is_empty() { + println!(" Removed edges: {}", result.removed_edges.len()); + } + println!(" Unchanged nodes: {}", result.unchanged_nodes); + println!(" Unchanged edges: {}", result.unchanged_edges); } - if !result.added_edges.is_empty() { - println!(" Added edges: {}", result.added_edges.len()); - } - if !result.removed_edges.is_empty() { - println!(" Removed edges: {}", result.removed_edges.len()); - } - println!(" Unchanged nodes: {}", result.unchanged_nodes); - println!(" Unchanged edges: {}", result.unchanged_edges); + Err(e) => println!("Diff failed: {}", e), } - Err(e) => println!("Diff failed: {}", e), } + Err(e) => println!("Failed to read file: {}", e), } - Err(e) => println!("Failed to read file: {}", e), - }, + } } } Commands::Daemon { action } => { @@ -1213,10 +1200,9 @@ async fn main() -> anyhow::Result<()> { let database = db::Database::open(&db_path).await?; // Create shared dependencies - let graph_store: std::sync::Arc = - std::sync::Arc::new(rustagent::graph::store::SqliteGraphStore::new( - database.clone(), - )); + let graph_store: std::sync::Arc = std::sync::Arc::new( + rustagent::graph::store::SqliteGraphStore::new(database.clone()), + ); let message_bus: std::sync::Arc = std::sync::Arc::new(rustagent::message::TokioMessageBus::default()); @@ -1227,10 +1213,8 @@ async fn main() -> anyhow::Result<()> { ); // Start the MessageBus-to-WebSocket bridge - let _ws_bridge = rustagent::daemon::ws::start_ws_bridge( - message_bus, - state.ws_tx.clone(), - ); + let _ws_bridge = + rustagent::daemon::ws::start_ws_bridge(message_bus, state.ws_tx.clone()); println!( "Daemon listening on {}:{}", @@ -1238,37 +1222,31 @@ async fn main() -> anyhow::Result<()> { ); // Start the HTTP server (blocks until shutdown) - rustagent::daemon::server::start_server(&config, state, shutdown_token) - .await?; + rustagent::daemon::server::start_server(&config, state, shutdown_token).await?; // Cleanup rustagent::daemon::remove_pid_file(&cleanup_config)?; println!("Daemon stopped."); } - DaemonAction::Stop => { - match rustagent::daemon::read_pid_file(&config)? { - Some(pid) => { - if !rustagent::daemon::is_daemon_running(&config)? { - println!( - "Stale PID file (process {} not running). Cleaning up.", - pid - ); - rustagent::daemon::remove_pid_file(&config)?; - return Ok(()); - } - - println!("Stopping daemon (PID {})...", pid); - #[cfg(unix)] - unsafe { - libc::kill(pid as i32, libc::SIGTERM); - } - println!("Signal sent. Daemon should stop shortly."); + DaemonAction::Stop => match rustagent::daemon::read_pid_file(&config)? { + Some(pid) => { + if !rustagent::daemon::is_daemon_running(&config)? { + println!("Stale PID file (process {} not running). Cleaning up.", pid); + rustagent::daemon::remove_pid_file(&config)?; + return Ok(()); } - None => { - println!("No daemon is running (no PID file found)."); + + println!("Stopping daemon (PID {})...", pid); + #[cfg(unix)] + unsafe { + libc::kill(pid as i32, libc::SIGTERM); } + println!("Signal sent. Daemon should stop shortly."); } - } + None => { + println!("No daemon is running (no PID file found)."); + } + }, DaemonAction::Status => { if rustagent::daemon::is_daemon_running(&config)? { let pid = rustagent::daemon::read_pid_file(&config)?.unwrap(); @@ -1291,16 +1269,10 @@ async fn main() -> anyhow::Result<()> { let mut entries: Vec<_> = std::fs::read_dir(log_dir)? .filter_map(|e| e.ok()) - .filter(|e| { - e.path() - .extension() - .map_or(false, |ext| ext == "log") - }) + .filter(|e| e.path().extension().map_or(false, |ext| ext == "log")) .collect(); entries.sort_by_key(|e| { - std::cmp::Reverse( - e.metadata().ok().and_then(|m| m.modified().ok()), - ) + std::cmp::Reverse(e.metadata().ok().and_then(|m| m.modified().ok())) }); if entries.is_empty() { diff --git a/src/message.rs b/src/message.rs index b3c3002..9fc3b75 100644 --- a/src/message.rs +++ b/src/message.rs @@ -120,7 +120,10 @@ impl Default for TokioMessageBus { impl MessageBus for TokioMessageBus { async fn send(&self, to: &AgentId, msg: WorkerMessage) -> Result<()> { let sender = { - let channels = self.agent_channels.lock().map_err(|e| anyhow!("lock poisoned: {}", e))?; + let channels = self + .agent_channels + .lock() + .map_err(|e| anyhow!("lock poisoned: {}", e))?; channels.get(to).cloned() }; match sender { diff --git a/tests/agent_runtime_test.rs b/tests/agent_runtime_test.rs index f4d14c3..0369c2c 100644 --- a/tests/agent_runtime_test.rs +++ b/tests/agent_runtime_test.rs @@ -30,6 +30,8 @@ fn make_test_context() -> AgentContext { }, project_path: PathBuf::from("/tmp/test"), graph_store: Arc::new(MockGraphStore), + previous_attempt: None, + dependency_statuses: vec![], } } diff --git a/tests/agent_tools_test.rs b/tests/agent_tools_test.rs index 79c67c2..1ef98d9 100644 --- a/tests/agent_tools_test.rs +++ b/tests/agent_tools_test.rs @@ -21,7 +21,8 @@ async fn test_spawn_sub_agent_creates_child_node() { let goal = common::create_test_goal("ra-test", "proj-1", "Test goal"); graph_store.create_node(&goal).await.unwrap(); - let task = common::create_test_task("ra-test.1", "proj-1", "Parent task", NodeStatus::InProgress); + let task = + common::create_test_task("ra-test.1", "proj-1", "Parent task", NodeStatus::InProgress); graph_store.create_node(&task).await.unwrap(); let tool = SpawnSubAgentTool::new(graph_store.clone(), message_bus, "worker-1".to_string()); @@ -77,7 +78,11 @@ async fn test_spawn_sub_agent_broadcasts_message() { let task = common::create_test_task("ra-test.1", "proj-1", "Parent", NodeStatus::InProgress); graph_store.create_node(&task).await.unwrap(); - let tool = SpawnSubAgentTool::new(graph_store.clone(), message_bus.clone(), "worker-1".to_string()); + let tool = SpawnSubAgentTool::new( + graph_store.clone(), + message_bus.clone(), + "worker-1".to_string(), + ); tool.execute(json!({ "title": "Broadcast test", @@ -312,7 +317,12 @@ async fn test_send_message_unknown_type() { .unwrap(); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); - assert!(parsed["error"].as_str().unwrap().contains("unknown message_type")); + assert!( + parsed["error"] + .as_str() + .unwrap() + .contains("unknown message_type") + ); } // ===== QueryAgentStatusTool Tests ===== @@ -327,11 +337,13 @@ async fn test_query_agent_status_with_tasks() { let goal = common::create_test_goal("ra-test", "proj-1", "Test goal"); graph_store.create_node(&goal).await.unwrap(); - let mut task1 = common::create_test_task("ra-test.1", "proj-1", "Task A", NodeStatus::InProgress); + let mut task1 = + common::create_test_task("ra-test.1", "proj-1", "Task A", NodeStatus::InProgress); task1.assigned_to = Some("worker-1".to_string()); graph_store.create_node(&task1).await.unwrap(); - let mut task2 = common::create_test_task("ra-test.2", "proj-1", "Task B", NodeStatus::Completed); + let mut task2 = + common::create_test_task("ra-test.2", "proj-1", "Task B", NodeStatus::Completed); task2.assigned_to = Some("worker-1".to_string()); graph_store.create_node(&task2).await.unwrap(); @@ -388,7 +400,8 @@ async fn test_tool_trait_compliance() { let message_bus: Arc = Arc::new(TokioMessageBus::default()); // SpawnSubAgentTool - let spawn_tool = SpawnSubAgentTool::new(graph_store.clone(), message_bus.clone(), "w1".to_string()); + let spawn_tool = + SpawnSubAgentTool::new(graph_store.clone(), message_bus.clone(), "w1".to_string()); assert_eq!(spawn_tool.name(), "spawn_sub_agent"); assert!(!spawn_tool.description().is_empty()); let params = spawn_tool.parameters(); diff --git a/tests/agent_types_test.rs b/tests/agent_types_test.rs index ef7de63..49528c9 100644 --- a/tests/agent_types_test.rs +++ b/tests/agent_types_test.rs @@ -81,6 +81,8 @@ fn test_agent_context_construction() { profile, project_path: PathBuf::from("/tmp"), graph_store: Arc::new(MockGraphStore), + previous_attempt: None, + dependency_statuses: vec![], }; // If this compiles, the struct is correctly defined } @@ -179,6 +181,8 @@ async fn test_mock_agent_run() { profile: agent.profile().clone(), project_path: PathBuf::from("/tmp"), graph_store: Arc::new(MockGraphStore), + previous_attempt: None, + dependency_statuses: vec![], }; let result = agent.run(ctx).await; diff --git a/tests/daemon_api_test.rs b/tests/daemon_api_test.rs index 4f4e16f..c698ccd 100644 --- a/tests/daemon_api_test.rs +++ b/tests/daemon_api_test.rs @@ -12,8 +12,7 @@ async fn create_test_state() -> AppState { let db = Database::open(Path::new(":memory:")).await.unwrap(); let graph_store: Arc = Arc::new(SqliteGraphStore::new(db.clone())); - let message_bus: Arc = - Arc::new(TokioMessageBus::default()); + let message_bus: Arc = Arc::new(TokioMessageBus::default()); AppState::new(db, graph_store, message_bus) } diff --git a/tests/daemon_client_test.rs b/tests/daemon_client_test.rs index 69dfb3f..17b0ff7 100644 --- a/tests/daemon_client_test.rs +++ b/tests/daemon_client_test.rs @@ -1,5 +1,5 @@ -use rustagent::daemon::client::{detect_daemon, DaemonClient}; use rustagent::daemon::DaemonConfig; +use rustagent::daemon::client::{DaemonClient, detect_daemon}; use std::path::Path; use tempfile::TempDir; @@ -106,10 +106,12 @@ async fn test_health_with_test_server() { // GET to nonexistent API path hits the fallback handler (returns 200 with fallback JSON) let fallback: serde_json::Value = client.get("/api/nonexistent").await.unwrap(); - assert!(fallback["message"] - .as_str() - .unwrap() - .contains("not bundled")); + assert!( + fallback["message"] + .as_str() + .unwrap() + .contains("not bundled") + ); server_handle.abort(); } diff --git a/tests/daemon_graph_api_test.rs b/tests/daemon_graph_api_test.rs index 5913cea..57eb6f1 100644 --- a/tests/daemon_graph_api_test.rs +++ b/tests/daemon_graph_api_test.rs @@ -13,8 +13,7 @@ async fn create_test_state() -> AppState { let db = Database::open(Path::new(":memory:")).await.unwrap(); let graph_store: Arc = Arc::new(SqliteGraphStore::new(db.clone())); - let message_bus: Arc = - Arc::new(TokioMessageBus::default()); + let message_bus: Arc = Arc::new(TokioMessageBus::default()); AppState::new(db, graph_store, message_bus) } @@ -128,10 +127,12 @@ async fn test_create_child_node() { let json = response_json(response).await; assert_eq!(json["node_type"], "task"); - assert!(json["id"] - .as_str() - .unwrap() - .starts_with(&format!("{}.", goal_id))); + assert!( + json["id"] + .as_str() + .unwrap() + .starts_with(&format!("{}.", goal_id)) + ); } #[tokio::test] diff --git a/tests/daemon_static_test.rs b/tests/daemon_static_test.rs index e2b43fa..50ca646 100644 --- a/tests/daemon_static_test.rs +++ b/tests/daemon_static_test.rs @@ -12,8 +12,7 @@ async fn create_test_state() -> AppState { let db = Database::open(Path::new(":memory:")).await.unwrap(); let graph_store: Arc = Arc::new(SqliteGraphStore::new(db.clone())); - let message_bus: Arc = - Arc::new(TokioMessageBus::default()); + let message_bus: Arc = Arc::new(TokioMessageBus::default()); AppState::new(db, graph_store, message_bus) } @@ -23,10 +22,7 @@ async fn test_fallback_without_bundle_ui() { let router = rustagent::daemon::server::create_router(state); // GET / should return fallback message (no bundle-ui feature) - let request = Request::builder() - .uri("/") - .body(Body::empty()) - .unwrap(); + let request = Request::builder().uri("/").body(Body::empty()).unwrap(); let response = router.clone().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); diff --git a/tests/daemon_test.rs b/tests/daemon_test.rs index 8cac568..4b13d2b 100644 --- a/tests/daemon_test.rs +++ b/tests/daemon_test.rs @@ -6,10 +6,7 @@ fn test_daemon_config_defaults() { let config = DaemonConfig::default(); assert_eq!(config.bind_address, "127.0.0.1"); assert_eq!(config.port, 7400); - assert!(config - .pid_file - .to_string_lossy() - .contains("rustagent.pid")); + assert!(config.pid_file.to_string_lossy().contains("rustagent.pid")); } #[test] diff --git a/tests/graph_tools_test.rs b/tests/graph_tools_test.rs index fb0af5f..c8f67c2 100644 --- a/tests/graph_tools_test.rs +++ b/tests/graph_tools_test.rs @@ -1,12 +1,12 @@ +use rustagent::config::SecurityConfig; use rustagent::db::Database; use rustagent::graph::store::{GraphStore, SqliteGraphStore}; use rustagent::graph::{EdgeType, NodeStatus, NodeType}; -use rustagent::tools::Tool; -use rustagent::tools::graph_tools::*; -use rustagent::tools::factory::create_v2_registry; use rustagent::security::SecurityValidator; use rustagent::security::permission::AutoApproveHandler; -use rustagent::config::SecurityConfig; +use rustagent::tools::Tool; +use rustagent::tools::factory::create_v2_registry; +use rustagent::tools::graph_tools::*; use serde_json::{Value, json}; use std::sync::Arc; @@ -658,7 +658,8 @@ fn test_v2_registry_includes_all_tools() { max_file_size_mb: 100, allowed_paths: vec![], }; - let validator = Arc::new(SecurityValidator::new(security_config).expect("Failed to create validator")); + let validator = + Arc::new(SecurityValidator::new(security_config).expect("Failed to create validator")); let permission_handler = Arc::new(AutoApproveHandler); // Create the v2 registry diff --git a/tests/orchestrator_test.rs b/tests/orchestrator_test.rs index 72c7ef6..d44ffc3 100644 --- a/tests/orchestrator_test.rs +++ b/tests/orchestrator_test.rs @@ -210,7 +210,12 @@ async fn test_task_retry_on_failure() { goal.status = rustagent::graph::NodeStatus::Active; graph_store.create_node(&goal).await.unwrap(); - let task = common::create_test_task("ra-test.1", "proj-1", "Test task", rustagent::graph::NodeStatus::Ready); + let task = common::create_test_task( + "ra-test.1", + "proj-1", + "Test task", + rustagent::graph::NodeStatus::Ready, + ); graph_store.create_node(&task).await.unwrap(); let mut config = OrchestratorConfig::default(); @@ -597,7 +602,11 @@ async fn test_run_with_shutdown_cancels() { assert_eq!(result.cumulative_tokens, 5000); // InProgress task should be reset to Ready - let task = graph_store.get_node("ra-shutdown.1").await.unwrap().unwrap(); + let task = graph_store + .get_node("ra-shutdown.1") + .await + .unwrap() + .unwrap(); assert_eq!(task.status, rustagent::graph::NodeStatus::Ready); } diff --git a/tests/work_package_test.rs b/tests/work_package_test.rs index 019ccd0..fa24909 100644 --- a/tests/work_package_test.rs +++ b/tests/work_package_test.rs @@ -1,5 +1,5 @@ -use rustagent::agent::work_package::*; use rustagent::agent::AgentOutcome; +use rustagent::agent::work_package::*; use rustagent::graph::Priority; use std::path::PathBuf; @@ -118,7 +118,12 @@ fn test_worker_state_variants() { #[tokio::test] async fn test_worker_handle_fields() { let cancel_token = tokio_util::sync::CancellationToken::new(); - let handle = tokio::spawn(async { Ok(AgentOutcome::Completed { summary: "done".to_string(), tokens_used: 0 }) }); + let handle = tokio::spawn(async { + Ok(AgentOutcome::Completed { + summary: "done".to_string(), + tokens_used: 0, + }) + }); let now = chrono::Utc::now(); let wh = WorkerHandle { @@ -224,7 +229,12 @@ fn test_work_package_id_format() { let id = generate_work_package_id(); assert!(id.starts_with("wp-"), "ID should start with 'wp-': {}", id); let hex_part = &id[3..]; - assert_eq!(hex_part.len(), 8, "Hex part should be 8 chars: {}", hex_part); + assert_eq!( + hex_part.len(), + 8, + "Hex part should be 8 chars: {}", + hex_part + ); assert!( hex_part.chars().all(|c| c.is_ascii_hexdigit()), "Should be hex: {}", @@ -265,9 +275,7 @@ fn test_complexity_estimation() { // 8 files -> Large let tasks = vec![TaskForGrouping { task_id: "t1".to_string(), - file_scope: (0..8) - .map(|i| PathBuf::from(format!("{}.rs", i))) - .collect(), + file_scope: (0..8).map(|i| PathBuf::from(format!("{}.rs", i))).collect(), profile: "coder".to_string(), priority: Priority::Medium, depends_on: vec![], diff --git a/tests/worktree_test.rs b/tests/worktree_test.rs index 7c0aa09..2c0ae8a 100644 --- a/tests/worktree_test.rs +++ b/tests/worktree_test.rs @@ -279,8 +279,7 @@ fn test_gitignore_appends_to_existing() { #[tokio::test] async fn test_single_agent_no_worktree_branch_in_summary() { let (_, graph_store) = common::setup_test_env().await.unwrap(); - let graph_store: Arc = - Arc::new(graph_store); + let graph_store: Arc = Arc::new(graph_store); use rustagent::agent::orchestrator::{Orchestrator, OrchestratorConfig}; use rustagent::config::{SecurityConfig, ShellPolicy}; -- 2.51.2