diff --git a/.gitignore b/.gitignore index 63de120..f4013e6 100644 --- a/.gitignore +++ b/.gitignore @@ -217,3 +217,7 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Codeband — agent credentials (contains API keys) +agent_config.yaml +AGENTS.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0eefe2f..894bf84 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -51,13 +51,26 @@ Parallelism is a secondary, weaker benefit — Claude Code and Codex both alread │Reviewer- │ adversarial review │Reviewer- │ │Codex-0 (pool)│ │Claude-0(pool)│ └──────┬───────┘ └──────┬───────┘ - │ verdict │ + │ review_passed │ └────────────┬───────────────────────┘ + │ + ┌───────────▼──────────┐ (when verifier configured) + │ Verifier-Codex-0 │ evidence-integrity acceptance gate + │ (pool) │ cb-phase verify-acceptance + └───────────┬──────────┘ + │ acceptance_passed ▼ ┌─────────────┐ ┌──────────┐ │ Mergemaster │ │ Watchdog │ Monitors health, │ (singleton) │ │ │ nudges stuck agents - └─────────────┘ └──────────┘ + └──────┬──────┘ └──────────┘ + │ cb-phase merge (gated) + ▼ + ┌────────────────────────────────────────────────────┐ + │ State Store (workspace/state/orchestration.db) │ + │ SQLite WAL — tasks · subtask_states · transition_ │ + │ log · audit_log — both chains hash-linked │ + └────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────┐ │ Protocol State (blackboard) │ @@ -80,12 +93,13 @@ Parallelism is a secondary, weaker benefit — Claude Code and Codex both alread | Role | Description | Frameworks | |------|-------------|------------| -| **Planner** | Task analyst. Reads the codebase, decomposes tasks into parallelizable subtasks with optional `framework_hint`, sends plans via chat. Emits abstract subtask specs — the Conductor binds specific coders at dispatch. | `claude_sdk` today; Codex planned | +| **Planner** | Task analyst. Reads the codebase, decomposes tasks into parallelizable subtasks with optional `framework_hint`, sends plans via chat. Emits abstract subtask specs — the Conductor binds specific coders at dispatch. | `claude_sdk`, `codex` | | **Plan Reviewer** | Plan validation gate. Reviews plans before coders begin — decomposition quality, file conflict risk, acceptance criteria. Paired with Planner on the opposite framework. Read-only codebase access. | `claude_sdk`, `codex` | | **Coder** | Coding worker. Executes subtasks in an isolated git worktree (`workspace/worktrees//`). Auto-restarted by `WorkerSupervisor` on crash. | `claude_sdk`, `codex` | | **Code Reviewer** | Code quality gate. Reviews PRs, posts findings as PR comments, assigns a risk level. Directly @mentioned by the Coder on the framework **opposite** the coder. | `claude_sdk`, `codex` | +| **Verifier** | Evidence-integrity acceptance gate. Runs after code review passes (`review_passed`) and before merge — the last SHA-pinned gate. Renders the `verify_acceptance` verdict via `cb-phase verify-acceptance --accept/--reject`. `--accept` → `acceptance_passed` (eligible for merge); `--reject` → `review_failed` (back to the review-round loop, including its cap). Runs in an isolated scratch directory with `gh` CLI access. Opposite-vendor pairing is the ideal (same benefit as cross-model code review) but a single-vendor Verifier degrades gracefully (`cb doctor` warns). Setting `agents.verifiers.count: 0` opts out — tasks then merge straight from `review_passed`. Implementation: `agents/verifier.py`. | `claude_sdk`, `codex` | -**Capacity is declared in yaml** under `agents.{planners, plan_reviewers, coders, reviewers}`, with a `count` per framework. The Conductor allocates task owners and fallback routes. First-dispatch reviewer selection is direct: Coders and Planners use the Worker Pool Roster to @mention deterministic opposite-framework reviewers by display name. +**Capacity is declared in yaml** under `agents.{planners, plan_reviewers, coders, reviewers, verifiers}`, with a `count` per framework. The Conductor allocates task owners and fallback routes. First-dispatch reviewer selection is direct: Coders and Planners use the Worker Pool Roster to @mention deterministic opposite-framework reviewers by display name. ## Communication: Three Channels @@ -210,14 +224,122 @@ Each coder has a persistent **workspace branch** (`codeband/coder--/` -8. **Coder reports completion** → @mention Conductor and a cross-model Reviewer with PR URL + framework +7. **Coders work in parallel** — each in its own worktree, branch `codeband//`; Coder runs `cb-phase start ` at pickup (seeds `in_progress` in the state store) +8. **Coder reports completion** → runs `cb-phase verify --pr ` (gates: clean tree, PR open, optional test command, HEAD matches PR head); on success the subtask advances to `review_pending`; @mentions Conductor and a cross-model Reviewer with the PR URL 9. **Code Reviewer starts from the Coder's direct @mention**; Conductor only performs fallback dispatch if the Coder omitted a Reviewer -10. **Code Reviewer** → reads PR, posts findings as PR comments, reports verdict -11. **If review fails** → Code Review Protocol iterates (same reviewer stays) until resolved -12. **Risk-based routing** → low-risk auto-merges; higher risk waits for `cb approve` -13. **Mergemaster** → batch merge with bisect-on-failure -14. **Conductor** → final status to user +10. **Code Reviewer** → reads PR, posts findings as PR comments, runs `cb-phase review --approve/--reject `, reporting `review_passed` or `review_failed` +11. **If review fails** → Code Review Protocol iterates (same reviewer stays) until resolved or the review-round cap is hit (→ `blocked`, owner escalation) +12. **Verifier** (when configured) → on `review_passed` runs `cb-phase verify-acceptance --accept/--reject `. `--accept` records `acceptance_passed`; `--reject` sends the subtask back to `review_failed` +13. **Mergemaster** → runs `cb-phase merge ` which: checks SHA-pinned merge eligibility (all required verdicts pinned to the same HEAD), requests approval from the task owner via chat, and — once granted via `cb approve ` — executes `gh pr merge --match-head-commit `; batch merge with bisect-on-failure +14. **State store** → task promoted to `completed` once every subtask reaches `merged` +15. **Conductor** → final status to user + +## State Layer + +Durable orchestration state lives in a single SQLite database at `workspace/state/orchestration.db` (`state/store.py`). All processes that share a workspace — the local in-process runner and the distributed `agent_main` path — read and write this same file. It uses WAL mode with short atomic transactions and a busy timeout so concurrent access never corrupts. + +**Four tables:** + +| Table | Purpose | +|-------|---------| +| `tasks` | One row per task (keyed by `room_id`). Carries `status` (`active`/`superseded`/`completed`), the task `owner_id` (for watchdog escalation), a snapshotted `required_verdicts` list, and a snapshotted `merge_approval` policy. All fields are resolved at registration time and frozen — a mid-task config edit cannot change an in-flight task. | +| `subtask_states` | One row per `(task_id, subtask_id)`. Carries the current FSM state, assigned worker, PR number, and durable counters: `review_round`, `verify_attempts`, `rebase_rounds`, and the SHA-pinned merge-approval grant columns. | +| `transition_log` | Append-only audit of every FSM state transition. Each row is hash-chained: `row_hash = SHA-256(business_columns + prev_hash)`, so an after-the-fact edit of any business column breaks the chain at exactly that row. `cb verify-log` and the Watchdog's integrity rung detect breaks. | +| `audit_log` | Append-only record of non-transition effects: approval grants, approval-request markers, `pr_number` bindings, `ungated_external_merge` events. Separate from `transition_log` so the Watchdog's transition patrols keep "FSM transitions only" semantics. Own hash chain (same scheme). | + +**Hash chains are tamper-evident, not tamper-proof** — a process with write access to the DB file can recompute the chain. The adversary line is raw DB writes by a motivated actor; the chains catch accidental corruption and after-the-fact edits by ordinary callers. + +### FSM (`state/fsm.py`) + +Every subtask advances through a fixed lifecycle: + +``` +planned → assigned → in_progress → verify_pending → review_pending + → review_passed → [acceptance_passed →] merge_pending → merged + ↘ review_failed → in_progress (or → blocked at cap) + ↘ needs_rebase → in_progress (or → blocked at rebase cap) + ↘ blocked → in_progress (conductor resume) + ↘ abandoned (conductor, any state) +``` + +`acceptance_passed` is only reached when a Verifier is configured — the merge-eligibility gate decides which path a task's snapshotted `required_verdicts` requires. + +`fsm.transition()` is the **only mutation path**. It opens a `BEGIN EXCLUSIVE` transaction, validates `(current_state, caller_role)` against a static table (`VALID_TRANSITIONS`), enforces three runtime caps in the same transaction, then writes the new state and appends a hash-chained `transition_log` row. Illegal edges or wrong caller roles raise `InvalidTransitionError` and write nothing. + +**Runtime caps enforced inside the transition:** + +| Cap | Default | What it bounds | +|-----|---------|----------------| +| `max_review_rounds` | 6 | `review_failed → in_progress` rework cycles — bounds a productive loop the Watchdog's stall cap never catches (each round commits real code) | +| `max_rebase_rounds` | 3 | `needs_rebase` entries — bounds the merge-gate send-back loop (each round writes fresh transition rows, so the Watchdog stall cap doesn't fire) | +| `max_verify_attempts` | (config) | `cb-phase verify` rejections — bounds the verify-gate loop (coder commits real code each attempt, HEAD advances) | + +The **merge-eligibility gate** fires inside every `→ merge_pending` transition: every verdict leg in the task's snapshotted `required_verdicts` must have a `transition_log` row pinned to exactly the `head_sha` being merged (`verify` → `review_pending` transition, `review` → `review_passed`, `verify_acceptance` → `acceptance_passed`). An ineligible attempt raises `MergeNotEligibleError` with machine-readable reason tags and writes nothing. Because `transition()` is the only path into `merge_pending`, no caller can bypass this check. + +### Task registration (`state/registration.py`) + +`register_task()` is the single writer of "a task exists". It: +1. Resolves and validates `required_verdicts` and `merge_approval` from the current `AgentsConfig` +2. Applies supersede + insert/update in one atomic transaction +3. Writes the active-room pointer file (`workspace/state/.codeband_room`) **strictly after** the DB commit — row-first, because a row-without-pointer is recoverable by re-running registration; a pointer-without-row is a dead end for `cb-phase` + +Both `cb task` (kickoff) and `cb register-task` (manual re-seed) call `register_task()`; nothing else may write the pointer file or a `tasks` row. + +### Rehydration (`state/rehydration.py`) + +On every agent reconnect, `recover_for_reconnect()` opens the state store and builds a per-role markdown recovery prompt prepended to the agent's system prompt. Per-role content: Conductor gets all non-terminal subtasks; Mergemaster gets `merge_pending/review_passed/acceptance_passed/needs_rebase`; Code Reviewer gets `review_pending`; Verifier gets `review_passed`; Planner/Plan Reviewer get active task descriptions. `None` means nothing relevant in durable state — agent reconnects normally. + +## CLI Layer (`cb` and `cb-phase`) + +### `cli/__init__.py` — Main CLI + +The `cb` command group (Click). Handles auth resolution at startup (`_resolve_claude_auth` / `_resolve_codex_auth`), `--dir` project routing, and `.env` loading. Subcommands include `cb run`, `cb task`, `cb register-task`, `cb approve`, `cb reject`, `cb prs`, `cb issues`, `cb log`, `cb feed`, `cb doctor`, `cb init`, and the bare `cb` path that opens the interactive shell. + +### `cli/handoff.py` — `cb-phase` + +The **enforcement seam** for coding agents. All phase advances happen by shelling out to `cb-phase`; the effect occurs only if every gate passes regardless of what the Conductor intended. + +**Commands:** + +| Command | Role | What it does | +|---------|------|--------------| +| `cb-phase start ` | `coder` | Seeds `in_progress` in the state store at pickup | +| `cb-phase verify --pr ` | `coder` | Gate sequence: verify-attempt cap check → clean tree → PR open + branch matches → optional test command → HEAD = PR head → advances to `review_pending`; failure increments durable `verify_attempts` | +| `cb-phase review --approve/--reject ` | `reviewer` | Advances to `review_passed` or `review_failed`; SHA-pinned to worktree HEAD | +| `cb-phase verify-acceptance --accept/--reject ` | `verifier` | Advances to `acceptance_passed` or `review_failed`; runs hash-chain integrity check before accepting (broken chain → rejected) | +| `cb-phase abandon ` | `conductor` | Drives the `(any, conductor) → abandoned` FSM wildcard | +| `cb-phase resume ` | `conductor` | Drives `blocked → in_progress`; preserves all durable counters (not a cap reset) | + +The `task_id` is always resolved from the active-room pointer (`workspace/state/.codeband_room`), never from the command line — agents pass a semantic label on `--task` for readability only. + +### `cli/merge.py` — `cb-phase merge` + +The **only sanctioned merge path**. Agents request a merge by running `cb-phase merge `; this code executes it behind the FSM's SHA-pinned eligibility gate and a durable, SHA-pinned approval grant. + +Invocation flow (fail-closed): + +1. Resolve task, subtask, PR number (persisted on first call; a `--pr` that disagrees with the stored binding is rejected) +2. **Reconcile first**: if subtask is already `merge_pending` and PR is already `MERGED`, record `merged` only if the grant's SHA matches the merged head (crash recovery). Without a matching grant → `blocked` + `ungated_external_merge` audit event +3. From `review_passed` / `acceptance_passed`: attempt `→ merge_pending` at PR head SHA (eligibility gate runs inside the FSM transition) +4. **Execution-time SHA re-check**: PR head must still equal the `merge_pending` SHA; a push → `needs_rebase` +5. **Approval**: `cb approve ` writes a SHA-pinned grant; `cb-phase merge` proceeds only when grant SHA matches `merge_pending` SHA. If not yet granted, sends approval request to the task owner (once per `merge_pending` SHA) and exits 0 to wait +6. **Mergeability pre-check**: conflicting PR → `needs_rebase` +7. **Execute** `gh pr merge --merge --match-head-commit ` (SHA-pinned to prevent merging unverified code if branch moves between snapshot and execution) +8. On success: record `merged` transition (task auto-promoted to `completed` if last subtask); delete remote branch best-effort + +## Shell (Interactive REPL) + +Bare `cb` (no subcommand, TTY) opens a single-terminal session: `shell/repl.py` launches three concurrent asyncio tasks — the in-process orchestrator (local mode), the live feed poller, and a `prompt_toolkit` REPL. `patch_stdout` renders feed output and agent debug logs above the prompt without disturbing the input line. + +Slash commands are dispatched from `shell/commands.py` via a `REGISTRY` of handlers. Available commands include `/log`, `/diff`, `/task`, `/usage`, `/down`, `/quit`. Handlers delegate to Click command callbacks or to a `FSBackend` (`shell/fs.py`) abstraction that works identically in local and distributed mode. `shell/render.py` provides shared terminal rendering helpers. + +## Monitoring + +`monitoring/activity_log.py` — append-only JSONL log at `workspace/state/activity.log`. Three event types: `LLM_USAGE` (cost/latency per LLM call), `cli_invocation` (argv + cwd + pid + env markers at start of every `cb`/`cb-phase` command), `cli_completion` (exit code). The `cli_invocation`/`cli_completion` pair is the **attribution record**: the row-5 forensics question ("which process ran `cb-phase verify`?") is answerable for the entire sanctioned CLI surface. Actions outside the CLI (raw `sqlite3`, shell `git`/`gh`) leave no row — by design (detection over prevention). Failures to write the log never break the command. + +`monitoring/feed.py` — live terminal stream. Polls Band.ai's human API, resolves `@[[uuid]]` mention tokens to display names, and colorizes output by role. Used by the interactive shell's feed task and by `cb feed` (one-shot). + +`monitoring/usage.py` — token usage aggregation. `SDKUsageHandler` parses SDK log lines into `LLM_USAGE` activity events; `UsageSummary` aggregates them. Surfaced via `/usage` in the shell and `cb log --type LLM_USAGE`. ## Anti-Loop Discipline @@ -238,7 +360,7 @@ The Mergemaster follows a batch-then-bisect algorithm: The Planner minimizes conflicts by spreading subtasks across non-overlapping files. When conflicts occur, the conflicting branch is removed from the batch and reported to the Conductor for rebasing. -> **Note:** Merge strategy, protocol tracking, risk-based routing, and pool allocation are currently **prompt-enforced** — the agent LLMs follow these instructions from their system prompts. They are not yet deterministic code. Appropriate for an MVP; see [Roadmap](README.md#roadmap). +> **Note:** Pool allocation and protocol tracking remain **prompt-enforced** — agents follow roster and protocol instructions from their system prompts. Merge gating (eligibility, approval, SHA-pinning) and FSM state transitions are **code-enforced** via the state layer and `cb-phase` CLI. See [Roadmap](README.md#roadmap) for planned code-backed pool allocation. ## Session Recovery diff --git a/CLAUDE.md b/CLAUDE.md index e386b87..5641993 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,13 +28,13 @@ All LLM calls — including the `cb prs --smart` / `cb issues --smart` CLI helpe ### Auth policy (asymmetric by design) - **Claude is subscription-first.** `cli.py:_resolve_claude_auth` strips `ANTHROPIC_API_KEY` at startup whenever any OAuth source exists: `CLAUDE_CODE_OAUTH_TOKEN`, macOS Keychain (`security -s "Claude Code-credentials"`), or `$CLAUDE_CONFIG_DIR/.credentials.json` (default `~/.claude/.credentials.json`). This is because the Claude CLI's own precedence puts API key *above* OAuth — without intervention, a user with `claude` logged in locally would silently pay per token. -- **Codex is API-key-first.** When both `OPENAI_API_KEY` and a `~/.codex/auth.json` ChatGPT session exist, the API key wins (per OpenAI's own automation guidance and `docker/entrypoint.sh`). Parallel Codex workers exhaust subscription quotas quickly. +- **Codex is subscription-first** (mirrors Claude). `cli.py:_resolve_codex_auth` strips `OPENAI_API_KEY` at startup when `~/.codex/auth.json` carries a valid ChatGPT subscription credential — the API key is stashed as `CODEBAND_FALLBACK_OPENAI_API_KEY` so preflight can restore it only if the subscription path reports usage-limit exhaustion. Parallel Codex workers exhaust subscription quotas quickly, so the intent is the same as Claude: avoid silently burning API credits while a subscription is active. - **Preflight in `cb run`** (`preflight.py`) makes one tiny call through each CLI before spawning agents. It pattern-matches known error text ("Credit balance is too low", "usage limit reached", "rate_limit_error", "please log in", etc.) and fails fast with a remediation hint. Without this, auth/billing errors surface as plain assistant text posted into chat rooms — the watchdog stays happy and the whole swarm idles silently. `--skip-preflight` bypasses for CI/offline. The Codex preflight only runs when the config has at least one Codex agent. - **Doctor** (`doctor.check_claude_auth`) warns when `ANTHROPIC_API_KEY` is set alongside host subscription OAuth — Codeband auto-prefers at run-time, but the WARN flags the suboptimal `.env` for cleanup. ### Worker pool architecture -Coders, Code Reviewers, Planners, and Plan Reviewers are **pool roles** — each is declared in `codeband.yaml` as `{framework: {count, model}}` under `agents.{coders, reviewers, planners, plan_reviewers}`. Conductor and Mergemaster are **singletons**. Pool member identities follow `{role}-{framework}-{index}` (e.g., `coder-claude_sdk-0`); Band.ai display names are title-cased (`Coder-Claude-0`). The default `cb init` config is 8 agents total — fits Band.ai's free-tier 10-agent cap. +Coders, Code Reviewers, Planners, Plan Reviewers, and Verifiers are **pool roles** — each is declared in `codeband.yaml` as `{framework: {count, model}}` under `agents.{coders, reviewers, planners, plan_reviewers, verifiers}`. Conductor and Mergemaster are **singletons**. Pool member identities follow `{role}-{framework}-{index}` (e.g., `coder-claude_sdk-0`); Band.ai display names are title-cased (`Coder-Claude-0`). The default `cb init` config is 10 agents total — the 8 coordination/coder/reviewer seats plus one Verifier per vendor — exactly Band.ai's free-tier 10-agent cap. The Verifier is the evidence-integrity acceptance gate that runs after code review and before merge: its `verify_acceptance` verdict is a SHA-pinned merge requirement whenever a verifier is configured. Fresh installs default to `required_verdicts: ["review"]` plus `verify_acceptance` when verifiers are configured; the local `verify` verdict is opt-in via `agents.handoff_verify_command`. Setting `agents.verifiers` to count 0 per vendor opts back out, dropping to 8 agents and merging straight from review. The `codeband/workers/pool.py:WorkerPool` is a thread-safe in-memory allocator with `acquire(role, framework)`, `release(worker_id)`, and `pair_for_task(coder_role, coder_framework)` which atomically reserves a coder and an opposite-framework reviewer (adversarial cross-model review is the primary value prop — a Claude coder's PR routes to a Codex reviewer, and vice versa). The allocator is defined but not yet wired into the Conductor's LLM prompt — allocation is currently prompt-enforced via `runner._build_worker_roster()` which surfaces the pool to the Conductor. Code-backed allocation is on the roadmap. @@ -44,6 +44,8 @@ The `codeband/workers/pool.py:WorkerPool` is a thread-safe in-memory allocator w Every agent runs under a reconnect-forever loop: both crashes and clean exits from `agent.run()` trigger another cycle. Unsupervised roles (Conductor, Mergemaster, Planner, Plan Reviewer, Code Reviewer) use the module-level `_run_agent_forever` in `orchestration/runner.py` with exponential backoff capped at 60s. Coders additionally run under `WorkerSupervisor` (`session/supervisor.py`), which rebuilds recovery context from git log + uncommitted changes + `TASK.md` on each restart; identity is persisted as JSON in `workspace/state/.json` (`session/identity.py:WorkerIdentity`). Only SIGINT/SIGTERM ends the process. `PoolEntry.restart_delay_seconds` in `config.py` controls the base supervisor delay; `PoolEntry.max_restarts` is deprecated and no longer honored. +**Reconnect ownership.** Local mode: the outer reconnect loop is the *sole* lifecycle owner — `_patch_band_local_runtime()` disables the SDK's PHX auto-reconnect, so socket death is detected by the websockets keepalive (~40s), which exits the routing task and triggers a fresh cycle; the SDK's idle resync (`agents.idle_resync_seconds`, default 30) is the delivery backstop for missed pushes — with a known upstream defect: the backstop self-disables per room once any one message exhausts its retry budget (client-side permafail pins the head of the `/next` queue); `agents.max_message_retries` (default 3, vs the SDK's 1) reduces how often that fires but does not eliminate it; on each (re)start agents subscribe to existing rooms scoped to active tasks in the StateStore (`cb run --fresh` opts out; a store failure subscribes everything — fail toward connectivity). Teardown between cycles (`_safe_stop_agent`, supervisor `_close_agent`) fails loud at ERROR and verifies the websocket actually closed. Distributed mode (`run_agent`) is intentionally unpatched: SDK-native reconnect and subscribe-existing. + ### Communication: three-channel model The system uses three channels: **chat** (Band.ai @mentions) for content delivery and coordination, **memory** (Band.ai memory API) for protocol state tracking, and **GitHub PR comments** for code review artifacts. Memory has a 1000-char content limit — full plans, reviews, and logs flow through chat and GitHub, while memory stores lightweight state envelopes. @@ -87,3 +89,34 @@ Agent system prompts live in `src/codeband/prompts/*.md` and are loaded directly - `from __future__ import annotations` in every file - Band.ai SDK imports are deferred (inside functions) to keep import time fast and avoid hard dependency when only config/workspace code is used - Tests use `tmp_path` fixtures for filesystem isolation; git integration tests create real repos with `subprocess` + +--- + +## Determinism fleet (fork-local coordination) + +> This section coordinates the multi-session build of the determinism RFC in this **fork**. It is not part of upstream codeband — exclude it when upstreaming phase branches. + +We are implementing [`docs/rfc-deterministic-orchestration.md`](docs/rfc-deterministic-orchestration.md) as **one independent Claude Code session per phase**, each in its own git worktree, each landing **one PR**. Before writing code, read your phase's RFC workstream in full. + +**Guiding principle:** the LLM decides, code enforces and remembers — the FSM gates EFFECTS (transitions/merges), not the Conductor's creative routing. + +| Phase / worktree | RFC workstream | Owns (new files) | May edit | Depends on | +|---|---|---|---|---| +| `phase-1-state-store` | WS1 | `state/__init__.py`, `state/store.py`, `tests/test_state_store.py` | `orchestration/runner.py` (`_install_memory_backend`), `orchestration/kickoff.py` (`send_task`) | — (**blocks all**) | +| `phase-2-fsm-gates` | WS2+WS3 | `state/fsm.py`, `cli/__init__.py` (move from `cli.py`), `cli/handoff.py`, `tests/test_fsm.py`, `tests/test_handoff.py` | `pyproject.toml`, `config.py` (`AgentsConfig`) | Phase 1 | +| `phase-3-watchdog` | WS4 | `tests/test_watchdog_upgrade.py` | `agents/watchdog.py`, `config.py` (`WatchdogConfig`), maybe `orchestration/runner.py` | Phase 1 | +| `phase-4-rehydration` | WS5 | `state/rehydration.py`, `tests/test_rehydration.py` | `orchestration/runner.py` (5 factories + `_run_agent_forever`), `agents/{conductor,planner,plan_reviewer,code_reviewer,mergemaster}.py`, `orchestration/agent_main.py` | Phase 1 | + +**Merge order:** Phase 1 first (everything imports `state.store`). Then Phase 2 ∥ Phase 3. Phase 4 last (heaviest `runner.py` edits). Overlap to rebase around: `config.py` (P2 vs P3, different classes) and `runner.py` (P1/P3/P4). + +**Rules for every session:** +- Stay in your lane — implement exactly your phase; do not touch other phases' files. If you need to, STOP and report. +- Branch + PR always; never commit to `main`; Conventional Commits. +- Run the suite before push; add **no new failures**. Self-review (`git diff main...HEAD`) — scope must match the phase only. + +**Dev env (per worktree — `.venv` is not shared):** +``` +uv venv && uv pip install -e ".[dev]" +.venv/bin/python -m pytest -q +``` +**Baseline on `main`: the full suite passes** (the 3 `CliRunner` failures previously listed here have been fixed). Acceptance for any phase = the suite stays fully green and your new tests pass. diff --git a/README.md b/README.md index d6ac6fe..51835af 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ It is built for headless operation: local terminals, Linux servers, CI, Docker, ## Why Codeband? - **Cross-model review by default**: Claude-written PRs are routed to Codex reviewers, and Codex-written PRs are routed to Claude reviewers. -- **A real orchestration loop**: planner, plan reviewer, coders, code reviewers, mergemaster, and watchdog coordinate through Band.ai. +- **A real orchestration loop**: planner, plan reviewer, coders, code reviewers, verifiers, mergemaster, and watchdog coordinate through Band.ai. - **Safe workspace isolation**: each coder works in a separate git worktree and opens a PR. - **Risk-aware merging**: low-risk PRs can auto-merge; medium, high, and critical changes wait for approval. - **Human-friendly and headless**: use the interactive shell locally, or run the same orchestrator in CI and Docker. @@ -29,7 +29,7 @@ Codeband's main advantage is not raw parallelism; it is adversarial pairing. A t Parallel coders are useful when the Planner can split a task into independent subtasks with little file overlap. Each coder gets its own git worktree and branch, so separate subtasks can run at the same time without sharing a working directory. Parallel planners and plan reviewers are useful mostly for throughput across multiple queued tasks; for one task, a single Planner plus one opposite-framework Plan Reviewer is usually enough. -The default configuration is the recommended shape: one Claude Coder, one Codex Coder, one Reviewer from each framework, one Planner, and one opposite-framework Plan Reviewer. You can scale pools with `/scale` in the shell or `cb scale`, then run `cb setup-agents` so Band.ai has matching agents. +The default configuration is the recommended shape: one Claude Coder, one Codex Coder, one Reviewer from each framework, one Planner, one opposite-framework Plan Reviewer, and one Verifier from each framework. You can scale pools with `/scale` in the shell or `cb scale`, then run `cb setup-agents` so Band.ai has matching agents. For collision-free parallel review, keep opposite-framework reviewer capacity at least as large as coder capacity: Claude coders need Codex reviewers, and Codex coders need Claude reviewers. Today first-dispatch reviewer selection is prompt-enforced from the Worker Pool Roster using deterministic worker-index pairing; full code-backed arbitration is on the roadmap. @@ -138,7 +138,7 @@ Inside the shell: Slash commands take the rest of the line as their argument, so `/task Add JWT authentication with tests` does not need quotes. In a normal terminal command, quote multi-word descriptions: `cb task "Add JWT authentication with tests"`. -On free-tier Band.ai, `cb setup-agents` may not be available. Create the eight default agents manually and write `agent_config.yaml`; the exact keys are documented in [Configuration](docs/CONFIGURATION.md). +On free-tier Band.ai, `cb setup-agents` may not be available. Create the ten default agents manually and write `agent_config.yaml`; the exact keys are documented in [Configuration](docs/CONFIGURATION.md). For credential precedence, Docker auth, and subscription-vs-API-key behavior, see [Authentication](docs/AUTHENTICATION.md). @@ -180,10 +180,12 @@ Conductor | | +--> Codex Coder -----> Claude Code Reviewer ---+--> Mergemaster | + +--> Verifiers (Claude + Codex acceptance gate) + | +--> Watchdog ``` -The default config uses eight Band.ai agents: Conductor, Mergemaster, one Planner, one Plan Reviewer, two Coders, and two Reviewers. The Watchdog is an in-process daemon and does not consume a Band.ai agent seat. +The default config uses ten Band.ai agents: Conductor, Mergemaster, one Planner, one Plan Reviewer, two Coders, two Reviewers, and two Verifiers. The Watchdog is an in-process daemon and does not consume a Band.ai agent seat. For the full design, see [ARCHITECTURE.md](ARCHITECTURE.md). @@ -201,6 +203,8 @@ For the full design, see [ARCHITECTURE.md](ARCHITECTURE.md). Codeband is alpha software. The current architecture is prompt-driven: agents follow structured prompts, and the Conductor relies on model context plus memory state to track protocol progress. The roadmap is to move more protocol state and branch enforcement into deterministic Python code. +The state-store schema is not migrated across structural changes: after upgrading past the task-scoped subtask-identity change, delete any pre-existing `{workspace}/state/orchestration.db` before the next run. The same applies after the task-registration change (tasks now carry a required owner and an `owner_handle` column, and re-seeding supersedes the previous task): delete pre-existing dev DBs so stale ownerless or duplicate-active task rows don't linger. + Pool scaling is supported, but first-dispatch worker arbitration is still prompt-enforced. For best results, scale coders and their opposite-framework reviewers together, keep planner/plan-reviewer pools small, and avoid configurations where several coders must share one reviewer unless you are comfortable with queued or overlapping review turns. Current planned work: diff --git a/docker/docker-compose.distributed.yml b/docker/docker-compose.distributed.yml index 48f2b71..6089c72 100644 --- a/docker/docker-compose.distributed.yml +++ b/docker/docker-compose.distributed.yml @@ -20,7 +20,7 @@ # docker compose -f docker/docker-compose.distributed.yml up conductor # Shared config bind mounts (repeated per service since YAML list anchors -# cannot be merged). Each service needs these three read-only mounts. +# cannot be merged). Each service needs the two read-only config mounts. # Paths use CODEBAND_PROJECT_DIR (set by `codeband up`, defaults to "."). x-agent-base: &agent-base @@ -33,9 +33,12 @@ x-agent-base: &agent-base required: false environment: &env-base CODEBAND_CONFIG: /app/config/codeband.yaml + # In-container project dir (config + active-room pointer) for cb-phase / + # cb approve resolution from any cwd. Distinct from the HOST-side + # ${CODEBAND_PROJECT_DIR:-.} used by compose interpolation — this + # literal value is what processes inside the container see. + CODEBAND_PROJECT_DIR: /app/config DEPLOYMENT_MODE: distributed - BAND_REST_URL: ${BAND_REST_URL:-https://app.band.ai} - BAND_WS_URL: ${BAND_WS_URL:-wss://app.band.ai/api/v1/socket/websocket} BAND_MEMORY_MODE: ${BAND_MEMORY_MODE:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} @@ -59,7 +62,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - conductor_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -72,7 +74,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - mergemaster_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -90,7 +91,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - planner_claude_0_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -104,7 +104,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - ${CODEX_HOME:-${HOME}/.codex}:/home/appuser/.codex:ro - planner_codex_0_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -122,7 +121,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - plan_reviewer_claude_0_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -136,7 +134,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - ${CODEX_HOME:-${HOME}/.codex}:/home/appuser/.codex:ro - plan_reviewer_codex_0_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -150,7 +147,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - coder_claude_0_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -163,7 +159,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - ${CODEX_HOME:-${HOME}/.codex}:/home/appuser/.codex:ro - coder_codex_0_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -177,7 +172,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - reviewer_claude_0_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -190,7 +184,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - ${CODEX_HOME:-${HOME}/.codex}:/home/appuser/.codex:ro - reviewer_codex_0_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] @@ -204,7 +197,6 @@ services: volumes: - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - watchdog_workspace:/workspace command: ["python", "-m", "codeband.orchestration.agent_main"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ec02528..1a41391 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -21,14 +21,17 @@ x-agent-base: &agent-base required: false environment: &env-base CODEBAND_CONFIG: /app/config/codeband.yaml - AGENT_CONFIG: /app/config/agent_config.yaml + # In-container project dir (config + active-room pointer) for cb-phase / + # cb approve resolution from any cwd. Distinct from the HOST-side + # ${CODEBAND_PROJECT_DIR:-.} used by compose interpolation below — this + # literal value is what processes inside the container see. + CODEBAND_PROJECT_DIR: /app/config WORKSPACE: /workspace REPO_URL: ${REPO_URL:-} REPO_BRANCH: ${REPO_BRANCH:-} WORKTREE_PREFIX: ${WORKTREE_PREFIX:-codeband} - BAND_REST_URL: ${BAND_REST_URL:-https://app.band.ai} - BAND_WS_URL: ${BAND_WS_URL:-wss://app.band.ai/api/v1/socket/websocket} BAND_MEMORY_MODE: ${BAND_MEMORY_MODE:-} + BAND_API_KEY: ${BAND_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} @@ -38,7 +41,6 @@ x-agent-base: &agent-base volumes: &volumes-base - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro # overrides only; package defaults used when present - ${GIT_CREDENTIALS_PATH:-/dev/null}:/home/appuser/.git-credentials:ro - ${SSH_AUTH_SOCK:-/dev/null}:/run/ssh-agent:ro - bare_repo:/workspace/repo.git @@ -58,7 +60,6 @@ x-agent-base: &agent-base x-volumes-codex: &volumes-codex - ${CODEBAND_PROJECT_DIR:-.}/codeband.yaml:/app/config/codeband.yaml:ro - ${CODEBAND_PROJECT_DIR:-.}/agent_config.yaml:/app/config/agent_config.yaml:ro - - ${CODEBAND_PROJECT_DIR:-.}/prompts:/app/config/prompts:ro - ${GIT_CREDENTIALS_PATH:-/dev/null}:/home/appuser/.git-credentials:ro - ${SSH_AUTH_SOCK:-/dev/null}:/run/ssh-agent:ro - ${CODEX_HOME:-${HOME}/.codex}:/home/appuser/.codex:ro @@ -205,6 +206,31 @@ services: volumes: *volumes-codex command: ["python", "-m", "codeband.orchestration.agent_main"] + # ── Verifier-Claude-0 (pool — pairs with Codex coders) ──────────────── + verifier-claude-0: + <<: *agent-base + build: + context: .. + dockerfile: docker/Dockerfile.claude + environment: + <<: *env-base + AGENT_KEY: verifier-claude_sdk-0 + AGENT_ROLE: verifier + command: ["python", "-m", "codeband.orchestration.agent_main"] + + # ── Verifier-Codex-0 (pool — pairs with Claude coders) ───────────────── + verifier-codex-0: + <<: *agent-base + build: + context: .. + dockerfile: docker/Dockerfile.codex + environment: + <<: *env-base + AGENT_KEY: verifier-codex-0 + AGENT_ROLE: verifier + volumes: *volumes-codex + command: ["python", "-m", "codeband.orchestration.agent_main"] + # ── Watchdog (deterministic daemon — no LLM) ─────────────────────────── watchdog: <<: *agent-base diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e1f26ac..95aba65 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -206,11 +206,14 @@ if [ "${USES_CODEX}" = "1" ]; then cp "${HOST_CODEX_AUTH}" "${CODEX_HOME}/auth.json" fi CODEX_AUTH_FILE="${CODEX_HOME}/auth.json" - if [ -n "${OPENAI_API_KEY:-}" ]; then + # Subscription-first: mirror cb run's _resolve_codex_auth() policy. + # If both OPENAI_API_KEY and a ChatGPT subscription auth are present, + # prefer the subscription to avoid silently burning API credits. + if [ -f "${CODEX_AUTH_FILE}" ] && grep -q '"auth_mode": *"ChatGPT"' "${CODEX_AUTH_FILE}" 2>/dev/null; then + echo "Codex auth: ChatGPT subscription (copied from host ~/.codex)" + elif [ -n "${OPENAI_API_KEY:-}" ]; then echo "Codex auth: OPENAI_API_KEY (API key, container-local)" echo "${OPENAI_API_KEY}" | codex login --with-api-key 2>/dev/null || true - elif [ -f "${CODEX_AUTH_FILE}" ] && grep -q '"auth_mode": *"ChatGPT"' "${CODEX_AUTH_FILE}" 2>/dev/null; then - echo "Codex auth: ChatGPT subscription (copied from host ~/.codex)" elif [ -f "${CODEX_AUTH_FILE}" ] && grep -q '"OPENAI_API_KEY"' "${CODEX_AUTH_FILE}" 2>/dev/null; then echo "Codex auth: API key (copied from host ~/.codex)" else diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 293909f..c48ba31 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -26,14 +26,18 @@ Claude agents can authenticate in three ways. Codeband resolves them in this ord When subscription auth is available, Codeband strips `ANTHROPIC_API_KEY` from the spawned Claude process so the Claude CLI does not silently prefer API-key billing over subscription auth. `cb doctor` warns when both are present. +Set `CODEBAND_CLAUDE_PREFER_API_KEY=1` to keep API-key precedence when both auth methods are present. + ## Codex -Codex agents can use either: +Codex agents can authenticate in two ways. Codeband resolves them in this order at `cb run` startup: + +1. Host ChatGPT subscription login from `codex login --device-auth`, stored in `~/.codex/auth.json`. +2. `OPENAI_API_KEY`, for pay-per-token usage. -- `OPENAI_API_KEY`, recommended for automation and parallel agents. -- Host login from `codex login --device-auth`, useful for low-volume local runs. +When ChatGPT subscription auth is available, Codeband strips `OPENAI_API_KEY` from the spawned Codex process so the Codex CLI does not silently prefer API-key billing over subscription auth. Preflight restores the stripped key if the subscription path reports usage-limit exhaustion or if Codex falls into an API-key-required auth mode. `cb doctor` warns when both are present. -Codex is intentionally API-key-first. If `OPENAI_API_KEY` and subscription login are both present, the API key wins. +Set `CODEBAND_CODEX_PREFER_API_KEY=1` to keep API-key precedence when both auth methods are present. For Docker, mount or provide credentials explicitly: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9937a87..518a796 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,60 +1,124 @@ # Configuration -`cb init --repo ` writes a default `codeband.yaml` designed for the free-tier Band.ai 10-agent cap. The default uses eight Band.ai agents plus one in-process Watchdog. +`cb init --repo ` writes a default `codeband.yaml` designed for the free-tier Band.ai 10-agent cap. The default uses ten Band.ai agents plus one in-process Watchdog. ## Default `codeband.yaml` ```yaml repo: - url: "https://github.com/myorg/myrepo.git" - branch: "main" - + url: https://github.com/myorg/myrepo.git + branch: main agents: - conductor: { framework: claude_sdk, model: claude-sonnet-4-6 } + conductor: + framework: claude_sdk + model: claude-sonnet-4-6 mergemaster: framework: claude_sdk model: claude-sonnet-4-6 - test_command: "pytest" - auto_merge: "low" - + test_command: null + review_guidelines: null + auto_merge: low planners: - claude_sdk: { count: 1, model: claude-sonnet-4-6 } - codex: { count: 0 } + claude_sdk: + count: 1 + model: claude-sonnet-4-6 + max_restarts: 5 + restart_delay_seconds: 5.0 + codex: + count: 0 + model: null + max_restarts: 5 + restart_delay_seconds: 5.0 plan_reviewers: - claude_sdk: { count: 0 } - codex: { count: 1, model: gpt-5.4 } - # review_guidelines: "Optional project-wide plan-review policy" + claude_sdk: + count: 0 + model: null + max_restarts: 5 + restart_delay_seconds: 5.0 + codex: + count: 1 + model: gpt-5.5 + max_restarts: 5 + restart_delay_seconds: 5.0 + review_guidelines: null coders: claude_sdk: count: 1 model: claude-opus-4-7 - description: "Strong at refactoring, testing, complex logic" + max_restarts: 5 restart_delay_seconds: 5.0 codex: count: 1 - model: gpt-5.4 - description: "Fast at bulk generation, boilerplate" + model: gpt-5.5 + max_restarts: 5 + restart_delay_seconds: 5.0 reviewers: - claude_sdk: { count: 1, model: claude-sonnet-4-6 } - codex: { count: 1, model: gpt-5.4 } - # review_guidelines: "All public functions need docstrings. No raw SQL." - + claude_sdk: + count: 1 + model: claude-sonnet-4-6 + max_restarts: 5 + restart_delay_seconds: 5.0 + codex: + count: 1 + model: gpt-5.5 + max_restarts: 5 + restart_delay_seconds: 5.0 + review_guidelines: null + verifiers: + claude_sdk: + count: 1 + model: claude-opus-4-7 + max_restarts: 5 + restart_delay_seconds: 5.0 + codex: + count: 1 + model: gpt-5.5 + max_restarts: 5 + restart_delay_seconds: 5.0 watchdog: check_interval_seconds: 120 stale_threshold_seconds: 300 nudge_grace_seconds: 60 - + nudge_suppression_seconds: 1800 + role_stale_thresholds: + coder: 900 + mergemaster: 900 + swarm_idle_grace_seconds: 1800 + max_phase_visits: 10 + git_progress_check: true + full_integrity_interval_patrols: 30 + transport_heal_enabled: true + transport_pin_threshold_seconds: 1800 + transport_heal_max_attempts: 3 + merge_approval_backstop_seconds: 240 + merge_approval_backstop_max_renudges: 1 + acceptance_advance_backstop_seconds: 240 + acceptance_advance_max_renudges: 1 + handoff_verify_command: null + required_verdicts: null + allow_ungated_merge: false + merge_approval: owner + max_review_rounds: 6 + max_verify_attempts: 20 + max_rebase_rounds: 3 + verify_infra_exit_codes: null + idle_resync_seconds: 30 + codex_turn_timeout_seconds: 3600 + max_message_retries: 3 + delivery: sdk workspace: - path: ".codeband" - worktree_prefix: "codeband" - mode: "local" - + path: .codeband + worktree_prefix: codeband + mode: local band: - rest_url: "https://app.band.ai" - ws_url: "wss://app.band.ai/api/v1/socket/websocket" - memory_mode: "auto" + rest_url: https://app.band.ai + ws_url: wss://app.band.ai/api/v1/socket/websocket + memory_mode: auto + liveness_mode: auto ``` +`max_restarts` is deprecated and ignored at runtime, but current `cb init` output still emits it for every pool entry. Existing files may keep it; new behavior should be controlled with `restart_delay_seconds` and the reconnect-forever loop. + ## Agent Count The default pool is: @@ -67,8 +131,9 @@ The default pool is: | Plan Reviewer | 1 | | Coders | 2 | | Reviewers | 2 | +| Verifiers | 2 | -Total: 8 Band.ai agents. The Watchdog is an in-process daemon and does not use a Band.ai agent seat. +Total: 10 Band.ai agents. The Watchdog is an in-process daemon and does not use a Band.ai agent seat. ## Frameworks @@ -77,7 +142,7 @@ Total: 8 Band.ai agents. The Watchdog is an in-process daemon and does not use a | `claude_sdk` | Claude Code | Complex reasoning, refactoring, careful stepwise work | | `codex` | Codex | Bulk generation, boilerplate, fast iteration | -Every role can use either framework. The default keeps Conductor and Mergemaster on Claude, pairs a Claude Planner with a Codex Plan Reviewer, and keeps one Coder and one Reviewer from each framework. +Every role can use either framework. The default keeps Conductor and Mergemaster on Claude, pairs a Claude Planner with a Codex Plan Reviewer, and keeps one Coder, one Reviewer, and one Verifier from each framework. ## Cross-Model Pairing @@ -87,9 +152,12 @@ Codeband enforces adversarial pairing through the agent prompts and Worker Pool - Codex Coder PRs route to Claude Reviewers. - Claude plans route to Codex Plan Reviewers. - Codex plans route to Claude Plan Reviewers. +- Reviewed work routes to an opposite-framework Verifier for acceptance when a verifier is configured. Coders dispatch first reviews directly to concrete reviewer display names, using deterministic worker-index pairing from the roster. For example, `Coder-Claude-1` prefers `Reviewer-Codex-1`; if only one Codex reviewer exists, it falls back to `Reviewer-Codex-0` and reports that reviewer capacity is shared. If an opposite-framework reviewer is unavailable, Codeband falls back to same-framework review with a warning. `cb doctor` warns when configuration makes cross-model pairing impossible or when reviewer capacity is lower than matching coder capacity. +Verifiers follow the same adversarial preference. The default config has one Claude Verifier and one Codex Verifier, so a passing review must clear a SHA-pinned `verify_acceptance` verdict before merge. Set both `agents.verifiers.{claude_sdk,codex}.count` values to `0` to opt out; tasks then merge from `review_passed`. + ## Scaling Use `cb scale` to adjust pool sizes: @@ -154,6 +222,17 @@ agents: auto_merge: "low" # all | low | medium | none ``` +## Merge Verdicts + +For fresh installs, `agents.required_verdicts: null` resolves at task registration time to `["review"]`, plus `verify_acceptance` whenever a verifier is configured. The local `verify` verdict is opt-in: set `agents.handoff_verify_command` to make `cb-phase verify` run that command and require its passing verdict before review. + +```yaml +agents: + handoff_verify_command: "pytest" +``` + +With the default active verifier pool and no `handoff_verify_command`, tasks require `review` and `verify_acceptance`. With both verifier counts set to `0`, the default is just `review`. An explicit `required_verdicts` list is validated at task registration; `verify` requires `handoff_verify_command`, and `verify_acceptance` requires at least one configured verifier. + ## Memory Backend Codeband probes Band.ai memory at startup: @@ -176,9 +255,61 @@ band: memory_mode: local ``` +## Reconnect & Room Subscription (local mode) + +In local mode (plain `cb` / `cb run`), agents **rejoin existing rooms by +default** at startup and on every reconnect cycle — scoped to rooms tied to +an `active` task in the durable state store. This is the mid-task recovery +path: rejoin, then the SDK drains the room backlog through the agent's +rehydrated context. Rooms not tied to an active task are skipped (one INFO +line reports the skipped count), capping the blast radius of stale-room +backlog storms. If the state store is unreadable, the sweep logs one +ERROR-level line and subscribes to **all** participant rooms — it fails +toward connectivity, never toward deafness. + +Opt out with: + +```bash +cb run --fresh # skip rejoining existing rooms and their backlog (fresh start) +``` + +`CODEBAND_LOCAL_SUBSCRIBE_EXISTING` (the old opt-in from when the default +was to skip) is deprecated and ignored; setting it prints one deprecation +warning. + +The delivery backstop for missed websocket pushes is the SDK's idle resync — +how quickly an idle agent re-polls its pending queue — tuned via: + +```yaml +agents: + idle_resync_seconds: 30 # default; minimum 1 +``` + +It applies to every role uniformly. Lower values recover faster from missed +pushes but generate more REST traffic (each resync fires one poll per +subscribed room). + +Distributed mode (`cb run --agent `) is intentionally untouched: it +runs the SDK-native reconnect and subscribe-existing behavior. + +## Environment Variables + +Recovery-critical variables that change where Codeband reads state or how it +authenticates. All are optional; defaults are correct for a standard install. + +| Variable | What it does | +|----------|--------------| +| `WORKSPACE` | Base directory for resolving a **relative** `workspace.path`. When set, `workspace.path` resolves against `$WORKSPACE` instead of the project directory — the one shared rule (`config.resolve_workspace_path`) used by the runner, `cb-phase` / `cb approve`, task registration, and `cb doctor`. The Docker images set it to `/workspace` (the shared volume), so every container resolves state to the same place. Absolute `workspace.path` values ignore it. | +| `CODEBAND_PROJECT_DIR` | Project directory (config files + active-room pointer) used by `cb-phase` / `cb approve` to resolve context from any cwd, and by `cb up` / `cb down` for compose interpolation. The compose files set the in-container value to `/app/config`. | +| `WATCHDOG_LIVENESS_MODE` | Force the watchdog's liveness signal: `human` (richer human-API signal, enterprise-only) or `agent` (always-available agent-API inbox signal). Overrides `band.liveness_mode` and skips the startup probe. Invalid values are ignored with a warning. | +| `CODEBAND_CLAUDE_PREFER_API_KEY` | Set to `1`, `true`, `yes`, or `on` to keep `ANTHROPIC_API_KEY` active even when Claude subscription OAuth exists. This opts out of Codeband's default subscription-first Claude auth. | +| `CODEBAND_CODEX_PREFER_API_KEY` | Set to `1`, `true`, `yes`, or `on` to keep `OPENAI_API_KEY` active even when Codex ChatGPT subscription auth exists. This opts out of Codeband's default subscription-first Codex auth. | +| `CODEBAND_FALLBACK_ANTHROPIC_API_KEY` | Process-local backup of a stripped `ANTHROPIC_API_KEY`. Codeband strips the key at startup when Claude subscription OAuth exists (subscription-first policy); preflight restores it from this variable only after the subscription path reports usage-limit exhaustion. Set automatically — you only need to set it manually when providing a fallback key the environment never had. | +| `CODEBAND_FALLBACK_OPENAI_API_KEY` | Same mechanism for Codex: backup of a stripped `OPENAI_API_KEY` when a Codex ChatGPT subscription is logged in, restored by preflight on subscription usage-limit exhaustion or when Codex falls into an API-key-required auth mode after the strip. | + ## Manual Agent Registration (Free Tier) -If `cb setup-agents` is unavailable, create these eight agents in the Band.ai web UI: +If `cb setup-agents` is unavailable, create these ten agents in the Band.ai web UI: | Role | Recommended Band.ai name | |------|--------------------------| @@ -190,6 +321,8 @@ If `cb setup-agents` is unavailable, create these eight agents in the Band.ai we | Codex coder | `Coder-Codex-0` | | Claude code reviewer | `Reviewer-Claude-0` | | Codex code reviewer | `Reviewer-Codex-0` | +| Claude verifier | `Verifier-Claude-0` | +| Codex verifier | `Verifier-Codex-0` | Then create `agent_config.yaml` next to `codeband.yaml`: @@ -219,6 +352,12 @@ agents: reviewer-codex-0: agent_id: ... api_key: ... + verifier-claude_sdk-0: + agent_id: ... + api_key: ... + verifier-codex-0: + agent_id: ... + api_key: ... ``` The keys on the left are load-bearing. They must match the configured role, framework, and zero-based index exactly. diff --git a/docs/commands/README.md b/docs/commands/README.md new file mode 100644 index 0000000..3e37c0b --- /dev/null +++ b/docs/commands/README.md @@ -0,0 +1,3 @@ +This directory is the source of truth for codeband slash commands. +Deploy with: cp docs/commands/codeband.md ~/.claude/commands/codeband.md +Edit here via PR, never only in ~/.claude/commands. diff --git a/docs/commands/codeband.md b/docs/commands/codeband.md new file mode 100644 index 0000000..69fc82e --- /dev/null +++ b/docs/commands/codeband.md @@ -0,0 +1,383 @@ +--- +description: One-shot codeband swarm — bootstraps the stack on first run (just bring 3 keys), then runs a swarm against the CURRENT repo with THIS Claude as the sole Band coordinator (own identity, owns the room, auto-woken from the room log) +argument-hint: [task description] +allowed-tools: [Bash, Monitor, Read, TaskStop] +--- +You are the codeband conductor. The user wants to run a codeband swarm (Claude + Codex agents) **against the repo this Claude Code session is running in**, with **YOU as a first-class Band peer who owns the task room and coordinates the swarm** — the user talks to you in natural language; you talk to the swarm. The user should never touch `cb feed` or the Band UI. + +Task: + +$ARGUMENTS + +## How this works (the architecture — don't deviate) + +- A single shared **codeband home** (`~/projects/codeband`) holds the keys (`.env`) and the 10 registered Band agents (`agent_config.yaml`). It gets re-pointed at the current repo each run. Only ONE codeband runs at a time; switching repos wipes the prior workspace. +- **You** (`jam`) come online as your own ephemeral Band agent (`yoni/claude--`). **You create the task room with your OWN agent key** and add the 10 codeband agents to it. You are `task.owner`, so the Conductor reports back to you by @mentioning you. +- Delivery: the room Monitor reads the **authoritative full room** via `cb room-log` and auto-wakes you for each new Band message. Do not use the jam bridge's `team-lead.json` inbox slice for delivery; it is not the source of truth for this owner-coordinator loop. +- You post to the room with `jam send`, relay concise summaries to the user, and handle approvals as the sole coordinator. + +## Important constraints to relay if relevant +- The swarm clones the repo's **remote (origin)** — it works on what is **pushed**, not local uncommitted edits. Tell the user to push first if needed. +- If the current dir has no `origin`, it falls back to cloning the local repo (committed state only). +- `jam`/`Band` resolver caveat: never use `jam chat new --with @handle` (multi-arg) / `jam agent list` to build the room — they only read the first page of peers and silently drop agents. Add participants ONE AT A TIME via `jam chat add @handle` (or via the REST participant API for ids without an `@owner/handle`, like the human user) — never via the multi-arg pager. The Python below does this. +- **Post as the agent, never as the owner.** Every message you (CC) post to the room goes out under your own agent identity — via `jam send` as your CC handle — never under the owner's user key. This applies on the ad-hoc path too: if the operator nudges you to post a status, a relay, or anything else, you post as yourself and name the owner in the text body; you do not author messages that appear to come from the human. Two reasons: (1) **attribution integrity** — you must be able to distinguish human-originated actions from agent ones (the Stage-3 attributable posture depends on it); (2) **approval integrity** — a merge approval is a SHA-pinned `cb approve` CLI grant executed by the human, not a message you post on their behalf. An agent must never post an approval that looks like it came from the owner. + +--- + +### Step 0 — first-run setup (skip in one check if the stack is already up) + +Run this gate first: + +```bash +CB_HOME="$HOME/projects/codeband" +if command -v codeband >/dev/null && command -v jam >/dev/null \ + && [ -f "$CB_HOME/.env" ] && [ -f "$CB_HOME/agent_config.yaml" ] \ + && jam whoami >/dev/null 2>&1; then + echo "SETUP-OK" +else + echo "SETUP-NEEDED" +fi +``` + +- If it prints **`SETUP-OK`**, go straight to Step 1 + 2. +- If it prints **`SETUP-NEEDED`**, bootstrap the stack first: + 1. Tell the user this is a one-time setup and confirm **`gh` is authenticated** — if `gh auth status` fails, have them run `gh auth login` first (it's interactive; you can't do it for them). + 2. Collect the **three keys** from the user (ask if not already provided in the conversation): + - `BAND_API_KEY` — their Band **user** key (`band_u_…`, from https://app.band.ai) + - `ANTHROPIC_API_KEY` + - `OPENAI_API_KEY` + 3. Run the idempotent bootstrap, passing the keys via env (it installs uv/gh/codex/jam/codeband as needed, configures the jam profile, creates the codeband home, writes `.env`, and registers the 10 Band agents): + ```bash + BAND_API_KEY="" ANTHROPIC_API_KEY="" OPENAI_API_KEY="" \ + bash "$HOME/.claude/codeband/setup.sh" + ``` + 4. It ends with `cb doctor`. If doctor is all ✓ (a single ⚠ about API-key-vs-subscription billing is fine), continue to Step 1 + 2. If it dies with an error, relay it and stop — common causes: `gh` not authenticated, a bad/again-an-agent (`band_a_`) Band key, or Homebrew missing. + +### Step 1 + 2 — detect the target repo and re-point the home (one bash block) + +```bash +set -e +CB_HOME="$HOME/projects/codeband" +TARGET_DIR="$(pwd)" + +[ -f "$CB_HOME/.env" ] && [ -f "$CB_HOME/agent_config.yaml" ] || { + echo "ERROR: codeband home not set up at $CB_HOME (need .env + agent_config.yaml). Run 'cb init' + 'cb setup-agents' there first."; exit 1; } +command -v jam >/dev/null || { echo "ERROR: jam not installed. Install it first (see github.com/ed-lepedus-thenvoi/jam)."; exit 1; } +jam whoami >/dev/null 2>&1 || { echo "ERROR: no jam profile. Run 'jam init' with your Band user API key first."; exit 1; } + +if [ "$TARGET_DIR" = "$CB_HOME" ]; then + echo "Running from the codeband home itself — keeping the configured repo target (no re-point)." + REPO_URL=""; BRANCH="" +else + REPO_URL="$(git -C "$TARGET_DIR" remote get-url origin 2>/dev/null || true)" + BRANCH="$(git -C "$TARGET_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ -z "$REPO_URL" ]; then + if git -C "$TARGET_DIR" rev-parse --git-dir >/dev/null 2>&1; then + REPO_URL="$TARGET_DIR"; echo "No 'origin' remote — will clone local repo at $TARGET_DIR (committed state only)." + else + echo "ERROR: $TARGET_DIR is not a git repo and isn't the codeband home. Nothing to target."; exit 1 + fi + fi + [ -z "$BRANCH" ] && BRANCH="main" +fi + +if [ -n "$REPO_URL" ]; then + CUR_URL="$(grep -m1 -E '^ url:' "$CB_HOME/codeband.yaml" | sed -E 's/^ url:[[:space:]]*//')" + if [ "$CUR_URL" != "$REPO_URL" ]; then + echo "Re-pointing codeband: '$CUR_URL' -> '$REPO_URL' (branch $BRANCH); wiping previous workspace." + [ -f "$CB_HOME/.ensemble/run.pid" ] && kill "$(cat "$CB_HOME/.ensemble/run.pid")" 2>/dev/null || true + ( cd "$CB_HOME" && codeband reset --dir . --clear-state-rooms >/dev/null 2>&1 || true ) + rm -rf "$CB_HOME/.codeband/repo.git" "$CB_HOME/.codeband/worktrees/"* \ + "$CB_HOME/.codeband/state/"*.jsonl "$CB_HOME/.codeband/state/coder-"*.json \ + "$CB_HOME/.codeband/state/orchestration.db" \ + "$CB_HOME/.codeband/state/.codeband_room" "$CB_HOME/.codeband_room" \ + "$CB_HOME/.codeband/scratch/"* 2>/dev/null || true + sed -i '' -E "s|^ url:.*| url: $REPO_URL|" "$CB_HOME/codeband.yaml" + sed -i '' -E "s|^ branch:.*| branch: $BRANCH|" "$CB_HOME/codeband.yaml" + else + echo "Target unchanged ($REPO_URL @ $BRANCH) — reusing existing workspace." + ( cd "$CB_HOME" && codeband reset --dir . --clear-state-rooms >/dev/null 2>&1 || true ) + fi +fi + +# A stable slug for this repo, used only when onboarding a NEW jam bridge in Step 3. +# Delivery is read from the authoritative room log, so there is no inbox path to derive. +SLUG="$(basename "$REPO_URL" .git 2>/dev/null | tr -c 'A-Za-z0-9._-' '-' | sed -E 's/-+/-/g;s/^-|-$//g')" +[ -z "$SLUG" ] && SLUG="$(basename "$TARGET_DIR")" +TEAM="codeband-$SLUG" +echo "TARGET: $(grep -m1 -E '^ url:' "$CB_HOME/codeband.yaml" | sed -E 's/^ url:[[:space:]]*//') @ $(grep -m1 -E '^ branch:' "$CB_HOME/codeband.yaml" | sed -E 's/^ branch:[[:space:]]*//')" +echo "CB_HOME=$CB_HOME"; echo "TARGET_DIR=$TARGET_DIR"; echo "TEAM=$TEAM" +``` + +Remember `CB_HOME`, `TARGET_DIR`, and `TEAM` from the output — later steps need them. + +### Step 3 — come online as a Band peer (your own identity) + +Run from the **target dir** so your bridge is scoped to this repo's cwd: + +```bash +cd "$TARGET_DIR" +# Use an explicit --session key (== TEAM) so this run's peer is selected +# deterministically — not "first running peer globally" which can bind to +# a completely unrelated session (e.g., Lyra) when other jam peers are active. +# The session key is a stable per-repo name here; a later refactor can swap +# it for $TEAM-$(uuidgen) to get fresh-per-run identities without changing +# the selection mechanism. +JAM_SESSION="$TEAM" +if jam --session "$JAM_SESSION" status 2>/dev/null | grep -q 'running=true'; then + echo "bridge already running for session $JAM_SESSION" +else + jam --session "$JAM_SESSION" onboard --team "$TEAM" >/dev/null 2>&1 +fi +jam --session "$JAM_SESSION" status +export JAM_SESSION +``` + +The first token of the status line (`owner/handle`) is your handle. Quote it to the user later. + +### Step 4 — start the swarm (background) from the home + +```bash +cd "$CB_HOME" && mkdir -p .ensemble && nohup codeband run > .ensemble/run.log 2>&1 & echo $! > .ensemble/run.pid +echo "cb run pid $(cat .ensemble/run.pid)" +``` + +### Step 5 — wait for the swarm to connect + +Poll `~/projects/codeband/.ensemble/run.log` for up to ~40s. Success looks like agents starting / connecting. If you see repeated `HTTP 429` (rate limited) or a preflight/auth/clone error, STOP, kill the run (`kill $(cat ~/projects/codeband/.ensemble/run.pid)`), show the error, and do NOT seed the task — tell the user to retry in a few minutes (429) or fix the error. + +### Step 6 — YOU create the room, add the agents + yourself-as-human, send the task + +This is the heart of it. jam 0.2.5 keeps peer state in an encrypted SQLite store (the 0.1.x `~/.config/jam/sessions/*/*.json` files are gone) — so we DON'T glob session files and we DON'T need cc's REST key. Instead: use jam CLI (`jam chat new` / `jam chat add` / `jam send`) for everything cc does (jam auths via the store internally), and use the Conductor's REST key for the two things that aren't a jam CLI command — resolving cc's room-owner id, and adding the **human** (BAND_API_KEY's user) as a participant of the agent-created room so `cb room-log`, the approve/reject notify half, the watchdog, the feed, and the Step 7 receive-gap Monitor all work in agent-room mode. + +Add agents one at a time via `jam chat add @handle` (avoids `jam chat new --with` / `jam agent list`'s first-page pager bug). Don't add the human via `jam chat add`: humans have no `@owner/handle`, only a UUID — use the REST participant API for that one. + +```bash +cd "$CB_HOME" +GR_TASK="$ARGUMENTS" "$HOME/.local/share/uv/tools/codeband/bin/python" - "$TARGET_DIR" "$CB_HOME" <<'PYEOF' +import asyncio, os, re, subprocess, sys, yaml +from thenvoi_rest import AsyncRestClient, ParticipantRequest +target_dir, cb_home = sys.argv[1], sys.argv[2] +task = os.environ.get("GR_TASK", "").strip() or "(no task text provided)" + +# 1) Find cc's @owner/handle from the session-scoped status (deterministic). +# JAM_SESSION was exported in Step 3; it is == TEAM (codeband-). +# Using --session avoids "first running peer globally" which can pick the +# wrong identity when other jam peers (e.g. Lyra) are concurrently active. +jam_session = os.environ.get("JAM_SESSION", "") +if not jam_session: + print("ERROR: JAM_SESSION not set — Step 3 must export it.", file=sys.stderr); sys.exit(1) +js = subprocess.run(["jam", "--session", jam_session, "status"], capture_output=True, text=True) +if js.returncode != 0: + print(f"ERROR: jam --session {jam_session} status failed:", js.stderr, file=sys.stderr); sys.exit(1) +cc_handle = None +for line in js.stdout.splitlines(): + parts = line.strip().split() + if parts and "/" in parts[0]: + cc_handle = parts[0]; break +if not cc_handle: + print(f"ERROR: could not parse handle from jam --session {jam_session} status:\n{js.stdout}", file=sys.stderr); sys.exit(1) + +# 2) Create the room as cc. jam CLI auths via the encrypted store internally. +cn = subprocess.run(["jam","chat","new","--as",cc_handle], capture_output=True, text=True) +if cn.returncode != 0: + print("ERROR: `jam chat new` failed:", cn.stderr, file=sys.stderr); sys.exit(1) +m = re.search(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", cn.stdout) +if not m: + print("ERROR: could not parse room id from `jam chat new`:", cn.stdout, file=sys.stderr); sys.exit(1) +rid = m.group(0) + +cfg = yaml.safe_load(open(os.path.join(cb_home, "codeband.yaml"))) +rest = cfg["band"]["rest_url"] +ac = yaml.safe_load(open(os.path.join(cb_home, "agent_config.yaml"))) +band_key = os.environ.get("BAND_API_KEY","").strip() +if not band_key: + print("ERROR: BAND_API_KEY not set in env.", file=sys.stderr); sys.exit(1) +cond_key = ac["agents"]["conductor"]["api_key"] + +async def main(): + cond = AsyncRestClient(api_key=cond_key, base_url=rest) + cond_me = (await cond.agent_api_identity.get_agent_me()).data + cond_handle, cond_name = cond_me.handle, cond_me.name + + # 3) Add the Conductor first via jam CLI so we can use its REST key on the room. + r = subprocess.run(["jam","chat","add",rid,"@"+cond_handle,"--as",cc_handle], + capture_output=True, text=True) + if r.returncode != 0: + print(f"ERROR: jam chat add @{cond_handle} failed:", r.stderr, file=sys.stderr); sys.exit(1) + + # 4) Resolve cc's id via Conductor REST — cc is the room's `owner`-role participant. + parts = (await cond.agent_api_participants.list_agent_chat_participants(rid)).data + cc_id = next((p.id for p in parts if getattr(p, "role", None) == "owner"), None) + if not cc_id: + print("ERROR: could not resolve room owner (cc) from participants.", file=sys.stderr); sys.exit(1) + + # 5) Register the task (tasks row + .codeband/state/.codeband_room pointer, atomically) BEFORE any other agent is added and before any kickoff is sent. + reg = subprocess.run(["cb","register-task","--room",rid,"--owner",cc_id, + "--owner-handle",cc_handle,"--description",task,"--dir",cb_home], + capture_output=True, text=True) + if reg.returncode != 0: + print("REGISTRATION FAILED (cb register-task exit", reg.returncode, ") — the seed is ABORTED: no task message was sent and no other agent was activated.", file=sys.stderr) + print(reg.stderr, file=sys.stderr) + print("Report this registration failure to the user verbatim and STOP. Do not retry, do not message the swarm.", file=sys.stderr) + sys.exit(1) + + # 6) Resolve every other agent's @owner/handle via REST, then `jam chat add` one at a time. + async def one(name, a): + c = AsyncRestClient(api_key=a["api_key"], base_url=rest) + me = (await c.agent_api_identity.get_agent_me()).data + return name, me.handle + metas = await asyncio.gather(*[one(n,a) for n,a in ac["agents"].items() if n != "conductor"]) + for name, h in metas: + rr = subprocess.run(["jam","chat","add",rid,"@"+h,"--as",cc_handle], + capture_output=True, text=True) + if rr.returncode != 0: + print(f"ERROR: jam chat add @{h} ({name}) failed:", rr.stderr, file=sys.stderr); sys.exit(1) + + # 7) Add the human (BAND_API_KEY's user) as a participant — Option A. + # Proven live 2026-06-14: Conductor's REST key (a non-creator member) can + # add by human UUID; the room then becomes visible to the human API, so + # `cb room-log`, `cb approve`/`cb reject`'s notify half, the watchdog, + # the feed, and the Step 7 receive-gap Monitor all stop 404ing. + human = AsyncRestClient(api_key=band_key, base_url=rest) + human_id = (await human.human_api_profile.get_my_profile()).data.id + await cond.agent_api_participants.add_agent_chat_participant( + rid, participant=ParticipantRequest(participant_id=human_id)) + + # 8) Send the kickoff as cc via jam CLI. `@` is parsed as a Band mention. + msg = (f"@{cond_handle} here's a new task for the team. Please send it to the Planner for analysis, " + f"then coordinate the build. Report progress, questions, and PR-approval requests back to me in this room.\n\n" + f"Task: {task}\n\n" + f"Repository: {cfg['repo']['url']} (branch: {cfg['repo']['branch']})") + sd = subprocess.run(["jam","send",rid,msg,"--as",cc_handle], capture_output=True, text=True) + if sd.returncode != 0: + print("ERROR: jam send (kickoff) failed:", sd.stderr, file=sys.stderr); sys.exit(1) + + print("ROOM", rid) + print("HANDLE", cc_handle) + print("OWNER_ID", cc_id) + print("CONDUCTOR", cond_name) +asyncio.run(main()) +PYEOF +``` + +If this prints `ROOM ` you've seeded the task as room owner. Remember `ROOM` (the room id, needed for approvals and for the Step 7 room poll) and `OWNER_ID` (so Step 7 can skip your own messages). Step 7's poll is authoritative on its own — no bridge inbox file is required. If it errors, show the user and stop. + +### Step 7 — arm the room Monitor (this is your "push") + +Call the **Monitor** tool (persistent) so each new Band message auto-wakes you. Read the **authoritative full room** via `cb room-log` — NOT the jam bridge's `team-lead.json`, which is a filtered owner-context slice (it can stall silently when traffic skips the owner's mention, and it can drop or re-emit inbound approvals on bridge retry — both observed in dogfood cluster 7). Substitute `CB_HOME`, `ROOM`, and `OWNER_ID` literally from Step 6. + +> Monitor tool call — `persistent: true`, description `"codeband: new Band messages"`, command: +> ``` +> PY="$HOME/.local/share/uv/tools/codeband/bin/python"; CB_HOME=""; ROOM=""; OWNER_ID=""; "$PY" -u -c " +> import json,subprocess,time,os +> cb_home=os.environ['CB_HOME']; room=os.environ['ROOM']; owner=os.environ['OWNER_ID'] +> def fetch(): +> try: +> r=subprocess.run(['cb','room-log','--json','--dir',cb_home,room],capture_output=True,text=True,timeout=20) +> if r.returncode!=0: return [] +> out=[] +> for l in r.stdout.splitlines(): +> l=l.strip() +> if not l: continue +> try: out.append(json.loads(l)) +> except Exception: pass +> return out +> except Exception: return [] +> seen=set() +> # Prime on startup so existing history is not replayed. Dedup key = inserted_at (microsecond UTC, unique per message). +> for m in fetch(): +> k=m.get('inserted_at') +> if k: seen.add(k) +> while True: +> for m in fetch(): +> k=m.get('inserted_at') +> if not k or k in seen: continue +> seen.add(k) +> if m.get('message_type')!='text': continue # drop thought/tool_call/tool_result +> if m.get('sender_id')==owner: continue # skip own outbound messages +> sender=m.get('sender_name') or m.get('sender_id') or '?' +> content=(m.get('content') or '')[:280] +> print('NEW BAND MSG ['+sender+']: '+content,flush=True) +> time.sleep(3) +> " +> ``` + +### Step 7b — arm the PR watcher (CRITICAL — the swarm's deliverable is a PR, and the Conductor does NOT reliably @mention you when one opens) + +The Conductor often routes the coder's "PR ready" message to a reviewer/mergemaster and never loops you in — and with `auto_merge` it may merge without ever asking. So do NOT rely on room messages to learn about PRs. Watch `cb pending` (GitHub-based, authoritative) with a second persistent **Monitor**. Substitute `CB_HOME` literally: + +> Monitor tool call — `persistent: true`, description `"codeband: PR status"`, command: +> ``` +> CB_HOME=""; cd "$CB_HOME"; prev="" +> while true; do +> cur="$(codeband pending --dir . 2>/dev/null | grep -E '#[0-9]+|http' | tr -s ' ')" +> if [ -n "$cur" ] && [ "$cur" != "$prev" ]; then echo "PR STATUS:"; echo "$cur"; prev="$cur"; fi +> sleep 25 +> done +> ``` + +When this fires, a PR has opened or changed state. Run `cd "$CB_HOME" && codeband pending --dir .` for the full picture and **tell the user immediately, with the PR URL** — that's the whole point of the run. + +### Step 7c — arm the liveness watcher (so a SILENT stall doesn't go unnoticed) + +The room and PR Monitors only fire on Band messages and on PRs. A swarm can die *silently* mid-run — e.g. a Codex turn timeout + a `422 Failed to mark message as processed` stalls an agent's Band cursor, producing no room message, no PR, and no surfaced error. Watch the run log for failure signatures **and** for a flat-line (no real progress) with a third persistent **Monitor**. Substitute `CB_HOME` literally: + +> Monitor tool call — `persistent: true`, description `"codeband: swarm liveness"`, command: +> ``` +> CB_HOME=""; LOG="$CB_HOME/.ensemble/run.log" +> ERRRE="timed out|Failed to mark message|crashed|Traceback|429|too many requests|preflight fail|unauthorized" +> prev_err=0; prev_real=-1; flat=0 +> while true; do +> [ -f "$LOG" ] || { sleep 30; continue; } +> errs=$(grep -cE "$ERRRE" "$LOG" 2>/dev/null); errs=${errs:-0} +> if [ "$errs" -gt "$prev_err" ]; then echo "SWARM ERROR SIGNAL:"; grep -E "$ERRRE" "$LOG" | tail -n $((errs-prev_err)); prev_err=$errs; fi +> real=$(grep -vcE "no longer exists|Watchdog|\[WATCHDOG\]" "$LOG" 2>/dev/null); real=${real:-0} +> if [ "$real" = "$prev_real" ]; then flat=$((flat+1)); else flat=0; prev_real=$real; fi +> if [ "$flat" = "12" ]; then echo "SWARM STALL: no real log progress for ~6m — swarm likely stuck (check for a timed-out turn / stalled cursor)."; fi +> sleep 30 +> done +> ``` + +On an **error signal**, check whether the pipeline is recovering on its own; if it's been quiet since, treat it as a stall. On a **SWARM STALL**, read `cd "$CB_HOME" && codeband pending --dir .` and the log tail (`grep -vE 'no longer exists' "$CB_HOME/.ensemble/run.log" | tail -20`), tell the user the swarm has stalled and what the last real activity was, and offer to nudge the Conductor or restart the run. Don't sit on it. + +### Step 7d — mandatory self-wakeup (owner/jam mode) + +When you are the task owner/initiator (agent-as-owner / jam mode — you started the session under your own Band identity), you **MUST** set a recurring self-wakeup, not rely on a passive room monitor alone. The monitor can miss events or stall silently; the self-wakeup is your liveness guarantee. + +On each wakeup, re-check the FSM state and the room so you never go dormant while the swarm needs an owner action — approving at `merge_pending`, reacting to a gate-stall, or escalating a blocked task. This is **mandatory** whenever you own the task. (When a human owns the task, a monitor alone is fine.) + +Use `ScheduleWakeup` with a delay of 270s or less (stays within the prompt-cache window) and pass the same `/codeband` prompt back as `prompt` so each firing re-enters coordination. Example reasoning for the `reason` field: `"owner self-wakeup: re-check FSM state and swarm room"`. + +### Step 8 — hand off to the user (keep it short) + +Tell the user: +- the swarm is running against ****, and **you** are coordinating it as **** +- it operates on **origin** — push local work first if needed +- they can just talk to you; you'll relay progress, **announce the PR URL as soon as it opens**, and surface anything that needs a decision +- to stop: `kill $(cat ~/projects/codeband/.ensemble/run.pid)` and `jam daemon stop` (run from the target dir), and you'll stop the Monitors + +### The rest of the session — coordinating (you're the sole coordinator) + +You have four Monitors/wakeup paths: **room messages** (`cb room-log` via the room Monitor), **PR status** (`cb pending`), **swarm liveness** (run-log error/stall signals), and the **owner self-wakeup** (recurring FSM/room check). + +**On a room-message event:** +1. Read the authoritative transcript: `cd "$CB_HOME" && cb room-log --dir . "$ROOM"` (or `--json` if you need structured sender/content/timestamp fields). +2. Decide and act. To respond, post into the room as your jam identity: `cd "$TARGET_DIR" && jam send "$ROOM" "" --as "$HANDLE"`. Include the recipient's `@owner/handle` when you need a specific agent to see it. +3. Relay a concise summary to the user. + +**On a PR-status event** (this is the deliverable — never sit on it): +1. `cd "$CB_HOME" && codeband pending --dir .` for full risk/eligibility, and `gh pr view --repo ` for the PR itself. +2. **Tell the user the PR URL right away** and what it does. +3. Merging is gated: as task owner you are the merge approver, and the swarm will ask you directly — handle it per **Merge approval** below. To request changes at any point, post into the room: `cd "$TARGET_DIR" && jam send "$ROOM" "CHANGES REQUESTED on PR #: ." --as "$HANDLE"`. +4. As sole coordinator you may approve low-risk PRs autonomously, but **state what you're approving** to the user first; for anything destructive, ambiguous, or high-risk, ask the user before approving. + +**Merge approval** — when you receive a merge-approval request (a Band @mention naming a PR, e.g. "PR #12 … is awaiting your merge approval at head . Approve with: cb approve 12"): +1. **Review before granting**: confirm the gate's verdicts passed (`cd "$CB_HOME" && codeband pending --dir .`) and read the diff (`gh pr diff --repo `) — you are approving specific code, not a status. Never approve blindly. +2. **To grant**: + a. Record the durable grant (no room notification yet): `cd "$CB_HOME" && cb approve --no-notify ` + b. If that exits 0, post the coordinator notification as yourself (not as the human key): `jam send "$ROOM" "Durable merge grant recorded for PR # — please proceed with merge." --as "$HANDLE"` + The grant is SHA-pinned — if new commits land after your approval, it expires and a fresh request arrives. **Never post "approved" as a bare chat message** — only the `cb approve` CLI grant is the durable record; an agent posting approval text without a `cb approve` grant violates attribution integrity. +3. **To withhold**: post on Band (`jam send`) stating what is missing or wrong. Do not run `cb approve`. + +Outbound to the Conductor at any time: `cd "$TARGET_DIR" && jam send "$ROOM" "@ ..." --as "$HANDLE"`. Do NOT run `cb feed` (it streams and blocks). diff --git a/docs/design/verifier-seat.md b/docs/design/verifier-seat.md new file mode 100644 index 0000000..2e83963 --- /dev/null +++ b/docs/design/verifier-seat.md @@ -0,0 +1,94 @@ +# Verifier seat — reservation note (Stage-3) + +> **Status: RESERVATION, not implementation.** This note reserves the design +> space for a dedicated *Verifier* role and records the shape we expect it to +> take. It is deliberately not built yet — the open questions below are real, +> and the design conversation happens *before* the build. Nothing in the +> codebase depends on this seat existing. + +## Why a Verifier (and why now) + +Stage-3 added evidence integrity (the hash-chained `transition_log` / +`audit_log` + `cb verify-log`), attribution (CLI invocation logging + role +markers), and peer-governance mechanicals (reconcile requires a grant, the +universal scope rule, the Conductor's verify-claims duty). Those are +*primitives and prompt duties*. What they lack is a **seat that owns running +them** — today the Conductor is asked to verify claims, but the Conductor is a +router, not an auditor, and finding 27 showed the Conductor's verification gap +is real. The Verifier is the role whose whole job is to not take anyone's word +for it. + +## Dual mandate + +The Verifier carries two distinct duties under one seat: + +1. **Acceptance / QA verdict.** Like a Code Reviewer but for *acceptance*: does + the change actually do what the task asked? This is a verdict leg in the + verdict-list architecture (alongside `verify` and `review`) — a SHA-pinned + passing record gated into a `to_state` the merge-eligibility check reads. + +2. **Protocol-integrity duties.** The part no existing seat owns: + - runs `cb verify-log` (both chains) on a cadence and on demand, and treats + a break / head-regression as a blocking finding, not a note; + - cross-checks agents' **claims** against store state (the same + `cb status` discipline the Conductor's verify-claims clause now mandates, + but as a primary duty rather than a side check) — "merged / abandoned / + approved / blocked / resumed" claims are verified against the FSM + grants; + - audits **room-record vs transition-log consistency**: every protocol + effect announced in chat should have a corresponding gate/store fact, and + every gated effect should have its expected chat trail. Divergence is a + finding. + +The integrity duties are detection, not prevention — consistent with the +Stage-3 posture (a process with DB write access can still recompute a chain). +The Verifier makes tampering and drift *visible and attributed*, fast. + +## Opposite-model pairing rule + +The Verifier's model must differ in vendor from the **coder's** vendor for the +work under verification — the same adversarial principle as the existing +Coder→Reviewer cross-model pairing (a Claude coder's PR routes to a Codex +reviewer, and vice versa). Rationale: a verifier sharing the coder's model +shares its blind spots and failure modes. Concretely, the pairing rule is +`verifier.vendor != coder.vendor`, resolved at allocation time the same way +`WorkerPool.pair_for_task` is intended to resolve the reviewer pairing. + +## Implementation shape (under the verdict-list architecture) + +The seat is deliberately small — one of each of the following, mirroring how +the reviewer leg is wired: + +- **one config entry** — a `verifiers` pool under `agents` (`{framework: + {count, model}}`), like `reviewers`; contributes a `verify_acceptance` (name + TBD) verdict leg to the required-verdicts snapshot when enabled. +- **one leg** — a `cb-phase verify-acceptance` (name TBD) handoff leg that + records the SHA-pinned acceptance verdict, plus a thin command (or a + watchdog-style cadence) for the integrity sweep. Role-gated to `verifier`. +- **one prompt** — `prompts/verifier.md`, carrying the dual mandate, the scope + rule (3b), and the verify-claims discipline (3c) as primary duties. +- **one pool slot** — a `verifier-{framework}-{index}` identity in the worker + pool, allocated opposite-vendor to the coder. + +## Open questions (genuinely open — resolve before building) + +- **Verdict vs. auditor split.** Is the acceptance verdict and the + integrity-audit duty really one seat, or two? One seat keeps the model count + inside Band's free-tier cap; two cleanly separates "judge this change" from + "audit the whole ledger." Leaning one seat with two modes — but open. +- **Integrity-sweep cadence.** On-demand only, per-merge, or a watchdog-like + interval? The watchdog already has an incremental integrity rung (Stage-3 + PR1); does the Verifier *replace* that rung, *consume* its alerts, or run + independently full-history? Overlap must be deliberate, not accidental. +- **Authority of an integrity finding.** Can the Verifier *block* a merge on a + chain break, or only *report*? Blocking turns detection into a gate (a + posture shift); reporting keeps Stage-3's detection-over-prevention line. +- **Free-tier budget.** The default `cb init` config is 8 agents under Band's + 10-agent cap. A verifier pool spends from that budget — is it on by default, + or opt-in for users who want the seat? +- **Pairing under single-vendor configs.** If a user runs Claude-only, the + opposite-vendor rule cannot be satisfied. Degrade to same-vendor-different- + model? Warn (as `cb doctor` warns on reviewer capacity)? Disable the seat? +- **Room-vs-log audit scope.** "Every chat claim has a store fact" is + expensive to evaluate exhaustively. What is the bounded, deterministic + version that catches the row-5 / finding-25 class of drift without + re-reading entire rooms each sweep? diff --git a/docs/engineering-brief.md b/docs/engineering-brief.md new file mode 100644 index 0000000..f0d25f7 --- /dev/null +++ b/docs/engineering-brief.md @@ -0,0 +1,560 @@ +# Codeband, jam, and the `/codeband` command — engineering brief + +> **Snapshot as of 2026-05-31.** Predates initiator-as-owner (PRs #23–25) and the Stage-2 merge gate. Accurate as a historical mechanism brief; refresh pending post-Stage-2. + +**Audience:** CTO / engineering leadership (and the Claude Code agent reading on their behalf). +**Purpose:** A complete, mechanism-level account of (1) the `/codeband` Claude Code command and its `jam` integration, (2) the deterministic-orchestration hardening of codeband itself, (3) the onboarding skill built from it, and (4) the broader pattern library this is a first instance of. +**Date:** 2026-05-31. + +> **Note for the reader's agent:** the deterministic-orchestration code (Part 2) lives in this repo under `src/codeband/state/`, `src/codeband/cli/handoff.py`, and `src/codeband/agents/watchdog.py`, with the design in `docs/rfc-deterministic-orchestration.md`. The `/codeband` command and `jam` integration (Part 1) live outside this repo, in the operator's `~/.claude/` (`commands/codeband.md`, `codeband/setup.sh`); the descriptions here are authoritative for those. + +--- + +## Contents + +- [TL;DR](#tldr) +- [Glossary](#glossary) +- [Part 1 — The `/codeband` command & the `jam` integration](#part-1) +- [Part 2 — Deterministic orchestration in codeband](#part-2) +- [Part 3 — The onboarding skill & distribution](#part-3) +- [Part 4 — The pattern library: protocoled patterns as skills](#part-4) + +--- + +## TL;DR + +We built and validated a working system in which **a single Claude Code session acts as the human-facing coordinator of a 10-agent autonomous coding swarm**, end to end, without the operator ever touching a chat UI. You type `/codeband ` inside any repo; Claude provisions itself an identity on the Band coordination platform, owns the task room, drives the swarm, watches GitHub and the process log out-of-band, and hands you back a reviewed PR. + +Getting there required diverging from how the underlying tools (`jam`, `codeband`) are normally used, in several non-obvious ways that each cost real debugging. The two headline divergences: + +1. **We replaced a missing native capability with a synthesized one.** The intended design pushes inbound messages into Claude's turn automatically (via a harness feature called `TeamCreate`). That feature isn't in our Claude Code build. Rather than abandon the approach, we **synthesized "push" using a polling `Monitor`** on the message bridge's inbox file — Claude gets woken on every new message as if it were native push. + +2. **We are making codeband itself production-grade by separating two things it currently fuses:** *deciding* what to do (creative, dynamic — stays in the LLM) from *enforcing and recording* what is allowed to happen (mechanical — moves into code). This deterministic control plane — a state machine, durable storage, mechanical liveness signals, and universal crash-recovery — is now prompt-wired through `cb-phase`, so the LLM still decides the route while code enforces and remembers the effects. + +A third strand is **distribution and reuse**: `/codeband` is one concrete instance of a more general idea — *protocoled multi-agent patterns shipped as skills* — that we want to generalize well beyond Claude Code and well beyond coding. + +--- + +## Glossary + +- **Band** — a messaging/coordination platform where autonomous agents (and humans) live, addressed by handle, talking in rooms. +- **codeband** — a multi-agent coding orchestrator: a Conductor LLM decomposes a task, spins up a pool of coder/reviewer/merge agents (Claude *and* Codex), and they coordinate on Band to plan → code → review → merge a task into a PR. +- **jam** — a CLI that wires a Claude Code session onto Band *as its own agent*, so it can send/receive Band messages. +- **`/codeband`** — the slash command that glues these together so *your* Claude session becomes the swarm's coordinator and your single point of contact. + +--- + + + +# Part 1 — The `/codeband` command & the `jam` integration + +> **One-line:** `/codeband ` makes *this* Claude Code session the sole coordinator of a 10-agent codeband swarm running against the repo you're sitting in — and it does so by inserting Claude onto the Band platform as its own agent, owning the task room, and synthesizing the message-push and liveness signals that the stock tooling doesn't reliably provide. + +The command lives at `~/.claude/commands/codeband.md` (a user-level slash command, surfaced as the `codeband` skill) with an idempotent bootstrap at `~/.claude/codeband/setup.sh`. + +## 1.1 The big picture + +There is a **shared "codeband home"** at `~/projects/codeband` that holds the API keys (`.env`) and the 10 Band-registered agents (`agent_config.yaml`). Each run re-points that home at the current repo's `origin` and runs from there. Because the home is shared, **only one swarm runs at a time** — switching repos wipes the prior workspace. + +The interesting engineering is *how a Claude Code session inserts itself as the coordinator* of a swarm it didn't create. The flow, end to end: + +1. **Claude comes online as its own Band peer.** `jam onboard --team codeband-` provisions an ephemeral Band agent (`/claude--`) with its own agent API key and starts a background "sockpuppet" bridge daemon that receives Band messages in real time and writes them to a local inbox file. +2. **Claude creates the task room with its own agent key** and adds the 8 codeband agents — so Claude is `task.owner` and the Conductor reports back to *it*. (Our first assumption was that the *user* key had to own the room; that was wrong — see §1.4.) +3. **Claude seeds the task** to the Conductor, then **arms three persistent `Monitor`s** that auto-wake it (inbox / PR / liveness — §1.3). +4. **Claude coordinates**: reads inbound via the inbox file, replies with `jam reply`, approves PRs by posting approval text into the room, and relays concise summaries to you in natural language. You never touch the Band UI. + +## 1.2 How Claude inserts itself as coordinator (the mechanics) + +### Bootstrap gate (Step 0) + +Before anything, the command checks whether the stack is already provisioned: + +```bash +CB_HOME="$HOME/projects/codeband" +if command -v codeband >/dev/null && command -v jam >/dev/null \ + && [ -f "$CB_HOME/.env" ] && [ -f "$CB_HOME/agent_config.yaml" ] \ + && jam whoami >/dev/null 2>&1; then echo "SETUP-OK"; else echo "SETUP-NEEDED"; fi +``` + +`SETUP-NEEDED` triggers `setup.sh` (see Part 3 for the full bootstrap). `SETUP-OK` goes straight to the swarm. + +### Re-point the home at the current repo (Steps 1–2) + +Claude reads the current repo's `origin` (`git remote get-url origin`) and branch, compares it to the `url:` already in `codeband.yaml`, and if different **wipes and re-points**: kills any running pid, `codeband reset`, removes `.codeband/repo.git`, worktrees, state files and scratch, then patches `url:`/`branch:` in `codeband.yaml`. It derives a stable slug from the repo URL and builds: + +- `TEAM="codeband-"` +- `INBOX="$HOME/.claude/teams/$TEAM/inboxes/team-lead.json"` ← **the bridge inbox file the inbox Monitor watches.** + +> **Note:** the swarm clones the repo's **`origin`** — it works on what's *pushed*, not your local uncommitted edits. If there's no `origin`, it falls back to the local committed state. + +### Come online as an ephemeral Band peer (Step 3) + +```bash +cd "$TARGET_DIR" +if jam daemon status 2>/dev/null | grep -q '^Running'; then echo "bridge already running" +else jam onboard --team "$TEAM" >/dev/null 2>&1; fi +``` + +`jam onboard` provisions the identity `/claude--`, gives it its own agent API key, and starts the sockpuppet bridge. (The `grep -q '^Running'` is deliberate — see the gotcha in §1.4.) + +### Start the swarm in the background (Steps 4–5) + +```bash +cd "$CB_HOME" && mkdir -p .ensemble && nohup codeband run > .ensemble/run.log 2>&1 & echo $! > .ensemble/run.pid +``` + +Claude then polls `.ensemble/run.log` for ~40s; on repeated `429`/preflight/auth/clone errors it kills the run and does **not** seed the task. + +### Create the room as itself, add the 10 agents, seed the task (Step 6) + +This is the core trick, and it bypasses jam (see §1.4). An inline Python heredoc, run with codeband's bundled interpreter, uses the `thenvoi_rest` SDK directly: + +1. **Recover Claude's own agent key** by scanning `~/.config/jam/sessions/*/*.json` for the record whose `cwd` matches the target dir and pulling its `agent_api_key`. +2. Build an `AsyncRestClient(api_key=cc_key, ...)` — **Claude now acts as itself over the Band REST API.** +3. `create_agent_chat(...)` — **Claude owns the room.** +4. For each of the 10 codeband agents: `add_agent_chat_participant(...)`. +5. Post the seed message `@`-mentioning the Conductor: *"here's a new task for the team. Please send it to the Planner … Report progress, questions, and PR-approval requests back to me in this room. Task: … Repository: …"* +6. Persist the room id to `.codeband_room`. + +Because Claude created the room with its own key, it is `task.owner`, and the Conductor `@`-mentions *Claude* for reports — which is exactly why the inbox Monitor (next section) is the delivery channel. + +## 1.3 The three Monitors (in exact detail) + +The most important architectural decision in the command is that **Claude is woken by three independent, persistent `Monitor`s** rather than by a single chat stream. This is defense in depth: the in-band chat channel (Conductor reports) is unreliable, so two of the three monitors get their truth from *out of band* (GitHub and the process log). + +### Monitor 1 — Inbox (synthesized message push) + +Watches the bridge inbox file `~/.claude/teams//inboxes/team-lead.json`, polling every **2 seconds**. It pre-seeds the set of already-seen `message_id`s so it only fires on genuinely new messages, then: + +```python +for m in json.load(open(INBOX)): + mid = m['band']['message_id'] + if mid not in seen: + seen.add(mid) + print('NEW BAND MSG ' + mid + ': ' + (m.get('summary') or '')[:240], flush=True) +``` + +Each `NEW BAND MSG …` line auto-wakes Claude, which reads the message and replies via `jam reply`. **This is the synthesized replacement for native push** (see §1.4, #2). + +### Monitor 2 — PR watcher (GitHub-backed, authoritative) + +Watches `codeband pending` (which is backed by GitHub), polling every **25 seconds**, and fires on any change to the filtered PR lines: + +```bash +cur="$(codeband pending --dir . 2>/dev/null | grep -E '#[0-9]+|http' | tr -s ' ')" +if [ -n "$cur" ] && [ "$cur" != "$prev" ]; then echo "PR STATUS:"; echo "$cur"; prev="$cur"; fi +``` + +This exists because **the Conductor is an unreliable reporter** (§1.4, #4): it routes the coder's "PR ready" to a reviewer/mergemaster and often never loops the owner in — and with `auto_merge` it may merge without asking. We do not trust chat for PR awareness; we poll GitHub. + +### Monitor 3 — Liveness (silent-stall detector) + +Tails `.ensemble/run.log` every **30 seconds**, watching two things: + +**(a) Error signatures** — a count of lines matching: + +``` +timed out | Failed to mark message | crashed | Traceback | 429 | too many requests | preflight fail | unauthorized +``` + +When the count rises, it prints `SWARM ERROR SIGNAL:` plus the new matching lines. + +**(b) Flat-line detection** — it counts "real" log lines (inverse-grepping out watchdog noise: `no longer exists | Watchdog | [WATCHDOG]`). If that count is unchanged for **12 consecutive 30-second patrols (~6 minutes)**, it prints `SWARM STALL: no real log progress for ~6m …`. + +This is the guard for the *silent* death we actually watched happen: a Codex turn timed out and a `422 Failed to mark message` stalled that agent's Band cursor — no message, no PR, no error chat. The chat-recency machinery never noticed. The flat-line detector would. + +> **Why three, and why two are out-of-band:** chat is necessary (the swarm talks on it) but not sufficient (it lies about PR status and goes silent on stalls). GitHub is the source of truth for the deliverable; the process log is the source of truth for liveness. Triangulating across all three is what makes the coordinator trustworthy. + +## 1.4 Hard-won fixes & workarounds (the non-obvious engineering) + +Each of these cost real debugging and is non-obvious enough to call out explicitly. + +### 1. Claude *can* own the room — the "agent not in peer network" wall was a `jam` bug + +We initially hit a wall building the room: `jam chat new --with @handle` and `jam agent list` reported codeband's agents as "not in peer network." Root cause: **jam's peer resolver only reads the first page (~20) of peers** and silently drops the rest, so the extra codeband agents fell off the page. The Band agent API itself pages fine. **Fix:** bypass jam's room-building CLI entirely and create the room + add participants via the `thenvoi_rest` agent API directly (Step 6). *Rule baked into the command: never use `jam chat new --with` / `jam agent list` to build the room.* + +### 2. No `TeamCreate` in this Claude build → synthesized push via Monitor + +The intended band-peer design relies on a harness feature, `TeamCreate`, to register the session as a team member so the harness injects new inbox messages as native `` blocks. **`TeamCreate` is absent from our Claude Code build**, so that injection never fires — the bridge writes the inbox file, but nothing delivers it into Claude's turn. **Fix:** the inbox Monitor (§1.3, Monitor 1) polls that same inbox file every 2s and prints a wake line per new message. From Claude's perspective it's push; we built the last hop ourselves. (The stock band-peer skill explicitly says: if `TeamCreate` isn't available, the skill won't work — *we made it work anyway.*) + +### 3. `cb approve` is broken in this topology → approve via `jam reply` + +`codeband approve` reads `.codeband_room` and posts as the **user** key — who is *not* a participant in Claude's room (Claude created it with its own *agent* key). So approvals fail. **Fix:** approve by posting the approval text into the room yourself, mirroring codeband's expected wording: + +```bash +jam reply "APPROVED: Please merge PR #. Reviewed and approved." +``` + +(Request changes with `"CHANGES REQUESTED on PR #: ."`) + +### 4. The Conductor is an unreliable reporter → GitHub-backed PR watcher + +Covered above (§1.3, Monitor 2). The Conductor routes "PR ready" elsewhere and often never loops the owner in. We get PR truth from `cb pending`/GitHub, not chat. + +### 5. Smaller gotchas that cost time + +- **`jam daemon status` exits 0 even when not running** → we must `grep -q '^Running'` rather than trust the exit code. +- **`claude --worktree` can't nest** inside a running session → fleet/dev sessions must launch from a fresh terminal or via manual `git worktree add`. +- The swarm works on `origin` (pushed state), not local edits. + +## 1.5 Where we deviate from stock `jam` (and why) + +Stock `jam`, as used by the `band-peer` skill, wires a Claude session as a **passive, push-driven peer**: inbound messages arrive automatically as `` blocks (via `TeamCreate`), rooms are built with jam's CLI conveniences (`jam chat new --with`), and Claude is a *participant*, not an owner. Our usage diverges on five axes — every divergence is a deliberate response to a concrete failure of the intended path: + +| Axis | Stock `jam` / band-peer | Our `/codeband` usage | Why | +|------|------------------------|----------------------|-----| +| **Inbound delivery** | Native `` push via `TeamCreate` | Synthesized push: a `Monitor` polls the inbox file every 2s | `TeamCreate` absent from our CC build | +| **Room construction** | `jam chat new --with` / `jam agent list` | Direct `thenvoi_rest` agent API (`create_agent_chat`, `add_agent_chat_participant`) | jam's resolver only pages ~20 peers (a bug) | +| **Identity** | jam abstracts the agent key away | We scrape Claude's own `agent_api_key` from `~/.config/jam/sessions/*.json` and act as that agent over REST | needed to own the room as an agent | +| **Role** | Claude is a participant/peer | Claude is the room **owner** (`task.owner`) | so the Conductor reports back to Claude | +| **Source of truth** | the Band message stream | message stream **+ GitHub (`cb pending`) + process log (`run.log`)** | chat is unreliable for PR status and silent on stalls | + +**Net:** jam supplies the parts it's reliable at — identity provisioning (`jam onboard`), the bridge daemon, and the message read/reply primitives (`jam inbox`, `jam reply`, `jam ack`). Everything jam is *unreliable* at in this topology — push delivery, room construction, and authoritative state — we route around it (the agent API, our own Monitors, GitHub, the process log). + +`jam` subcommands we rely on: `init`, `whoami`, `onboard`, `daemon status/stop`, `inbox`, `reply`, `ack`. We avoid: `chat new --with` / `agent list` (paging bug), `cb feed` (blocks/streams), `cb approve` (wrong key). + +## 1.6 Using it & tearing it down + +- **Run:** from inside any git repo, `/codeband Add a dark-mode toggle and open a PR`. First run bootstraps the stack; later runs go straight to the swarm. +- **Stop:** `kill $(cat ~/projects/codeband/.ensemble/run.pid)` + `jam daemon stop`. + +**File locations:** command `~/.claude/commands/codeband.md`; bootstrap `~/.claude/codeband/setup.sh`; runtime home `~/projects/codeband`; bridge inbox `~/.claude/teams/codeband-/inboxes/team-lead.json`; jam session store `~/.config/jam/sessions/*/*.json`. + +--- + + + +# Part 2 — Deterministic orchestration in codeband + +> **One-line:** We are making codeband production-grade by adding a deterministic, code-enforced control plane — a state machine, durable storage, mechanical liveness signals, and universal crash-recovery — *without* sacrificing the agent autonomy and parallelism that make it worth using. The guiding principle: **the LLM decides; code enforces and remembers.** + +Full design: `docs/rfc-deterministic-orchestration.md` (this repo). The implementation lives under `src/codeband/state/`, `src/codeband/cli/handoff.py`, and `src/codeband/agents/watchdog.py`. + +## 2.1 The problem: codeband today is overwhelmingly LLM-driven, with a thin deterministic scaffold + +Three concrete fragilities, each observed in practice: + +- **There is no state machine in code.** "State" is free-text *protocol envelopes* (e.g. `protocol code_review pr 42 round 1 state findings_posted`) that the **Conductor LLM** writes into memory and re-reads. Python parses those strings *only to render `cb status`* — nothing reads them to *drive* or *gate* a transition. So nothing in code prevents a skipped review, a double-merge, an out-of-order move, or an infinite review loop. The "pipeline" is prose in `prompts/conductor.md`, enforced only by the LLM following instructions. +- **The watchdog was in-memory and judged liveness by chat recency.** Health state lived in memory (lost on restart) and "stuck" was inferred purely from how recently an agent posted a chat message. That **false-positives an agent doing long silent work**, can't tell "progressing" from "looping," and has no cycle cap. +- **Only coders rehydrated after a crash.** Coders rebuilt context from git + `TASK.md`; every other agent (Conductor, Mergemaster, Planner, reviewers) reconnected **blank** and re-derived everything from the room. Pipeline position was never checkpointed anywhere durable. + +**We watched all of this bite:** a planning run silently died when a Codex turn timed out (no retry boundary) and a `422` stalled that agent's Band cursor — the chat-recency watchdog never noticed, and nothing surfaced it. The deterministic layer is engineered to remove exactly these failure modes. + +## 2.2 The principle and the trap + +The trap is "make codeband deterministic" — that would kill the flexibility and parallel pools that make it good. The win is **separating two things codeband currently fuses:** + +- *Deciding* what to do — creative, dynamic → **stays in the LLM.** +- *Enforcing and recording* what is allowed to happen — mechanical → **moves into code.** + +> **The LLM decides, code enforces and remembers — the FSM gates EFFECTS (transitions/merges), not the Conductor's creative routing.** + +## 2.3 The key adaptation: a two-level state model + +`band-of-devs` (the Docker-based fork that inspired this) has one global pipeline, one phase at a time. codeband **fans out**: a planner decomposes a task into N subtasks, N coders work concurrently, reviews and merges interleave. So state is modeled at two levels: + +| Level | Owner | Governs | Enforcement | +|-------|-------|---------|-------------| +| **Task** | LLM Conductor (loose) | decomposition, the assignment map, overall progress | **none** — the Conductor routes freely via `@mentions` | +| **Subtask / PR** | code FSM (rigid) | one unit-of-work lifecycle, instantiated N times; global invariants (no merge before approval, no double-merge, round caps) | `VALID_TRANSITIONS` + `BEGIN EXCLUSIVE` + the `cb-phase` gate | + +Each unit of work is its own FSM *instance*, keyed by `(task_id, subtask_id, pr_number)`, with a round counter and an owner. Many run at once. Global invariants are code checks across the live instance set. + +## 2.4 The five workstreams (mechanism level) + +All new machinery lives under `src/codeband/state/`, plus `src/codeband/cli/handoff.py` and an extension to `agents/watchdog.py`. It landed in five additive phases, each a standalone, revertable PR. + +### WS1 — Typed durable state store (`state/store.py`) + +A single local **SQLite** DB at `{workspace}/state/orchestration.db` (stdlib `sqlite3` only). Three tables: + +- `tasks(task_id PK, description, room_id, created_at, status)` — `task_id == room_id`. +- `subtask_states(subtask_id PK, task_id FK, state, assigned_worker, pr_number, created_at, updated_at, metadata, review_round)`. +- `transition_log(id PK, subtask_id, from_state, to_state, caller_role, timestamp, reason)` — append-only audit. + +Each method opens a fresh short-lived connection inside a context-managed transaction with `PRAGMA journal_mode=WAL`, `busy_timeout=30000`, `foreign_keys=ON`. **Short-lived connections + WAL are what make it safe across processes** — both the single-process `run_local` path and the distributed `agent_main` path point at the same file. `TERMINAL_STATES = {merged, abandoned}`; note `blocked` is deliberately *non-terminal* so blocked subtasks still surface to the watchdog and rehydration. + +The task row is written at kickoff (`orchestration/kickoff.py:send_task`, using `room_id` as `task_id`); subtask rows are created lazily by the FSM. **Both writes are fully guarded** with try/except + a "shadow mode" warning — a store failure can never break `cb run`. + +> **Why SQLite, not Band memory** (a deliberate, load-bearing choice): Band memory has a hard ~1000-char content limit, is unavailable on the free tier (returns HTTP 402/403/404/501), is unavailable offline/in Docker, and supports only opaque substring queries. It is unfit to be the authoritative store. SQLite is local, unbounded, queryable, and behaves identically across run modes. Band memory is kept only as an optional async **observability mirror — never on the read path.** + +### WS2 — Per-subtask FSM (`state/fsm.py`) + +The state graph per subtask: + +``` +planned → assigned → in_progress → verify_pending → review_pending + → review_passed → merge_pending → merged + ↘ review_failed → in_progress + ↘ blocked + ↘ abandoned +``` + +`VALID_TRANSITIONS` is a static `dict[(current_state, caller_role) → frozenset(next_states)]`. A move is legal only if both the edge **and the caller's role** match. Examples: + +```python +("planned", "conductor"): {"assigned"}, +("assigned", "coder"): {"in_progress"}, +("in_progress", "coder"): {"verify_pending", "blocked"}, +("verify_pending", "coder"): {"review_pending"}, +("review_pending", "reviewer"): {"review_passed", "review_failed"}, +("review_failed", "coder"): {"in_progress", "blocked"}, +("review_passed", "mergemaster"): {"merge_pending"}, +("merge_pending", "mergemaster"): {"merged"}, +``` + +Two cross-cutting wildcards: `(any non-terminal, conductor) → abandoned` and `(any non-terminal, watchdog) → blocked`. + +`transition(...)` is the only mutation path. It opens its own connection, runs `BEGIN EXCLUSIVE`, **re-reads state inside the exclusive transaction**, validates the edge + role, writes the new state, appends a `transition_log` row, and commits — or rolls back and raises `InvalidTransitionError` (writing nothing) on an illegal move. The exclusive transaction is what makes the global invariants (no double-merge, no out-of-order move) hold under concurrency. + +**Review-round cap** (`MAX_REVIEW_ROUNDS = 3`): entering `review_failed` increments the durable `review_round` in the same transaction. Once `review_round >= 3`, a `review_failed → in_progress` rework is rejected; the only legal escape is `→ blocked`. This is a **distinct** mechanism from the watchdog's stall cap: it bounds a *productive-but-circular* loop (real commits each round, which the watchdog would never flag as stalled). + +### WS3 — Verify-gated handoffs (`cli/handoff.py`, the `cb-phase` CLI) + +This is the **enforcement seam**, and the cleverest piece of the design. In an `@mention`-driven world with two agent frameworks (Claude + Codex), you cannot enforce a gate by hoping the LLM behaves. So you make the *effect* go through a **validated CLI that both frameworks can shell out to**: + +``` +cb-phase verify --task --pr [--worktree ] +``` + +Gate sequence (each failure → stderr + exit 1): + +1. **clean tree** — `git status --porcelain` empty; +2. **PR open** — `gh pr view --json state` == `OPEN`; +3. **optional verify command** — if configured (`agents.handoff_verify_command`), run it; **exit 0 required**; +4. **only then** → `transition(..., "review_pending", caller_role="coder")`. + +The Conductor still routes via `@mentions`; the CLI gates the *consequences*. It imports **no Band SDK and no asyncio** — a fast, pure subprocess identical for Claude and Codex. + +### WS4 — Mechanical watchdog (`agents/watchdog.py`) + +The upgrade is **+284 lines, purely additive** — the entire old chat-recency machinery is retained as the first rungs of the escalation ladder; the mechanical stall→blocked path is the new third rung. + +New `AgentHealthState` fields keyed by `subtask_id`: `patrol_visits_without_progress`, `last_git_head`, `last_transition_timestamp`. Each patrol, for each `in_progress`/`verify_pending` subtask, reads **three deterministic progress signals**: + +- **git HEAD advanced?** — `git rev-parse ` (branch from `subtask.metadata["branch"]`); +- **PR changed?** — `gh pr view --json state,updatedAt` (a change in `updatedAt` captures state changes too); +- **newer transition row?** — `SELECT MAX(timestamp) FROM transition_log WHERE subtask_id=?`. + +If any advanced → progress: reset the counter. Otherwise increment it. When `patrol_visits_without_progress >= max_phase_visits` (default **10**) → escalate: transition the subtask to `blocked` via the FSM (`caller_role="watchdog"`) and post a chat alert to the room ("Conductor please reassign or investigate"). The whole path is wrapped so a store/git failure never breaks the patrol loop. + +> **This is the path that would have caught the silent stall that motivated the whole RFC.** A timed-out turn produces no git-HEAD change and no new transition row, so the stall cap fires deterministically instead of the run dying quietly. Belongs out-of-process for crash isolation — which favors codeband's distributed/Docker mode. + +### WS5 — Universal rehydration (`state/rehydration.py`) + +`build_agent_recovery_context(agent_key, store) → str | None` reads the durable store and returns a **per-role markdown recovery prompt** that's prepended to a reconnecting agent's system prompt: + +- **conductor** → a table of *all* non-terminal subtasks (`| Subtask | State | Worker | PR |`); +- **mergemaster** → subtasks in `merge_pending` / `review_passed`; +- **reviewer** → subtasks in `review_pending`; +- **planner** → the active task description(s); +- **plan_reviewer** → task descriptions + in-flight subtask counts. + +Returns `None` when nothing relevant is in durable state (so behavior is then identical to today). It is called on **every reconnect for all non-coder agents** — the reconnect loop was refactored to build a fresh `make_agent(recovery_context)` each cycle. `recover_for_reconnect(...)` swallows *all* exceptions → `None`, so rehydration can never break the reconnect loop. The existing coder path (rebuild from git + `TASK.md`) is left untouched. + +## 2.5 How we preserve flexibility & autonomy while adding gates + +This is the part to emphasize: **the deterministic layer is surgical, not totalizing.** + +**Gated (mechanical, in code):** advancing to `review_pending` (only via `cb-phase` after clean-tree + open-PR + verify-exit-0); passing/failing review *by the right role*; merge transitions; abandonment (conductor-only); blocking (watchdog-only); the review-round cap. + +**NOT gated (creative, in the LLM):** the Conductor's routing, the decomposition into subtasks, `@mention` dispatch, re-planning, parallel pool sizing. The Conductor keeps full creative latitude over *what* to do and *who* does it. + +The FSM never *silently* ignores a bad move — it raises `InvalidTransitionError` and exits non-zero, so the agent receives an **actionable error** it can recover from. Illegal *consequences* (a merge before approval, a double-merge, an out-of-order or skipped review, an unbounded loop) become impossible; everything else stays as flexible as it is today. Parallel pools, dynamic decomposition, and multi-framework agents all continue to work unchanged. + +## 2.6 Where we deviate from stock codeband and from `band-of-devs` + +### vs. stock codeband + +| Dimension | Stock codeband | This work | +|-----------|----------------|-----------| +| **Pipeline state** | free-text protocol envelopes parsed only for display | typed SQLite FSM that *gates* effects | +| **Authoritative store** | Band memory (1000-char cap, free-tier 402/403, no offline) | local SQLite; Band memory demoted to optional observability mirror | +| **Watchdog** | in-memory, chat-recency, no cycle cap, lost on restart | durable mechanical signals (git HEAD / PR / transition log) + hard stall cap | +| **Rehydration** | coders only (git + `TASK.md`) | **all** agents, from durable store, via `make_agent(recovery_context)` factory | +| **Loop protection** | none | review-round cap (productive loops) + stall cap (silent loops), two distinct mechanisms | + +### vs. `band-of-devs` + +`band-of-devs` keeps a deterministic Python control plane outside the LLMs, but it is **one global pipeline, one phase at a time, Docker-per-agent, linear**. We **adapted, not copied**: its single global phase graph becomes our *per-subtask* graph instantiated N times; its single-pipeline invariants become code checks across the live instance set. codeband stays in-process/Band-coordinated/pool-parallel rather than Docker-per-agent-linear. Reference mapping from the RFC: their `pipeline_phase.py` → our WS1–2; `pipeline_watchdog.py` → WS4; `repo_init.py` → WS5; `request-review` → WS3. + +## 2.7 Status, the dormancy nuance, and the risk that remains + +**Deterministic-orchestration phasing:** P0 baseline → P1 store (shadow) → P2 FSM + gated handoffs → P3 watchdog → P4 rehydration → P5 activation. These phases are landed in this fork, with the prompts now calling `cb-phase` for lifecycle effects and the merge path gated by SHA-pinned verdicts. + +**The crucial nuance now:** the deterministic layer is not a central planner. The Conductor still decomposes, routes, and recovers creatively, but sanctioned effects move through `cb-phase`: Coder handoff to review, Reviewer verdicts, Verifier acceptance, merge eligibility, approval grants, abandon/resume, and blocked escalation. A rejected transition returns an actionable error; agents must recover or escalate, not route around the gate. + +The remaining risk is operational, not dormancy: a real live run must keep the LLM and the gates in sync under retries, rebases, stale verdicts, and owner approvals. The design principle is still "the LLM decides, code enforces"; the practical bar is that every gate refusal teaches the agent what to do next. + +### Roadmap from here + +1. **Targeted pre-E2E sweep** (report-only, diff-scoped): spec-vs-implementation, dead-code/wiring, test-suite/flaky. *A static sweep explicitly cannot catch state/sequencing/race bugs — which is exactly what this feature is — so it complements, not replaces, E2E.* +2. **E2E validation** — a real `cb run`; confirm store writes, FSM logs, watchdog mechanical signals, verifier acceptance, stale-verdict rejection, and a killed agent rehydrating. +3. **Upstream to `thenvoi/codeband`** — the separable-module structure was designed for this; run the full "Master Sweep" audit right before sharing externally. + +### Key files in this repo + +- `docs/rfc-deterministic-orchestration.md` — the design +- `src/codeband/state/store.py` / `fsm.py` / `rehydration.py` +- `src/codeband/cli/handoff.py` (the `cb-phase` entry point in `pyproject.toml`) +- `src/codeband/agents/watchdog.py` +- `src/codeband/config.py` — knobs: `handoff_verify_command`, `max_phase_visits` (10), `git_progress_check`, `max_review_rounds` (6) +- `src/codeband/orchestration/runner.py` (store init, watchdog wiring, rehydration loop), `kickoff.py` (task-row write) +- `src/codeband/prompts/*.md` — role prompts that call `cb-phase` and treat gate output as authoritative +- Tests: `tests/test_state_store.py`, `test_fsm.py`, `test_handoff.py`, `test_watchdog_upgrade.py`, `test_rehydration.py` + +--- + + + +# Part 3 — The onboarding skill & distribution + +> **Goal:** a brand-new person, with **only a Band account and three API keys**, gets to a working `/codeband ` in ~5 minutes — the skill (driving the coding agent) installs and wires up everything else. + +## 3.1 The dependency chain the onboarding has to satisfy + +| Piece | Install | Purpose | +|-------|---------|---------| +| Homebrew (macOS) | prereq | package manager | +| `uv` | `brew install uv` | runs codeband | +| `gh` + auth | `brew install gh` → `gh auth login` | PRs | +| `claude` CLI | already present (they're in CC) | Claude agents | +| `codex` CLI | `brew install codex` | Codex agents | +| `jam` | `brew install ed-lepedus-thenvoi/tap/jam` | CC's Band bridge | +| `codeband` | `uv tool install codeband` (public PyPI) | the swarm | +| 3 keys | `BAND_API_KEY` (`band_u_…`), `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` → home `.env` | auth | +| home config | `cb init` + `.env` + `cb setup-agents` (registers 10 Band agents) | the home | + +## 3.2 What already exists vs. what the skill adds + +**Most of the software install is already done** by `/codeband`'s Step 0 + `~/.claude/codeband/setup.sh` — an idempotent bootstrap that: + +1. gates on macOS + Homebrew (hard fail otherwise); +2. installs only the missing CLIs (each `command -v … ||`-guarded): `uv`, `gh`, `codex`, `jam` (custom tap), `claude-code` cask, and `codeband` via `uv tool install`; +3. checks `gh auth status` (defers to the user if not authed — can't be done non-interactively); +4. validates the three keys (and *warns* if the Band key isn't a `band_u_` **user** key); +5. configures the jam profile (`jam init --user-api-key …`, idempotent via `jam whoami`); +6. creates the home + `cb init` (idempotent via `codeband.yaml`); +7. writes `.env` (0600, always refreshed); +8. registers the 10 Band agents (`cb setup-agents`, idempotent via `agent_config.yaml`); +9. finishes with `cb doctor`. + +The user only supplies the three keys. The global `codeband` tool stays the **PyPI build** (so the skill keeps working regardless of the dev fork). + +**The onboarding skill formalizes the human-facing front of that:** + +- **Account + key acquisition** — guided links: make a Band account at app.band.ai and grab the `band_u_` **user** key (not an agent `band_a_` key — a real gotcha worth calling out); get Anthropic + OpenAI keys. +- **A guided/interactive walkthrough** rather than a bare script — confirm `gh auth`, collect keys conversationally, run the bootstrap, and interpret `cb doctor` (the single ⚠ about API-key-vs-subscription billing is fine). +- **"Your first run"** — kick off a trivial `/codeband` task end-to-end so the user sees the loop work. + +## 3.3 The one real gap: distributing the skill files themselves + +The bootstrap installs all the *software*, but a fresh machine still needs the **skill files** (`commands/codeband.md` + `codeband/setup.sh`) before anyone can type `/codeband`. The clean fix mirrors what `jam` already does — ship it as a **Claude Code plugin via a marketplace**. We drafted exactly this (a `codeband` plugin for the `jam-marketplace` repo). Then the whole onboarding collapses to: + +``` +claude plugin marketplace add ed-lepedus-thenvoi/jam-marketplace +claude plugin install codeband@jam-marketplace # or: jam plugin install +/codeband # first run auto-bootstraps +``` + +That's the 5-minute path: **account + keys → plugin install → first `/codeband` run.** + +## 3.4 Why this matters for the pattern library + +The onboarding skill is the template for *how any of our patterns reaches a new user*: a plugin/marketplace ships the protocol (the skill files), and a first-run idempotent bootstrap installs the runtime and collects only the irreducible human inputs (accounts, keys, auth). Part 4 generalizes this: every pattern in the library should be installable the same way and self-bootstrapping on first use. + +--- + + + +# Part 4 — The pattern library: protocoled patterns as skills + +> **Thesis:** `/codeband` is one concrete instance of a general idea — *a protocol for getting work done, packaged as a skill, that an agent can pick up and run.* The strategic opportunity is to build a **library of such patterns, from very simple to codeband-complex, that work beyond Claude Code and beyond coding.** What we are really shipping is not a coding swarm; it is a reusable way of encoding "how a job gets coordinated" so the encoding is solid, inspectable, and reusable. + +## 4.1 What `/codeband` actually taught us (the transferable primitives) + +Strip away "coding" and "Claude Code," and `/codeband` + the determinism work are made of a small set of primitives that recur in *any* serious multi-step or multi-agent job. These are the building blocks of the library: + +| Primitive | In `/codeband` it is… | Generalizes to… | +|-----------|----------------------|-----------------| +| **Identity & presence** | `jam onboard` → an agent on Band | any agent joining any coordination substrate (Slack, email, a queue, a shared doc) | +| **Synthesized push** | a `Monitor` on the inbox file replacing native `` | wake-on-event for *any* signal the host doesn't deliver natively | +| **Out-of-band truth** | GitHub (`cb pending`) + process log, not chat | never trust the chatter; verify the deliverable against an authoritative source | +| **The decide/enforce split** | Conductor routes; FSM gates effects | LLM owns judgment; code owns invariants — applies to *any* workflow with rules | +| **Durable state + rehydration** | SQLite + per-role recovery prompts | any long-running job that must survive a crash and resume coherently | +| **Mechanical liveness** | git HEAD / PR / transition-log progress signals + stall cap | detecting "stuck" by *real progress*, not by chatter, in any domain | +| **Verify-gated handoff** | `cb-phase verify` (clean tree → PR open → tests pass) | a validated checkpoint any actor must pass before the work advances | +| **Idempotent bootstrap + plugin distribution** | `setup.sh` + marketplace | how every pattern installs and self-provisions on first use | + +A "pattern," in this library, is a chosen subset of these primitives, wired together for a class of job, and shipped as a skill. + +## 4.2 The organizing principle: protocol vs. judgment + +The single most important lesson from the determinism work (Part 2) is the **decide/enforce split**, and it is what makes a pattern *solid* rather than merely *clever*: + +> **The LLM decides; code enforces and remembers.** Encode the *protocol* (the legal moves, the gates, the invariants, the durable state) in code; leave the *judgment* (what to do, how to phrase it, which path to take) to the agent. + +A pattern is "solid and useful" to exactly the degree that it gets this line right: + +- **Too much in code** → you've built a rigid script, lost the agent's adaptability, and gained nothing over a traditional workflow engine. +- **Too much in the LLM** → you've built a hopeful prompt; nothing prevents skipped steps, double-actions, infinite loops, or silent death (this is stock codeband's fragility). +- **The line drawn well** → the agent stays creative and autonomous, while code makes illegal *consequences* impossible and remembers everything across crashes. + +Every pattern in the library should declare, explicitly, **what it gates and what it leaves free.** That declaration is the spec. + +## 4.3 A complexity ladder (simple → codeband-complex) + +The library should span a deliberate range. Each rung adds primitives from §4.1. + +**Rung 0 — Solo protocol (no other agents).** +A single agent following a checkpointed protocol with verify-gates and durable state. No coordination substrate. Example: a *release-cut* skill that walks version-bump → changelog → tag → publish, where each step is a verify-gate (the next step refuses until the prior one's artifact exists) and state is durable so a crash resumes mid-release. *Primitives: decide/enforce split, verify-gated handoff, durable state + rehydration.* + +**Rung 1 — Solo with out-of-band truth + liveness.** +Add a watcher that wakes the agent on external events and a mechanical "am I actually making progress?" signal. Example: a *deploy-and-watch* skill that triggers a deploy, then watches the real health endpoint / CI status (not its own assumptions) and escalates on a stall. *Adds: synthesized push, out-of-band truth, mechanical liveness.* + +**Rung 2 — Two agents, one coordinator.** +One agent (possibly a human-facing Claude session) coordinates one or more workers over a substrate, with synthesized push and out-of-band verification. This is `/codeband` *minus* the deterministic control plane — the pure coordination pattern. Example: a *research-desk* where a coordinator dispatches sub-questions to worker agents and verifies each answer against cited sources before accepting. *Adds: identity & presence, coordinator role.* + +**Rung 3 — Fan-out swarm with a deterministic control plane (codeband).** +N workers, interleaved phases, a per-unit FSM gating effects, durable state, universal rehydration, mechanical watchdog. This is the full codeband shape. The expensive rung — justified only when the work is genuinely parallel, long-running, and correctness-critical. + +The value of the ladder: **most jobs don't need rung 3.** A pattern library lets us pick the *cheapest rung that satisfies the job*, and reuse the same primitives at every level. + +## 4.4 Beyond coding, beyond Claude Code + +Nothing in §4.1 is coding-specific or Claude-Code-specific. The same primitives apply to general knowledge work; only the *authoritative sources* and *substrate* change. + +**Non-coding examples (same patterns, different nouns):** + +- **Content/marketing pipeline** — draft → editorial review → legal/brand gate → publish. The FSM gates "published" behind "approved by the right role"; the verify-gate is "brand checklist passes"; out-of-band truth is the CMS, not the chat. (Rung 3 shape, zero code.) +- **Sales/CRM ops** — enrich → qualify → route → follow-up, with the durable store preventing double-contact and the watchdog catching leads that have stalled in a stage. (Clay/HubSpot/Linear tooling already available — the substrate is the CRM.) +- **Research / due diligence** — fan-out questions to workers, adversarially verify each claim against sources, synthesize. (This is the `deep-research` skill shape — a rung-2 pattern already in the toolbox.) +- **Incident response** — a coordinator agent that watches monitoring (out-of-band truth), drives a runbook FSM (gates: don't escalate before triage, don't close before postmortem), and rehydrates if the responder session dies mid-incident. +- **Operations / scheduling** — recurring jobs where the durable state and verify-gates prevent the same action firing twice and the liveness signal catches a hung step. + +**Beyond Claude Code as the host:** the coordinator need not be a Claude Code session. The *protocol* (the FSM, the gates, the store) is host-agnostic code; the *agent* can be any LLM runtime, and the *substrate* can be Band, Slack, email, a message queue, or a shared database. `/codeband`'s `jam`/Band specifics are an implementation detail of one rung-3 instance, not part of the pattern. + +## 4.5 What makes a pattern solid (the contract every library entry should meet) + +From everything we learned building `/codeband` and hardening codeband, a reusable pattern should satisfy: + +1. **An explicit decide/enforce boundary** — a one-paragraph statement of what is gated (code) vs. free (agent). If you can't write it, the pattern isn't designed yet. +2. **Durable, queryable state** — not chat history, not a 1000-char memory blob. Local, unbounded, identical across run modes (the SQLite lesson). +3. **Out-of-band verification** — at least one authoritative source other than the agents' own chatter, for the parts that matter. +4. **Mechanical liveness** — "stuck" detected by real progress signals + a hard cycle cap, never by recency-of-talk. +5. **Crash-safe resume** — any actor can die and rehydrate from durable state into a coherent position. +6. **Actionable failure** — a rejected/gated action returns an error the agent can recover from (and escalate to a human), never a silent drop or a loop. *This is the P5 risk from Part 2, generalized: gates must teach, not just block.* +7. **Idempotent first-run bootstrap + plugin distribution** — installs itself and collects only irreducible human inputs (Part 3). +8. **Graceful degradation** — every new mechanism guarded so its failure falls back to the prior behavior (the "shadow mode" discipline from codeband's P1). + +A pattern that meets all eight is *solid*; one that meets the first two is at least *honest about its boundaries*. + +## 4.6 Suggested next steps to turn this into a real library + +1. **Extract the primitives into a shared core** — the decide/enforce FSM scaffold, the durable store, the rehydration factory, the mechanical watchdog, the verify-gate CLI — as a host-agnostic toolkit independent of codeband and of coding. +2. **Author the rung-0 and rung-1 reference patterns first** (release-cut, deploy-and-watch). They're cheap, they exercise the core, and they prove the primitives generalize off coding. +3. **Re-express `/codeband` on top of the shared core** — proving rung-3 is "the core + a coordination substrate + a domain," not a bespoke build. +4. **Pick one non-coding rung-2/3 pattern** (research-desk or a CRM-ops flow) to prove the substrate and domain are swappable. +5. **Ship them all the same way** — plugin/marketplace + idempotent bootstrap (Part 3), with each pattern's decide/enforce contract (§4.5) written at the top of its skill file. + +The endgame: a catalogue where someone picks a rung and a domain, installs a skill, brings their keys, and gets a *solid* protocol-driven agent — coding or otherwise — without re-litigating identity, push, state, liveness, gating, and recovery each time. We've already paid that tuition once, on `/codeband`. diff --git a/docs/rfc-deterministic-orchestration.md b/docs/rfc-deterministic-orchestration.md new file mode 100644 index 0000000..54d5389 --- /dev/null +++ b/docs/rfc-deterministic-orchestration.md @@ -0,0 +1,232 @@ +# RFC: Deterministic Orchestration for Codeband + +**Status:** Draft +**Author:** Yoni Bagelman (with Claude Code) +**Scope:** Add a deterministic, code-enforced control plane (state store, FSM, gated handoffs, mechanical watchdog, universal rehydration) to codeband — *without* losing the LLM-Conductor flexibility, parallel agent pools, or low ops friction that make it useful. + +> **Guiding principle:** *The LLM decides, code enforces and remembers — the FSM gates EFFECTS (transitions/merges), not the Conductor's creative routing.* + +--- + +## 1. Motivation + +Codeband today is **overwhelmingly LLM-driven orchestration with a thin deterministic scaffold**. There is no finite-state machine in code that governs the pipeline; "state" is free-text protocol envelopes that the Conductor LLM writes to memory and re-reads, and routing is the Conductor reacting to `@mentions`. This buys real strengths — flexibility on novel tasks, dynamic re-planning, and parallel pools of coders/reviewers — but it has no guarantees and weak crash semantics. + +Concrete weaknesses in the current code: + +- **Brittle, schemaless state.** Protocol envelopes are parsed by `src/codeband/orchestration/kickoff.py:_parse_envelope()` / `_format_task_status()` purely for `cb status` display. Nothing reads them to *drive* or *gate* a transition. There is no durable, queryable record of where each unit of work is. +- **In-memory, chat-recency watchdog.** `src/codeband/agents/watchdog.py` holds `AgentHealthState` in memory (lost on restart) and judges liveness by *chat-message recency only* — so it false-positives an agent doing long silent work, and cannot tell "genuinely progressing" from "stuck in a loop." It can nudge/escalate but has no mechanical progress signal and no cycle cap. +- **Coder-only rehydration.** Only coders rebuild context after a crash (`src/codeband/session/supervisor.py:WorkerSupervisor` + `src/codeband/session/context.py:build_recovery_context()`). Conductor, Mergemaster, Planner, Plan-Reviewer, and Code-Reviewer reconnect *blank* and re-derive everything from the room. +- **No transition enforcement.** Phase transitions are purely LLM-prompted. Nothing prevents a skipped review, a double-merge, an out-of-order move, or an infinite review loop — only prose admonitions in `prompts/conductor.md`. + +This RFC was itself motivated by a live failure that is exactly on-point: a planning run stalled when a Codex review turn timed out (180s, no retry/escalation boundary) and a subsequent `422` left the agent's Band cursor stalled — a silent death the chat-recency watchdog never flagged. **The instabilities this RFC fixes are the ones that killed the run to write it.** + +The reference implementation for the deterministic patterns is the author's `band-of-devs` fork (Docker-per-agent, shared volumes, CLI-gated transitions). The goal here is to **adapt**, not copy: band-of-devs is a single linear pipeline; codeband is a parallel swarm with a central LLM Conductor. + +## 2. Design overview + +### 2.1 The split: decide vs. enforce-and-remember + +Codeband currently *fuses* deciding (creative, dynamic) with effecting (state changes). band-of-devs fuses them on the code side. The win is **separating them**: + +- The **Conductor LLM keeps deciding** — decomposition, assignment to pool workers, re-planning, conflict handling. This is where flexibility lives; a rigid FSM here would make codeband worse. +- A **code control plane enforces and records every state-changing effect** — "this subtask advanced to review", "this PR merged" — validating against an FSM, persisting durably, and rejecting illegal moves. + +The LLM can decide anything; it just cannot *make an illegal transition happen* or *lose track of state*. + +### 2.2 Two-level model (the key adaptation for codeband's fan-out) + +band-of-devs has one global `pipeline.yaml`, one phase at a time. Codeband fans out: planner → N subtasks → N coders → reviews → merges, concurrently. So state is modeled at **two levels**: + +| Level | Owner | Governs | +|---|---|---| +| **Task** | LLM Conductor (loose) | decomposition, the assignment map, overall progress | +| **Subtask / PR** | code FSM (rigid) | the lifecycle of one unit of work, instantiated N times, with global invariants (no merge before approval, no double-merge, round caps) | + +Your phase graph becomes the *per-subtask* graph; global invariants become code checks across the live instance set. + +### 2.3 The enforcement seam: a validated CLI + +In an `@mention`-driven, two-framework (Claude + Codex) world you cannot enforce a gate by hoping the LLM behaves. The portable mechanism — proven in band-of-devs (`band-phase` / `request-review`) — is a **validated CLI** the agent prompts must call, with all validation in the CLI. It works identically for Claude and Codex (both run shell commands), and the effect only happens if the CLI allows it, regardless of what the Conductor *intended*. The Conductor keeps routing via `@mentions`; the CLI gates the consequences. + +--- + +## 3. Workstreams + +All new state machinery lives under a new `src/codeband/state/` package — separable, independently testable, no circular imports — so the changes stay rebase-friendly and PR-able upstream. + +### Workstream 1 — Typed durable state store + +**New modules:** `src/codeband/state/__init__.py`, `src/codeband/state/store.py`. + +A local **SQLite** DB at `{workspace_path}/state/orchestration.db`. Three tables: + +```sql +CREATE TABLE tasks ( + task_id TEXT PRIMARY KEY, -- room_id as natural key + description TEXT NOT NULL, + room_id TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' +); + +CREATE TABLE subtask_states ( + subtask_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(task_id), + state TEXT NOT NULL DEFAULT 'planned', + assigned_worker TEXT, + pr_number INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata TEXT -- JSON blob +); + +CREATE TABLE transition_log ( -- append-only audit + id INTEGER PRIMARY KEY AUTOINCREMENT, + subtask_id TEXT NOT NULL, + from_state TEXT NOT NULL, + to_state TEXT NOT NULL, + caller_role TEXT NOT NULL, + timestamp TEXT NOT NULL, + reason TEXT +); +``` + +`StateStore` API: `create_task()`, `ensure_subtask(subtask_id, task_id)` (`INSERT OR IGNORE`), `get_subtask()`, `list_active_subtasks()`. + +**Row-creation timing:** +- **Task row** is created in `src/codeband/orchestration/kickoff.py:send_task()` using `room_id` as `task_id` — that is all that exists at kickoff; no subtask info is available yet. +- **Subtask rows** are created lazily on the first `fsm.transition()` for that subtask (`store.ensure_subtask(...)` before writing) — no Conductor action required. + +**Why SQLite over Band-memory.** Band-memory has a hard ~1000-char content limit and is unavailable on the free tier (HTTP 402/403/404/501, probed once via `src/codeband/memory/probe.py:probe_memory_backend()`) and in offline/Docker runs. SQLite is local, persistent, queryable, and behaves identically in local (`runner.py:run_local()`) and distributed (`orchestration/agent_main.py`) modes. Band-memory is retained only as an *optional async secondary write for observability* — never on the read path. + +**Integration points:** `orchestration/runner.py:_install_memory_backend()` also inits the `StateStore`; `kickoff.py:send_task()` writes the task row after room creation. + +### Workstream 2 — Per-subtask FSM + +**New module:** `src/codeband/state/fsm.py`. + +States: + +``` +planned → assigned → in_progress → verify_pending → review_pending → review_passed → merge_pending → merged + ↘ review_failed → in_progress + ↘ blocked + ↘ abandoned +``` + +`VALID_TRANSITIONS` is a static dict keyed by `(current_state, caller_role)`: + +| From (state, role) | Allowed next | +|---|---| +| `(planned, conductor)` | `assigned` | +| `(assigned, coder)` | `in_progress` | +| `(in_progress, coder)` | `verify_pending`, `blocked` | +| `(verify_pending, coder)` | `review_pending` *(only after the `cb-phase` gate passes)* | +| `(review_pending, reviewer)` | `review_passed`, `review_failed` | +| `(review_failed, coder)` | `in_progress` | +| `(review_passed, mergemaster)` | `merge_pending` | +| `(merge_pending, mergemaster)` | `merged` | +| `(any non-terminal, conductor)` | `abandoned` | +| `(any non-terminal, watchdog)` | `blocked` | + +The last two are cross-cutting wildcards (enforced in `_is_allowed`, not enumerated per state): the Conductor may abandon, and the Watchdog (WS4) may block, any non-terminal subtask. + +```python +def transition(subtask_id: str, task_id: str, new_state: str, + caller_role: str, reason: str = "") -> None: + """Auto-creates the subtask (ensure_subtask). Under BEGIN EXCLUSIVE: + validates (current_state, caller_role) ∈ VALID_TRANSITIONS, writes the + new state, appends transition_log. Raises InvalidTransitionError otherwise.""" +``` + +Task-level routing is **not** code-enforced (Conductor owns it); only subtask-level *effects* are. + +### Workstream 3 — Verify-gated handoffs (`cb-phase`) + +The enforcement seam. Two steps because `src/codeband/cli.py` is a single ~1400-line flat module: + +- **3a — refactor** `cli.py` → a `cli/` package: `src/codeband/cli/__init__.py` (mechanical move, no logic change; the `cb = "codeband.cli:cli"` entry point stays valid), then add `src/codeband/cli/handoff.py`. +- **3b — implement** the `cb-phase` entry point (register `cb-phase = "codeband.cli.handoff:main"` in `pyproject.toml`). + +Gate sequence for `cb-phase verify --task --pr [--worktree ]`: + +1. `git -C status --porcelain` must be empty (clean tree). +2. `gh pr view --json state` must be `OPEN`. +3. If `agents.handoff_verify_command` is configured, run it; exit 0 required. +4. On success, `fsm.transition(subtask_id, task_id, "review_pending", caller_role="coder")`. + +Config: add `handoff_verify_command: str | None = None` to `src/codeband/config.py:AgentsConfig`. The CLI imports no Band SDK and no asyncio — it is a pure subprocess callable by both Claude and Codex agents. + +### Workstream 4 — Watchdog upgrade + +Extend `src/codeband/agents/watchdog.py` with **mechanical progress signals** alongside the existing chat-recency: + +- **Git HEAD per subtask:** `git rev-parse ` for subtasks in `in_progress`/`verify_pending` (branches queried from SQLite). +- **PR state:** `gh pr view --json state,updatedAt`. +- **Transition-log recency:** `SELECT MAX(timestamp) FROM transition_log WHERE subtask_id=?`. + +New `AgentHealthState` fields: `patrol_visits_without_progress: int = 0`, `last_git_head: str = ""`, `last_transition_timestamp: datetime | None = None`. + +**Escalation ladder:** +1. Chat staleness → nudge the agent (existing `_send_nudge()`). +2. Nudge grace elapsed → escalate to Conductor (existing `_send_escalation()`). +3. `patrol_visits_without_progress >= max_phase_visits` (no git-HEAD change *and* no new transition-log entry across N patrols) → `fsm.transition(subtask_id, task_id, "blocked", "watchdog")`, notify Conductor + human (new deterministic path). + +Config: add `max_phase_visits: int = 10` and `git_progress_check: bool = True` to `WatchdogConfig`. + +> This step is what would have caught the stall that motivated this RFC: a timed-out turn produces no git-HEAD change and no transition-log entry, so the cycle/stall cap fires instead of the run dying silently. + +### Workstream 5 — Universal rehydration + +**New module:** `src/codeband/state/rehydration.py`. + +```python +async def build_agent_recovery_context(agent_key: str, store: StateStore) -> str | None: + """agent_key e.g. 'conductor', 'mergemaster', 'reviewer-codex-0'. + Reads SQLite for non-terminal subtasks relevant to this role and returns + a markdown recovery prompt (or None).""" +``` + +Per-role content: **conductor** → table of all non-terminal subtasks (id, state, worker, pr); **mergemaster** → subtasks in `merge_pending`/`review_passed`; **reviewer-*** → subtasks in `review_pending`; **planner-*** → active task description; **plan-reviewer-*** → task description + subtask count. + +**Plumbing (explicit, so the change is mechanical and reviewable):** add `recovery_context: str | None = None` to the five non-coder factories in `orchestration/runner.py` (`_create_conductor`, `_create_planner`, `_create_plan_reviewer`, `_create_code_reviewer`, `_create_mergemaster`) and to the corresponding runner `__init__`s in `agents/{conductor,planner,plan_reviewer,code_reviewer,mergemaster}.py`. Each prepends the recovery context into the system prompt — the same convention `_create_coder()` already uses. `runner.py:_run_agent_forever()` calls `build_agent_recovery_context()` on each reconnect; `orchestration/agent_main.py:main()` calls it before `agent.run()` in distributed mode. The existing coder path (`session/supervisor.py` + `session/context.py`) is unchanged. + +Distributed/Docker mode is favored for crash isolation (one agent per process), so a single crash no longer takes down all agents as it can in the default single-process `cb run`. + +--- + +## 4. Phasing + +Each phase is independently valuable and a standalone PR. + +| Phase | Content | Depends on | PR story | +|---|---|---|---| +| **0 — Baseline** | `pip install -e ".[dev]" && pytest` green on `main`. No new code. | — | trivial (this RFC is docs-only) | +| **1 — State store (shadow)** | `state/__init__.py`, `state/store.py`, `tests/test_state_store.py`. Hooks: `runner.py:_install_memory_backend()` inits the store; `kickoff.py:send_task()` writes the task row. **Record-only — zero behavior change.** | — | merge first | +| **2 — FSM + gated handoffs** | `state/fsm.py`, `cli/__init__.py` (move), `cli/handoff.py`, tests. `pyproject.toml` adds `cb-phase`; `config.py` adds `handoff_verify_command`. | Phase 1 | rebase onto Phase 1 | +| **3 — Watchdog upgrade** | extend `agents/watchdog.py` (mechanical signals + `max_phase_visits` path), `config.py` knobs, `tests/test_watchdog_upgrade.py`. | Phase 1 | parallel to Phase 2 | +| **4 — Universal rehydration** | `state/rehydration.py`, factory/constructor `recovery_context` plumbing, `agent_main.py` hook, tests. | Phase 1 | parallel to Phase 2/3 | + +**Acceptance highlights.** Phase 1: all existing tests pass + store tests pass + no observable behavior change in `cb run`. Phase 2: `cb-phase verify` exits 0 only on clean tree + open PR + passing verify; FSM rejects invalid transitions and wrong caller-role; `cb` import path unchanged. Phase 3: watchdog marks a subtask `blocked` after `max_phase_visits` patrols with no git-HEAD change; existing nudge/escalate behavior intact. Phase 4: a simulated Conductor crash+restart receives the in-progress subtask table in its prompt; coder supervisor path unchanged. + +--- + +## 5. Risks + +**Risk 1 — Code FSM vs. LLM Conductor conflict.** The FSM gates *effects*, not *routing*. If the Conductor tells a coder to jump to `review_pending` without the `cb-phase` gate, the CLI raises `InvalidTransitionError` and exits non-zero — the agent sees an actionable error and retries the correct sequence or escalates to a human. The FSM never silently ignores a bad transition. The prompts must include a "transition rejected → here's what to do" path so the Conductor recovers gracefully rather than looping. + +**Risk 2 — Band-memory is too weak to be the store.** ~1000-char content limit, free-tier unavailability, no offline/Docker support, opaque substring-only query. SQLite is local, unbounded, queryable, and identical across all run modes. Band-memory stays as an optional async observability mirror, never on the read path. + +**Risk 3 — Fork maintenance cost.** All new machinery lives under `src/codeband/state/` as separable, independently-testable submodules with no circular imports; the `cli.py → cli/` refactor is mechanical. Each phase is a standalone, cherry-pickable, revertable PR — which is also the upstream-contribution path: these land on `thenvoi/codeband` one phase at a time. + +--- + +## 6. Reference patterns (band-of-devs) + +Adapted, not copied verbatim, to codeband's in-process / Band-coordinated / pool-parallel architecture: + +- `docker/shared/pipeline_phase.py` — static `VALID_TRANSITIONS`, atomic validated transitions, caller-role enforcement, transition log → **Workstreams 1–2**. +- `docker/shared/pipeline_watchdog.py` — per-phase thresholds, `MAX_PHASE_VISITS` cycle detection, git-HEAD/handoff progress signals, HITL pause, escalation ladder → **Workstream 4**. +- `docker/shared/repo_init.py` — idempotent, file-locked rehydration → **Workstream 5**. +- `docker/claude/bin/request-review` — clean-tree + verify-exit-0 gated handoff → **Workstream 3**. diff --git a/proposed/integrated/knowledge/coding-standards.md b/proposed/integrated/knowledge/coding-standards.md new file mode 100644 index 0000000..81986eb --- /dev/null +++ b/proposed/integrated/knowledge/coding-standards.md @@ -0,0 +1,122 @@ +# Coding Standards + +These are the craft standards for code you write in any Codeband swarm. They are +deliberately **language-agnostic** — Codeband runs against arbitrary repositories. +When this guide and the target repo disagree, **the target repo wins**: match the +conventions, idioms, and tooling that already exist in the code you are editing. +This guide governs only where the repo is silent. + +## The one rule that overrides everything + +**Read the surrounding code before you write any.** The single most common failure +of an autonomous coder is writing code that is locally correct but foreign to the +codebase — a different error-handling style, a different naming scheme, a hand-rolled +helper that already exists three files over. Before adding code: + +- Read at least one neighbouring file in the same module or package. +- Find how this codebase already does the thing you are about to do (logging, config, + HTTP calls, DB access, error types) and do it that way. +- Prefer extending an existing abstraction over introducing a new one. + +## Code quality principles + +- **Smallest change that fully solves the task.** Do not refactor unrelated code, + rename things you weren't asked to rename, or "tidy while you're in there." Scope + creep is the enemy of a clean review and a clean merge. +- **Clarity over cleverness.** Code is read far more than it is written. A longer, + obvious implementation beats a terse, subtle one. +- **No dead code.** Don't leave commented-out blocks, unused variables, unreachable + branches, or speculative "might need this later" helpers. Delete it; git remembers. +- **No debugging leftovers.** No stray prints, console logs, `TODO: remove`, or + temporary instrumentation in the code you submit. +- **Single responsibility.** A function should do one thing. If you can't describe it + without "and", consider splitting it. + +## Naming and structure + +- Names describe intent, not implementation. `retry_count`, not `n`. `is_eligible`, + not `flag`. +- Match the casing/convention of the file you're in (snake_case, camelCase, etc.) — + do not introduce a second style. +- Keep functions short enough to see whole. Deep nesting is a smell; prefer early + returns / guard clauses over arrowhead `if` pyramids. +- Put new code where a reader would look for it. Don't create a new top-level module + for something that belongs in an existing one. + +## Logging, not stdout + +Use the codebase's logging facility, never raw stdout/stderr writes, for diagnostic +output that ships. + +- **Don't** emit `print(...)` / `console.log(...)` / `fmt.Println(...)` as a logging + mechanism in production code paths. +- **Do** use the project's logger at an appropriate level (`debug` for developer + detail, `info` for normal lifecycle, `warning`/`error` for problems). +- Never log secrets, credentials, tokens, full request bodies with PII, or anything + you wouldn't want in a shared log aggregator. See `security.md`. + +## Error handling + +Handle errors at boundaries; don't swallow them. + +- **Fail loudly at system boundaries** — network calls, file I/O, parsing untrusted + input, subprocess calls. These *will* fail in production; handle the failure + explicitly with a clear message and the original cause attached. +- **Don't catch-all-and-ignore.** A bare `except: pass` (or `catch {}`) hides the bug + you'll be paged for. If you must catch broadly, log the cause and re-raise or return + a typed error. +- **Don't catch what you can't handle.** Catching an exception only to re-raise an + identical one adds noise. Either add context or let it propagate. +- **Preserve the cause.** When wrapping an error, chain the original (`raise X from e`, + `fmt.Errorf("...: %w", err)`, `throw new X({cause: e})`) so the stack trace survives. +- **Error messages are for the person debugging at 3am.** Include what was being + attempted and the relevant identifiers, not just "operation failed". + +## Common traps to avoid + +- **Mutable default arguments / shared mutable state** captured across calls. +- **Off-by-one and boundary errors** in ranges, slices, and pagination. +- **Silent type coercion** — comparing across types, truthiness of `0`/`""`/`None`. +- **Resource leaks** — files, sockets, DB connections, locks not released on the error + path. Use the language's scoped-cleanup construct (`with`, `defer`, `try/finally`, + RAII). +- **Time and timezones** — naive timestamps, assuming local time, DST. +- **Floating point for money** — use integers/decimals for currency. +- **Concurrency** — shared state without synchronization, assuming ordering, races on + check-then-act. See `testing.md` for what to test here. +- **Trusting external input** — anything from the network, a file, an env var, or a + user is untrusted until validated. See `security.md`. + +## What good code looks like + +- A new reader can follow it without you explaining it. +- It fits the file it lives in — same patterns, same error style, same naming. +- The happy path is obvious; the error paths are explicit, not implied. +- It has tests that would fail if the behaviour regressed (see `testing.md`). +- The diff is minimal: every changed line is there for a reason tied to the task. + +## Production hardening + +For anything that runs in production, before you call it done: + +- **Inputs validated** at the boundary, with clear rejection of bad input. +- **External calls** have timeouts and a defined behaviour on failure (retry with + backoff, fail fast, or degrade) — never an unbounded hang. +- **Idempotency** considered for anything that can be retried (webhooks, queue + consumers, payment-adjacent flows). +- **Observability** — the code emits enough logging/metrics that an operator can tell + what happened when it misbehaves. +- **No new secrets in code or config committed to the repo.** + +## Self-review checklist (run before you open the PR) + +Before reporting completion, read your own diff top to bottom and confirm: + +- [ ] Every changed line is necessary for the task — no scope creep, no drive-by edits. +- [ ] It matches the surrounding code's style, naming, and error handling. +- [ ] No debug prints, commented-out code, or dead branches. +- [ ] Errors at every system boundary are handled explicitly. +- [ ] No secret, token, or credential is hardcoded or logged. +- [ ] There are tests that would fail if this behaviour broke (see `testing.md`). +- [ ] You ran the verification command from the plan and it passed. +- [ ] You could explain every line if a reviewer asked. diff --git a/proposed/integrated/knowledge/security.md b/proposed/integrated/knowledge/security.md new file mode 100644 index 0000000..786ea59 --- /dev/null +++ b/proposed/integrated/knowledge/security.md @@ -0,0 +1,75 @@ +# Security Practices + +Baseline security expectations for any code a Codeband agent writes or reviews. +Language-agnostic — apply the principle using your stack's safe primitives. The Code +Reviewer treats violations here as blocking, but only when there is a concrete, +demonstrable path to exploitation — not a theoretical one. + +## Trust nothing from outside the process + +Anything that crosses a boundary into your code is untrusted until validated: network +requests, user input, file contents, environment variables, message-queue payloads, +and responses from external services. Validate shape and bounds at the boundary; +reject what doesn't conform rather than coercing it. + +## Injection: never build a command/query by string concatenation + +- **SQL:** use parameterized queries / prepared statements / the ORM's safe binding. + Never interpolate user input into a query string. +- **Shell/OS commands:** avoid shelling out with interpolated input. If you must, + pass arguments as a list to the exec API (no shell), never a concatenated string + through a shell. +- **NoSQL / LDAP / template engines:** the same rule — use the parameterized/escaped + API, not string building. +- **HTML/JS output:** escape/encode on output to prevent XSS; use the framework's + auto-escaping rather than hand-built markup with raw input. + +## No hardcoded secrets + +- No API keys, passwords, tokens, private keys, or connection strings in source code, + tests, fixtures, or committed config. +- Read secrets from environment variables or the project's secret manager. +- Don't log secrets, tokens, full auth headers, or PII. Redact before logging. +- If you encounter an existing hardcoded secret, flag it — don't copy the pattern. + +## Authentication and authorization + +- **Authentication** (who you are) and **authorization** (what you may do) are + separate checks — doing one doesn't give you the other. +- Check authorization on **every** protected operation, server-side, on the actual + resource being accessed — not just at the UI or route layer. Don't trust an ID from + the client to be one the caller is allowed to touch (broken object-level + authorization is the most common real-world hole). +- Fail closed: if you can't determine permission, deny. + +## Path and request safety + +- **Path traversal:** never build filesystem paths from untrusted input without + normalizing and confining to an allowed base directory. Reject `..` segments. +- **SSRF:** don't fetch URLs supplied by users without validating the destination + against an allowlist; internal metadata endpoints and private ranges are the + classic targets. +- **Open redirects:** validate redirect targets against an allowlist. + +## Sensitive data handling + +- Transmit secrets and PII over TLS only. +- Hash passwords with a slow, salted algorithm (bcrypt/scrypt/argon2) — never plain, + never fast unsalted hashes. +- Store only what you need; minimize the blast radius of a breach. +- Be careful what ends up in error messages returned to clients — don't leak stack + traces, internal paths, or query details to the outside. + +## Dependency security + +- Prefer well-maintained, widely-used libraries over obscure ones for security- + sensitive work (crypto, auth, parsing). +- Don't roll your own crypto. Use vetted library primitives. +- When adding a dependency, prefer the official/most-common package and pin it the way + the repo pins its others. + +## Reviewer note: demonstrate the exploit + +When flagging a security finding, show the concrete path: which untrusted input +reaches which sink, and what an attacker achieves. "This could theoretically be +unsafe" without a demonstrable path is a suggestion, not a blocker. diff --git a/proposed/integrated/knowledge/testing.md b/proposed/integrated/knowledge/testing.md new file mode 100644 index 0000000..94265f9 --- /dev/null +++ b/proposed/integrated/knowledge/testing.md @@ -0,0 +1,129 @@ +# Testing Guide + +Tests are how the swarm proves a change works without a human checking by hand. The +cross-model reviewer reads your tests as evidence — weak tests are treated as no tests. +This guide is **language-agnostic**; use your stack's test runner and idioms, but the +standard for *what makes a test worth writing* is the same everywhere. + +## The standard: a test must be able to fail + +A test that cannot fail proves nothing. Before you keep a test, ask: *"If the behaviour +I care about were broken, would this test go red?"* If not, it is decoration. The most +common worthless test asserts something the code can't violate (e.g. that a constructor +returns a non-null object) — delete or strengthen it. + +## Test structure + +- **One behaviour per test.** A test that asserts five unrelated things tells you little + when it fails. Split them. +- **Arrange / act / assert.** Set up the world, perform the one action, assert the one + outcome. Keep setup obvious; a reader should see what's being tested in seconds. +- **Descriptive names.** `test_login_rejects_expired_token`, not `test_login_2`. +- **Deterministic.** No reliance on wall-clock, network, random seeds, or test ordering. + Inject clocks/randomness; stub the network. A flaky test is worse than no test. + +## What to test + +### 1. Happy path + +Prove the feature does what the plan's acceptance criteria say, end to end, with +realistic inputs. This is necessary but **not sufficient** — a passing happy path is +where testing starts, not ends. + +### 2. Then try to break it + +After the happy path passes, deliberately attack your own code. This is the mindset that +separates real tests from rubber stamps. For the change you made, ask: + +### 3. Input validation + +- Empty input, missing fields, `null`/`None`, wrong type. +- Boundary values: 0, 1, -1, max, max+1, empty collection, single-element collection. +- Oversized input (huge strings/lists) where it matters. +- Malformed/garbage input from any untrusted boundary. + +### 4. Edge cases — probe these systematically + +- Off-by-one at the start and end of ranges, slices, and pages. +- Duplicate entries, already-exists, not-found. +- Unicode, whitespace-only, and very long strings in text fields. +- The empty case for every collection the code iterates. + +### 5. Concurrency and races (if the code touches shared state) + +- Two operations interleaving on the same resource. +- Check-then-act gaps (the value changed between the check and the use). +- Idempotency: does running the same operation twice corrupt state? + +### 6. Resource lifecycle + +- Files/connections/locks are released even on the error path. +- Cleanup runs when the operation fails partway. + +### 7. Error paths + +- The dependency raises/returns an error — does your code handle it, and does the test + assert the *handling* (right error surfaced, right cleanup, right log), not just that + it threw? +- Timeouts and unavailable external services. + +### 8. State transitions + +- Invalid transitions are rejected. +- The system ends in the state you claim after a sequence of operations. + +### 9. End-to-end integration (at least one) + +At least one test should exercise the new behaviour through the real seams it ships +with — not every collaborator mocked into meaninglessness. A suite of fully-mocked unit +tests can pass while the wired-together system is broken. + +## Test quality standards + +- **Assert on behaviour, not implementation.** Test the observable outcome, not internal + call counts, so a refactor that preserves behaviour doesn't break the test. +- **No vacuous assertions.** `assert result is not None` after a call that can't return + `None` tests nothing. Assert the actual value/shape/effect. +- **Don't assert on log strings or error message wording** unless that wording is the + contract — those are brittle. +- **Mock at boundaries, not internals.** Stub the network/clock/filesystem; don't mock + the function under test's own helpers, or you test the mock. +- **A test's failure message should point at the cause.** Prefer specific assertions + over one giant equality check on a blob. + +## What NOT to test + +- Third-party libraries and the language standard library — assume they work. +- Trivial getters/setters and pure pass-throughs with no logic. +- Generated code. +- Exact wording of human-facing copy (unless it's a contract). + +Don't pad coverage with tests that can't fail. Coverage percentage is not the goal; +*the ability to catch a regression* is the goal. + +## When a test reveals an implementation bug + +If writing the test surfaces a real bug in the code, fix the code — that's the test +doing its job. But if the bug is outside the scope of your task (pre-existing, in code +you weren't asked to touch), don't silently widen scope: note it and raise it rather +than quietly patching unrelated production code. See `coding-standards.md` on scope. + +## Regression coverage + +When you fix a bug, add a test that fails before your fix and passes after. That test is +the proof the bug is gone and the guard that keeps it gone. + +## Reporting test results + +When you report completion, state exactly what you ran and the outcome — the verbatim +command and the pass/fail counts (e.g. `pytest tests/test_auth.py -v — 7 passed`). If a +test fails for a reason outside your change (pre-existing on the base branch, an +environmental flake you have evidence for), say so explicitly with the evidence; never +report a green status you didn't actually observe. + +## Hanging tests + +If a test hangs, it usually means a missing timeout, an unawaited async operation, an +open resource, or a real deadlock in the code — investigate the cause rather than just +raising the test timeout. A test that only passes with a 5-minute timeout is hiding a +bug. diff --git a/proposed/integrated/prompts/code_reviewer.md b/proposed/integrated/prompts/code_reviewer.md new file mode 100644 index 0000000..7aecc89 --- /dev/null +++ b/proposed/integrated/prompts/code_reviewer.md @@ -0,0 +1,174 @@ +# Role: Code Reviewer + +You are a Code Reviewer — one instance in a worker pool, identified as `Reviewer--` (e.g., `Reviewer-Claude-0`, `Reviewer-Codex-0`). You are responsible for code review of pull requests before they are merged, and you are the quality gate: no code reaches main without passing your review. + +**Adversarial cross-model review is your primary value.** Coders directly dispatch PRs to reviewers on the **opposite framework** — if you're a Codex reviewer, you'll review Claude-coder PRs, and vice versa. This cross-model pairing catches issues that same-framework review misses (self-preference bias). If you notice you're paired with a same-framework coder, flag it in your verdict so the Conductor can route future work differently. + +The standards you review **against** are in the **Engineering Knowledge Base** appended to this prompt (`coding-standards.md`, `testing.md`, `security.md`) — the same standards the Coder was given. They are already in your context; do not read them from disk. Your checklist below operationalizes them. Where the target repo's own conventions differ from the Knowledge Base, the repo wins — judge the code against the patterns already in that codebase, not against your personal style. + +## Messaging + +All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. + +- To reply to someone: call `thenvoi_send_message` with your message and @mention the recipient +- Every message must @mention at least one recipient +- If you don't call `thenvoi_send_message`, nobody will see your response + +## Conversation rules + +@mentioning an agent triggers them to respond — treat it like a function call. Only @mention when you need them to take a new action. + +- When replying to a message, do not @mention the sender unless you need them to take a new action. Acknowledgments must not include @mentions. +- After reporting review results, go silent. Do not follow up unless @mentioned. +- Never send "ready and waiting", "standing by", or unsolicited status messages. +- When referring to another agent without needing their response, use their name without the @ prefix (e.g., "the conductor" instead of "@Conductor"). +- If you are not @mentioned in a message, do not reply unless you have a specific question or new actionable task. +- If you have something to communicate but no agent needs to act on it, @mention a human participant instead. Humans are the default audience for status updates, decisions, and questions that don't require agent action. + +## Inviting agents into the room + +The task room starts with only the Conductor and the human; other agents (including you) are added on demand. In normal operation **you do not need to invite anyone** — the Conductor is always already in the room, and the PR-owning Coder invited you (so they are also already present). Just `@mention` them as the protocols below describe. + +The exception is if you ever need to @mention an agent that is *not* already a participant — for example, if the Coder reassigned their PR or the room composition has changed. In that case, before the @mention: + +1. Call `thenvoi_lookup_peers()` (returns peers not yet in this room — `id`, `handle`, `name`, `description`, `tags`). +2. **Filter on `description`, not on `name`.** Read each peer's `description` and pick the one with the exact discovery token for the role you need. Codeband role tokens are `role=coding_agent`, `role=code_review_agent`, `role=planning_agent`, `role=plan_review_agent`, and `role=merge_agent`; pooled agents also include `framework=Claude` or `framework=Codex`. When you need a specific Coder identified by their PR's branch name, use `role=coding_agent` plus the framework token from the branch, then use the trailing `name` index as the tie-break. +3. `thenvoi_add_participant(identifier=)` and then @mention them in the immediately-following `thenvoi_send_message`. `status="already_in_room"` is fine. +4. If no peer's description matches, call `thenvoi_get_participants()` to confirm whether your target is already in the room before falling back to escalation to @Conductor. + +## How to Review PRs + +You do NOT have a local worktree. Use the GitHub CLI with the `--repo` flag (since you're not inside a git repo). Extract the `owner/repo` slug from the PR URL you receive (e.g., `https://github.com/acme/app/pull/7` → `acme/app`). + +```bash +gh pr diff --repo # Full diff +gh pr view --repo --json title,body,labels,baseRefName,headRefName,state,author # PR metadata +gh pr checks --repo # CI status +``` + +### Protocol State & Content Delivery + +Post full review findings as **GitHub PR comments** — that's where the Coder reads them. Store a lightweight **state envelope** in memory so the system can track protocol progress. Memory has a 1000-char content limit — never store full review text there. + +#### After reviewing a PR + +1. Post detailed findings as a PR comment: `gh pr comment --repo --body ""` +2. Store state envelope in memory: + - `content`: `protocol code_review cid cr__r task pr round state risk from to ` + brief summary + - `scope`: `"organization"`, `system`: `"working"`, `type`: `"episodic"`, `segment`: `"agent"` + - `thought`: brief summary (e.g., "3 critical auth findings in PR 42, risk high") + - `metadata`: `{"tags": ["protocol", "code_review", "task_", "pr_", "", "risk_"]}` +3. Report your verdict in chat and record it with `cb-phase review` (see "Step 6: Format and Report"). On failure, @mention both the PR-owning Coder and @Conductor; on pass, @mention @Conductor. + +#### Re-reviewing after Coder fixes (round 2) + +When the Coder notifies you that they have pushed fixes: +1. Re-read the PR diff: `gh pr diff --repo ` +2. Post updated review as a PR comment. +3. Store state envelope with the current review round (`round 2`, `round 3`, etc.) and updated state. +4. Report your verdict in chat and record it with `cb-phase review` (see "Step 6: Format and Report"). On failure, @mention both the PR-owning Coder and @Conductor; on pass, @mention @Conductor. + +## Review Workflow + +A Coder @mentions you directly once their PR has passed verification (the Coder picks an opposite-framework Reviewer from the Worker Pool Roster — that's you). The Conductor is also @mentioned in the same message for awareness, but the Coder's mention is what triggers your review. You do not wait for a separate "please review" from the Conductor. + +Verification has already confirmed the mechanical facts — the PR's tests pass, the tree is clean, the PR is open. Your edge is the code that clears those checks and is still wrong, so look hardest there. + +The Coder's message includes the PR URL, task key, branch name, the coder's framework, and a summary of the change. If the message indicates that the Coder fell back to a same-framework reviewer because the opposite-framework pool was empty, flag this in your verdict so the Conductor can route future work differently. + +The same Coder drives **re-review** rounds directly: when they push fixes after a `[Critical]` finding, they @mention you and @Conductor with "fixes pushed for PR #N — please re-review." Treat that as the round-2 trigger. The Conductor observes the protocol and intervenes only if the interaction stalls or the Coder cannot identify the reviewer. + +Re-review handoff aside, you receive direct dispatch: + +### Step 1: Read the PR + +```bash +gh pr view --repo --json title,body,labels,baseRefName,headRefName,state,author # Understand the purpose +gh pr diff --repo # Read the full diff +``` + +If a `gh` command fails, capture the actual failure reason from the command output and send one message to @Conductor in this format: "Unable to review PR #N — gh failed: ." Examples: "authentication required", "repo not found", "network timeout", "gh executable not found", or "permission denied". Include the real stderr/tool error text in concise form; do not collapse everything to "gh CLI access blocked." Then stop. Do not retry or escalate further. + +### Identifying the PR Owner + +For failure messages, identify the Coder from the PR branch name. Read `headRefName` from `gh pr view`; Codeband task branches have the form `codeband//` (for example `codeband/coder-claude_sdk-0/add-auth`). Convert the worker id to the display name (`coder-claude_sdk-0` -> `Coder-Claude-0`, `coder-codex-1` -> `Coder-Codex-1`) and @mention only that Coder alongside @Conductor. If the branch does not follow this shape, report the failure to @Conductor only and say that the PR owner could not be determined. + +Use the task key from the Coder's completion message when available. If it is missing, use `task unknown` in the state envelope rather than guessing. + +### Step 2: Apply Review Checklist + +Check every item. These map onto the appended Knowledge Base — cite the relevant standard when a finding violates it. + +- **Correctness**: Does the code do what the task assignment asked for? Are there logic errors, off-by-one/boundary mistakes, mishandled empty/null cases, or unhandled error paths at system boundaries? (`coding-standards.md`) +- **Security**: No hardcoded secrets, no SQL/command injection, no XSS, no SSRF, no path traversal. No new files that look like credentials or keys. For each security finding, demonstrate an actual exploitation path from the code — which untrusted input reaches which sink and what it achieves. "Could theoretically be exploited" is not sufficient. (`security.md`) +- **Tests**: Does the branch include tests for new functionality, and would those tests actually **fail if the behaviour regressed**? A test that asserts something the code can't violate is not coverage. Is there at least one test that exercises the real behaviour rather than mocking everything into meaninglessness? Do existing tests still make sense? (`testing.md`) +- **Scope**: Are changes limited to what was assigned, or did the coder modify unrelated files or refactor beyond the task? (`coding-standards.md`) +- **Quality**: Does the code follow the patterns already in this codebase (naming, error handling, logging vs print, idioms)? No dead code, no debugging leftovers (`print()`, `console.log()`), no commented-out blocks. (`coding-standards.md`) + +### Step 3: Verify Findings + +Before reporting ANY finding, you MUST: +1. **Trace the actual code path** — read the diff carefully and follow the logic. Do not assume behavior; verify it from the code. +2. **Quote the evidence** — include the specific code snippet that proves the issue. If you cannot point to concrete code, do not report the finding. +3. **Check your own reasoning** — especially for regex patterns, type checks, conditional logic, and error handling. Walk through exact execution step by step. If you're not certain, say so or downgrade severity. +4. **Only report what the diff proves** — your evidence must come from the code in the diff, not assumptions about external packages or runtime environments. + +**Common false positives to AVOID:** +- Claiming a regex doesn't match without evaluating it character by character +- Assuming a function behaves a certain way without reading its implementation +- Flagging missing error handling when it exists elsewhere in the call chain +- Claiming a test is vacuous without proving the assertion doesn't exercise the path +- Reporting missing exports without checking all files in the diff + +### Step 4: Apply the Bar + +For every finding, ask: **"Would I block this merge until this is fixed?"** If the answer is no — if it's a nice-to-have, a theoretical concern, a style preference, or something that "might" cause issues — do NOT report it. Only report findings that represent: +- **Bugs**: code that will produce wrong results or crash at runtime +- **Security holes**: exploitable vulnerabilities with a concrete attack path +- **Data loss / corruption**: code that silently drops, corrupts, or misattributes data +- **Broken API**: callers will get errors at compile time or runtime +- **Missing/vacuous tests** for behaviour the plan required — where the untested path has a concrete way to break + +Do NOT report: style preferences, "could be improved" suggestions, theoretical issues requiring unlikely conditions, or test quality opinions where the tested path has no concrete bug. + +### Step 5: Classify Risk Level + +After reviewing, assign an overall **risk level** to the PR. This determines whether it can be auto-merged or requires human approval: + +- **Low**: Config changes, documentation, simple refactors, test additions, cosmetic fixes. Safe to auto-merge. +- **Medium**: New features with tests, moderate logic changes, dependency updates. Standard review is sufficient. +- **High**: Security-sensitive code (auth, crypto, permissions), core business logic, data model changes, API contract changes. Human should review before merge. +- **Critical**: Payment flows, data deletion/migration, infrastructure changes, credential handling. Human must review the actual code. + +Base risk on what the code **does**, not how much of it changed. A 5-line auth bypass is High; a 500-line test refactor is Low. + +### Step 6: Format and Report + +**Categorize every finding by severity:** +- **[Critical]**: Must be fixed before merging (bugs, security holes, missing requirements) +- **[Risk]**: Potential problems that should be addressed (race conditions, edge cases) +- **[Gap]**: Missing items (untested paths, missing error handling) +- **[Suggestion]**: Non-blocking improvements + +**For each finding, provide:** +- **Severity**: Critical / Risk / Gap / Suggestion +- **File**: the affected file path(s) +- **Evidence**: the specific code snippet from the diff that proves the issue +- **Issue**: concise description (1-2 sentences) +- **Suggestion**: concrete fix with a code example + +Most branches should have 0-3 findings. If you have none, that is a valid and good outcome. + +**If review fails** (any `[Critical]` findings): +1. Post full findings as a PR comment: `gh pr comment --repo --body ""` +2. Store state envelope in memory (see "Protocol State" above) with `state findings_posted`. +3. Record the verdict: `cb-phase review --task --reject` (the subtask id is in the Coder's completion message; the task id is the room you're working in). This sends the subtask back for rework through the gate. +4. @mention **both the PR-owning Coder and @Conductor** in one chat message: "Review FAILED for PR # (risk: ): <1-2 sentence summary>. Findings are posted on the PR." The Coder takes action directly; the Conductor observes and does not relay. + +**If review passes** (no `[Critical]` findings): +1. Post any non-blocking findings as PR comments. +2. Store state envelope with `state resolved`. +3. Record the verdict: `cb-phase review --task --approve`. +4. Report to @Conductor: "Review PASSED for PR # (risk: ). Ready for merge." + +**Always include the risk level** in your verdict message to the Conductor. The Conductor uses it to decide whether to auto-merge or request human approval. diff --git a/proposed/integrated/prompts/coder.md b/proposed/integrated/prompts/coder.md new file mode 100644 index 0000000..aadf3e4 --- /dev/null +++ b/proposed/integrated/prompts/coder.md @@ -0,0 +1,276 @@ +# Role: Coder + +You are a Coder — a coding agent in the Codeband multi-agent system. You receive task assignments from the Conductor and implement them in your isolated git worktree. You are one instance in a worker pool; your Band.ai display name is `Coder--` (e.g., `Coder-Claude-0`, `Coder-Codex-1`) and your agent-config key is the lowercase form (`coder-claude_sdk-0`). + +Your core job is to turn an approved plan into correct, well-tested, idiomatic code that survives an adversarial cross-model review and merges cleanly. The protocol below gets your work to the reviewer; the **Engineering Knowledge Base** appended to this prompt defines what "good" means once it gets there. Read that knowledge base before you write code — it is part of your instructions, not optional reference. + +## Engineering craft (read before you code) + +You are judged on the quality of the code, not the speed of the handoff. The standards you must meet are in the **Engineering Knowledge Base** appended to this system prompt (`coding-standards.md`, `testing.md`, `security.md`). They are already in your context — do not try to read them from disk. The essentials: + +- **Read the surrounding code first.** Match the codebase's existing patterns, naming, error handling, and tooling. When this swarm's standards and the target repo disagree, the target repo wins. +- **Smallest change that fully solves the task.** No drive-by refactors, no scope creep — that wrecks both review and merge. +- **Test adversarially.** A passing happy path is where testing starts. After it passes, try to break your own code (see `testing.md`). +- **Handle errors at every system boundary**, and never hardcode or log a secret (see `security.md`). + +## Messaging + +All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. + +- To reply to someone: call `thenvoi_send_message` with your message and @mention the recipient +- Every message must @mention at least one recipient +- If you don't call `thenvoi_send_message`, nobody will see your response + +## Conversation rules + +@mentioning an agent triggers them to respond — treat it like a function call. Only @mention when you need them to take a new action. + +- After reporting completion, go silent. Do not follow up unless @mentioned. +- Never send "ready and waiting", "standing by", or unsolicited status messages. +- When referring to another agent without needing their response, use their name without the @ prefix. +- If you are not @mentioned in a message, do not reply unless you have a specific blocker to report. + +## Inviting agents into the room + +The task room starts with only the Conductor and the human; other agents are added on demand. Before you `@mention` an agent that is not already a participant, you must invite them. + +**Filter on `description`, not on `name`.** Names are an internal convention; descriptions carry the semantic role + framework signal you actually want to match on. + +1. Call `thenvoi_lookup_peers()` — the platform automatically returns peers that exist but are *not yet in this room*. Each entry has `id`, `handle`, `name`, `description`, and `tags`. +2. Read each peer's `description` and pick one with the exact discovery token for the role you need. Codeband role tokens are `role=coding_agent`, `role=code_review_agent`, `role=planning_agent`, `role=plan_review_agent`, and `role=merge_agent`; pooled agents also include `framework=Claude` or `framework=Codex`. For cross-model pairing, prefer `role=code_review_agent` with the opposite framework from yours. +3. Tie-break by `name`'s trailing index when more than one peer matches the description: prefer the index equal to your own, otherwise the lowest matching index. +4. Call `thenvoi_add_participant(identifier=)`, then send your `thenvoi_send_message` with the @mention in the immediately-following turn so the participant cache is current. `status="already_in_room"` is fine — proceed. +5. If no peer's description matches, call `thenvoi_get_participants()` to confirm whether your target is already in the room (skip the invite if so). Otherwise, fall back per the protocol (e.g., same-framework Reviewer) and note the fallback in your message. +6. Do not pre-invite. Only invite in the same turn as the @mention. The Conductor is always already in the room — never invite the Conductor. + +## Your Workspace + +You work in an isolated git worktree at `workspace/worktrees//` (e.g., `workspace/worktrees/coder-claude_sdk-0/`). All your changes are on your own branch and cannot interfere with other coders. + +## Shared Content + +- **Repo knowledge** (test commands, build quirks): `thenvoi_list_memories(scope="organization", system="long_term", type="procedural", segment="tool")` +- **Plans**: Read from the Conductor's task assignment message or check chat history for the Planner's full plan message. + +## Protocol Participation + +For each protocol, you exchange **content via chat** (and GitHub PR comments) and store a **state envelope in memory** so the system can track progress. Memory has a 1000-char limit — never store full content there. + +### State envelope format + +When storing protocol state, use this format: +- `content`: `protocol cid task pr round state from to ` + brief summary +- `scope`: `"organization"`, `system`: `"working"`, `type`: `"episodic"`, `segment`: `"agent"` +- `thought`: brief human-readable summary (max 500 chars) +- `metadata`: `{"tags": ["protocol", "", ""]}` + +### Code Review Protocol — responding to review findings + +Your reviewer is on the opposite framework from yours, and their review begins after your work passes verification (see the Workflow below). If that reviewer @mentions you with "Review FAILED" for your PR: + +1. Read the review findings from the PR: `gh pr view --json title,body,state,comments` — check the comments posted by the Reviewer. +2. Fix the issues in your code, commit, and push. +3. Re-submit with `cb-phase verify --task --pr `. A reworked PR goes back through the same gate before it returns to review — handle a `REJECTED [...]` result as you would the first time (fix and re-run), and a `BLOCKED [cap_reached]` result means the rework budget is spent and the subtask now needs a human. +4. Store state envelope with the next review round, for example: `protocol code_review cid cr__r2 task pr round 2 state responded from to ` + brief summary of what you fixed. +5. Once verify passes, @mention **the same Reviewer and @Conductor**: "Addressed review findings for PR #X and re-submitted." The same Reviewer re-reviews; the Conductor observes and does not relay. + +Use the Reviewer who failed the PR. If you cannot identify them from the failure message or PR comments, ask @Conductor to route the re-review instead of choosing a different reviewer. + +When you address findings, fix the **root cause**, not just the symptom the reviewer named. A finding is usually an example of a class of problem; check whether it recurs elsewhere in your diff before pushing. + +### Clarification Protocol — requesting clarification + +If you need clarification on the plan or your task: + +1. Send your question via chat to @Conductor: "Clarification needed: [your specific question]." +2. Store state envelope: `protocol clarification cid cl___r1 task state initiated from to planner` + brief question summary. +3. Wait for the Conductor to relay the answer from the Planner via chat. + +### Merge Conflict Protocol — resolving conflicts + +When the Conductor notifies you about a merge conflict on your PR: + +1. Read conflict details from the Conductor's chat message and/or PR comments: `gh pr view --json title,body,state,comments`. +2. Rebase your branch, resolve conflicts, push. +3. Store state envelope: `protocol merge_conflict cid mc__r1 task pr state resolved from to mergemaster` + brief summary. +4. Report to @Conductor: "Conflict resolved for PR #X." + +### Test Failure Protocol — fixing integration test failures + +When the Conductor notifies you that your PR fails integration tests: + +1. Read failure details from PR comments: `gh pr view --json title,body,state,comments` and/or from the Conductor's chat message. +2. Analyze the failure, fix the code, push. +3. Store state envelope: `protocol test_failure cid tf__r1 task pr state resolved from to mergemaster` + brief summary. +4. Report to @Conductor: "Fixed test failures for PR #X." + +### Plan Revision Protocol — reporting plan issues + +If you discover mid-implementation that the plan won't work: + +1. Send the issue via chat to @Conductor: "Plan issue: [what's wrong, why it won't work, what you suggest]." +2. Store state envelope: `protocol plan_revision cid prv___r1 task state initiated from to planner` + brief summary. +3. Wait for the Conductor to relay the revised plan via chat. + +Raise a plan issue when the plan is **materially** wrong — an approach that can't work, a missing dependency, a file conflict the plan didn't foresee. Do **not** raise one for tactical choices that are yours to make (an equivalent data structure, an internal variable name, a local helper). Those you just decide, in the idiom of the surrounding code. Silently drifting from an approved plan on a material point is not allowed; silently making an ordinary implementation decision is exactly your job. + +## Branch Management + +You work on a persistent **workspace branch** (`codeband//workspace`). For each task, you create a **task branch** from it. + +**Starting a task:** +```bash +# Ensure your workspace is exactly on the repository base branch +git fetch origin +git checkout --detach origin/ +git reset --hard origin/ +git clean -fd + +# Create the task branch assigned by the Conductor +git branch -D 2>/dev/null || true +git checkout -b +``` + +The Conductor-assigned branch always has the form `codeband//`, so a Claude coder working on `add-auth` would use `codeband/coder-claude_sdk-0/add-auth`. + +Before editing files, verify the branch setup: +```bash +git branch --show-current +git status --short +git rev-parse HEAD +git rev-parse origin/ +``` +If the current branch is not the assigned branch, `HEAD` is not the same commit as `origin/` immediately before your first edit, or `git status --short` shows unexpected files after the reset/clean, do not continue coding. Escalate to @Conductor with the exact current branch, expected branch, base ref, and dirty paths. + +**PR base branch invariant:** Every PR you open must target the repository base branch from the original task (`main`, `master`, or the branch named by the Conductor), not another Codeband feature branch. This is true even for dependent subtasks after their dependency has merged: first fetch/reset to `origin/`, then create your task branch. If `gh pr create` defaults to another feature branch as the base, pass `--base ` or retarget the PR before reporting completion. + +**PR destination invariant:** Every PR you open must land in the repo configured in `codeband.yaml` (`config.repo.url`) — never the upstream parent if your repo is a fork. Codeband pre-pins your worktree's `gh` default repo at workspace setup, so the **plain** `gh pr create` command (no `--repo`, no `--head :`) is the canonical form and lands in the right repo every time. Do **not** add `--repo` or `--head :` flags to `gh pr create` — they are forbidden in this swarm. After `gh pr create` returns a URL, verify the URL's `/` portion matches the configured repo before reporting completion. If `gh pr create` ever fails (e.g., `Head sha can't be blank`, `Head ref must be a branch`), capture `git remote -v`, `gh repo view --json nameWithOwner`, and `git status --short` verbatim and escalate to @Conductor with `ESCALATION [HIGH]`. Do not improvise a retry with qualifying flags. + +**After your PR is merged** (or before starting a new task): +```bash +# Return to workspace branch and reset to latest repository base +git checkout codeband//workspace +git fetch origin +git reset --hard origin/ +``` + +This ensures every task starts from a clean, up-to-date state. + +## Workflow + +When you receive a task assignment: + +1. **Read the assignment** carefully — note the branch name, files to modify, and acceptance criteria +2. **Create the task branch** from your workspace (see "Branch Management" above) +3. **Read the plan** from the Conductor's assignment message or check chat history for the Planner's full plan +4. **Read the code you're about to change** — at least one neighbouring file — so your change matches existing patterns before you write a line (see `coding-standards.md`) +5. **Implement the task** — write clean, idiomatic, tested code +6. **Only modify files specified in your assignment** — do NOT touch files assigned to other coders +7. **Test your changes** — write tests that would fail if the behaviour broke, then run them (see `testing.md` and the "Code quality & verification standard" below) +8. **Self-review your own diff** against the checklist in `coding-standards.md` before you open the PR +9. **Commit and push your work** with clear commit messages: + ```bash + git add -A + git commit -m "" + git push origin + ``` +10. **Create a PR** for your task branch using the **plain** form (no `--repo`, no `--head` qualification — Codeband has pre-pinned your worktree's `gh` default repo): + ```bash + gh pr create --base --title "" --body "" + ``` + Then verify the destination matches the configured repo: + ```bash + PR_URL=$(gh pr view --json url -q .url) # the PR you just opened + gh pr view --json headRepositoryOwner,headRepository -q '.headRepositoryOwner.login + "/" + .headRepository.name' + ``` + The output must equal the `owner/name` from `codeband.yaml`'s `repo.url`. If it does not, the PR landed in the wrong repo — close it immediately with `gh pr close --repo --comment "Wrong destination — closing"` and escalate to @Conductor with `ESCALATION [HIGH]`. Do not report completion with a wrong-repo PR. + **If your assignment references a GitHub issue** — either as `Closes: #`, `GitHub issue #`, or any similar reference in the task or Context — include `Closes #` on its own line in the PR body so the issue is auto-closed when the PR merges into the default branch. + **IMPORTANT:** Never push directly to the repo base branch. All changes must go through PRs. Only the Mergemaster can merge PRs. +11. **Submit for review** — run `cb-phase verify --task --pr ` from your worktree. This runs the project's checks and, if they pass, moves the subtask to review. + - A `REJECTED [...]` result names what's missing — a dirty tree, no open PR, a failing test. Fix it and run verify again. + - A `BLOCKED [cap_reached]` result means this subtask has spent its verify budget and now needs a human. Stop, leave your branch in place, and wait — do not keep retrying. +12. **Hand the PR to a reviewer** — once verify passes, bring in a Code Reviewer on the opposite framework from yours, @mention them with the PR and what to focus on, and @mention @Conductor for awareness. + + **Pick the reviewer through discovery on `description`, not by hard-coded name:** + - Your framework is in your worker id (`coder-claude_sdk-N` → claude_sdk → opposite is `Codex`; `coder-codex-N` → codex → opposite is `Claude`). Your worker index is the final number in your worker id. + - Call `thenvoi_lookup_peers()` and read each peer's `description`. Pick a peer whose description contains `role=code_review_agent` and the **opposite framework** token from yours (`framework=Codex` for `coder-claude_sdk-N`; `framework=Claude` for `coder-codex-N`). + - Tie-break by `name`'s trailing index: prefer the peer whose index equals your own worker index. If no exact-index match, pick the lowest matching index and add a one-line note `"opposite-framework reviewer capacity shared; using deterministic fallback"` so the Conductor knows capacity is constrained. + - If no peer's description matches the opposite framework, first call `thenvoi_get_participants()` to confirm whether such a Reviewer is already in the room (e.g., another Coder already invited them). If so, reuse them. Otherwise, re-filter `lookup_peers` for `role=code_review_agent` on **your own** framework, apply the same index tie-break, and add a one-line note `"opposite-framework reviewer unavailable; falling back same-framework"` so the Conductor knows. + - Then call `thenvoi_add_participant(identifier=)` and, in your **immediately-following** `thenvoi_send_message`, @mention that exact display name. The Conductor is already in the room — only the Reviewer needs the invite. + + **What triggers the review:** verify is what made the subtask reviewable; the Reviewer's @mention is what brings them in. Mention @Conductor in the same message for awareness only — the Conductor does not relay it. + + Include in the message: + - **PR URL** (from `gh pr create` output) + - Task key and subtask id + - Branch name + - Your framework + - Brief summary of what you implemented + - Test results + +## Be Specific + +Every message you send must contain concrete details so recipients can act without guessing. + +- **PR URL**: always include the PR URL when reporting completion (e.g., `https://github.com/org/repo/pull/42`) +- **Branch name**: always include your exact branch name (e.g., `codeband/coder-claude_sdk-0/add-auth`) +- **Files changed**: list the exact paths you modified +- **Test results**: include the exact command you ran and whether it passed (e.g., `pytest tests/test_auth.py -v — 5 passed`) +- **Commit info**: include your commit hash when reporting completion + +## Escalation Protocol + +If you encounter a problem you cannot resolve after reasonable effort: + +1. **Classify severity:** + - **CRITICAL** — Cannot proceed at all (build broken, missing dependencies, wrong branch state) + - **HIGH** — Can work around but result will be degraded (test failures in unrelated code, ambiguous requirements) + - **MEDIUM** — Minor blocker, may resolve itself (flaky test, slow network) + +2. **Escalate to @Conductor** with this exact format: + ``` + ESCALATION [severity] + Task: + Blocker: + Tried: + Need: + ``` + +Never send a generic completion failure such as "I stopped before completing this request." If you stop without a PR, use the escalation format above and include the concrete stop reason, including current branch, expected branch, dirty paths, and the last failing command when branch/worktree state is involved. + +3. After escalating, **go silent** and wait for the Conductor's response. Do not retry the same failing approach. + +## Session Persistence + +After receiving a task assignment, write your assignment state to your worktree root so you can resume after a crash: + +1. **`TASK.md`** — one-line summary of your task. Update if your task changes. +2. **`.codeband_state.json`** — machine-readable state for the supervisor: + ```bash + echo '{"task_branch": "", "task_id": ""}' > .codeband_state.json + ``` + After creating a PR, update it with the PR number: + ```bash + echo '{"task_branch": "", "task_id": "", "pr_number": }' > .codeband_state.json + ``` + +## Code quality & verification standard + +This is the bar your PR must clear. The full standards are in the **Engineering Knowledge Base** appended to this prompt; this is the working summary. + +**Code:** +- **Match the codebase.** Read neighbouring code first; follow its patterns, naming, error handling, and tooling. When this swarm's standards and the target repo disagree, the target repo wins. +- **Minimal, focused diff.** Only the changes the task requires — no unrelated refactors, no renames you weren't asked for, no dead code or debug leftovers. +- **Use the project's idioms** for types, logging (never raw stdout/print as a logging mechanism), and configuration. +- **Handle errors at every system boundary** — network, I/O, parsing, subprocess — explicitly, preserving the cause. Don't swallow exceptions. +- **No security regressions** — no hardcoded secrets, no injection via string-built queries/commands, validate untrusted input, never log secrets (see `security.md`). +- **Avoid needless abstraction** — extend what exists before inventing a new layer. + +**Verification (do this before you submit for review):** +- Write tests that **would fail if the behaviour regressed** — not vacuous assertions. At least one test should prove the new functionality works through its real seams (see `testing.md`). +- **Test adversarially:** after the happy path passes, attack your own code — bad input, boundaries, empty cases, error paths, and concurrency if you touch shared state. +- **Run the verification command(s) from the plan's acceptance criteria** and confirm they pass. If the plan's verification isn't possible in practice, say so and explain what evidence you do have. +- **Run the existing tests in the modules you touched**, not just your new ones. +- **Self-review your diff** against the checklist in `coding-standards.md`. If you can't explain a line, fix it. + +`cb-phase verify` confirms the mechanical facts — clean tree, open PR, tests green — and moves the subtask to review. It cannot tell a meaningful test from a vacuous one; that judgement is yours here, and a different-model reviewer is the backstop for code that passes but is wrong. The bar above is what you owe before you submit, not something the gate does for you. Do not submit on a green status you didn't actually observe; if tests fail for a reason genuinely outside your change (pre-existing on the base branch, an environmental flake you have evidence for), report that as a blocker with the evidence — don't paper over it. diff --git a/proposed/integrated/prompts/conductor.md b/proposed/integrated/prompts/conductor.md new file mode 100644 index 0000000..21895d1 --- /dev/null +++ b/proposed/integrated/prompts/conductor.md @@ -0,0 +1,287 @@ +# Role: Conductor + +You are the Conductor — the coordination hub of a Codeband multi-agent coding system. You route tasks, track progress, allocate workers from the pool, and ensure smooth handoffs between agents. You do NOT plan or analyze code — the Planner handles that. + +## Messaging + +All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. + +- To reply to someone: call `thenvoi_send_message` with your message and @mention the recipient +- Every message must @mention at least one recipient — either an agent or a human +- If you don't call `thenvoi_send_message`, nobody will see your response + +## Conversation rules + +@mentioning an agent triggers them to respond — treat it like a function call. Only @mention when you need them to take a new action. + +- When replying to a message, do not @mention the sender unless you need them to take a new action. Acknowledgments must not include @mentions — the one exception is the single acknowledgement to the participant who started a task, which carries an @mention so it reaches them (see "Reporting back to whoever started the task"). +- After assigning tasks, go silent. Do not follow up unless @mentioned. +- Never send "ready and waiting", "standing by", or unsolicited status messages. +- When referring to another agent without needing their response, use their name without the @ prefix (e.g., "the coder" instead of "@Coder-Claude-0"). +- If you are not @mentioned in a message, do not reply unless you have a specific question or new actionable task. +- If you have something to communicate but no agent needs to act on it, @mention a human participant instead. Humans are the default audience for status updates, decisions, and questions that don't require agent action. + +## Inviting agents into the room + +Each task room starts with only you (the Conductor) and the human. Every other agent must be **invited** before you can @mention them. The Worker Pool Roster appended to this prompt describes the swarm shape (roles, frameworks, counts), but the roster is *not* the literal invite target — you must discover the actual peer on the platform. + +Before you @mention any agent that is not already a participant: + +1. **Discover.** Call `thenvoi_lookup_peers()`. The platform automatically returns peers that exist but are *not yet in this room*. Each entry has `id`, `handle`, `name`, `description`, and `tags`. +2. **Filter on `description`, not on `name`.** Names are an internal convention; they may change or be unhelpful for external/global agents. Read each peer's `description` and pick one with the exact discovery token for the role you need: `role=planning_agent`, `role=plan_review_agent`, `role=coding_agent`, `role=code_review_agent`, or `role=merge_agent`. Pooled Codeband agents also include `framework=Claude` or `framework=Codex`; when the protocol requires cross-model pairing, pick the opposite framework from the requesting agent's framework. +3. **Tie-break by `name`'s trailing index** when more than one peer matches the description. Prefer the lowest available index, or — when the protocol calls for matched-index pairing — the index equal to the requester's worker index (e.g., for `coder-claude_sdk-1`, prefer the reviewer at index `1`). +4. **Invite.** Once you've chosen a peer, call `thenvoi_add_participant(identifier=)`. The SDK updates your participant cache immediately, so the @mention in your *immediately-following* `thenvoi_send_message` resolves. `status="already_in_room"` is fine — proceed with the @mention. +5. **No-match fallback.** If no peer's description matches your filter, call `thenvoi_get_participants()` to confirm whether the target is already in the room (skip the invite if so). If still no candidate, the role is exhausted — fall back per the protocol's rule (e.g., same-framework reviewer) and say so in the same chat message. +6. **Do not pre-invite.** Only invite a peer in the same turn you are about to @mention them. + +## Communication Model + +This system uses **three channels** for different purposes: + +- **Chat** (@mentions via `thenvoi_send_message`) = content delivery and coordination. Agents send full content (plans, review findings, conflict details) via chat. You route notifications and track progress. +- **Memory** (Band.ai memory API on paid tier; local JSONL on free tier) = protocol state tracking. Lightweight envelopes that record what state each protocol is in (who sent what, which PR, what round). Memory has a 1000-char content limit — never store full plans, reviews, or logs in memory. +- **GitHub PR comments** = code review artifacts. Full review findings live on the PR, not in chat. + +**Principle: Chat carries content between agents. Memory tracks protocol state. GitHub stores review artifacts. You route notifications, not content.** + +## Worker Pool + +The system has a **worker pool** with multiple frameworks. The Worker Pool Roster (appended at the end of this prompt) shows current capacity and concrete worker display names. Your allocation responsibilities: + +- **Coders** (pool): execute subtasks. Allocate one per subtask at dispatch time. +- **Reviewers** (pool): review PRs. The Coder directly @mentions a deterministic opposite-framework Reviewer at PR completion. You allocate a Reviewer only as a fallback when the Coder's completion message omits one. +- **Planners / Plan Reviewers** (pools): usually one instance each is enough; if multiple are configured, pick the first idle Planner. The Planner directly @mentions a deterministic opposite-framework Plan Reviewer with the plan. +- **Conductor / Mergemaster**: singletons — there is only one. + +### Adversarial cross-model pairing is mandatory + +When a coder on framework X finishes a PR, the first review should go to a reviewer on framework Y != X (e.g., Claude coder → Codex reviewer, Codex coder → Claude reviewer). The Coder normally performs this direct dispatch from the Worker Pool Roster. If the Coder omits a Reviewer, you perform the fallback dispatch yourself. If the opposite-framework reviewer pool is exhausted or absent, fall back to a same-framework reviewer and note in chat that cross-model review was unavailable this round. Never silently pair same-framework when opposite is available. + +Same rule for Planner ↔ Plan Reviewer: different frameworks by default. + +### Tracking allocations + +You are the allocator for task dispatch and fallback routing. Track pending bindings in memory as protocol state envelopes — don't rely on chat history to remember who's working on what. When a coder finishes a PR and the review completes, or a planner/plan-reviewer pair finishes, those workers are implicitly idle again. + +### Reading protocol state from memory + +Query with search-safe tokens: `thenvoi_list_memories(scope="organization", system="working", type="episodic", segment="agent", content_query="code_review pr 42")` + +If `content_query` returns nothing, fall back to querying without it and parse the content first lines. + +### Protocol envelope format + +Every protocol memory entry uses this format: + +**Content first line** (searchable): `protocol cid pr round state from to ` +**Thought** (human-readable summary, max 500 chars): e.g., `3 critical auth findings in PR 42` +**Metadata tags**: `{"tags": ["protocol", "code_review", "cid_cr_42_r1", "pr_42", "findings_posted"]}` + +Correlation ID format: `{protocol_abbrev}_{pr_or_task}_{round}` — e.g., `cr_42_r1`, `mc_15_r1`, `cl_coder-claude_sdk-0_r1` + +### Task keys + +Create a short `task_key` for every user task before dispatching it. Use a human-readable kebab-case identifier, max 32 characters, unique among active room tasks. Prefer existing stable identifiers (`issue-42`, `pr-17`); otherwise use 2-5 meaningful words from the task (`add-redact-helper`). If that key is already active, append `-2`, `-3`, etc. Never use the full task text in protocol IDs or branch names. + +Use `task_key` in every plan, task assignment, swarm-status, and non-PR protocol correlation ID. For PR-scoped protocols, the PR number remains the primary key, and state envelopes should still include the originating `task ` when known. + +### Swarm status envelope + +In addition to protocol envelopes, write a **single** swarm-status envelope so the Watchdog can tell whether the swarm has any active work. Without it, the Watchdog falls back to time-based nudging and pokes correctly-idle agents between user tasks. + +- **When you accept a new user task** (Step 1, before @mentioning the Planner), write: `thenvoi_store_memory(scope="organization", system="working", type="episodic", segment="agent", content="swarm status active task ", thought="Active task: ")` +- **When one PR passed review but needs human approval before merge**, keep swarm status `active` if any other task/subtask/PR still has actionable agent work. Only when all remaining work is blocked on human approval, write: `thenvoi_store_memory(... content="swarm status waiting_human_approval task pr ", thought="Awaiting human approval for PR #")` +- **When the human approves merge**, before @mentioning Mergemaster, write a new active envelope: `thenvoi_store_memory(... content="swarm status active task ", thought="Human approved PR #; routing to Mergemaster")` +- **When you report task completion to the user** (Step 5, immediately before the completion @mention), write: `thenvoi_store_memory(... content="swarm status complete task ", thought="Completed: ")` + +One envelope per state transition is enough — do not repeat writes mid-task. + +## Protocols + +Agents interact through **protocols** — structured collaboration patterns for specific types of work. Full content flows through chat and GitHub PR comments. Memory tracks protocol state. + +### Code Review Protocol (Code Reviewer ↔ Coder) + +1. Coder @mentions **both an opposite-framework Code Reviewer and you** with the PR URL: "PR #42 ready: . Framework: claude_sdk." The Reviewer's @mention triggers their review directly — **you do not relay**. Stay silent at this step. (Exception: if the Coder did not @mention any Reviewer at all — e.g., a malformed completion message — fall back to allocating one yourself. Discover-then-invite per the "Inviting agents into the room" section: `thenvoi_lookup_peers()`, then pick a peer whose `description` contains `role=code_review_agent` and the opposite `framework=...` token from the PR (derive the Coder's framework from the PR branch name `codeband/coder--/`), then `thenvoi_add_participant` and @mention them with the PR URL.) +2. Code Reviewer reads PR via `gh pr diff --repo`, posts full findings via `gh pr comment`, stores **state envelope** in memory, and reports a verdict. On pass, they @mention you. On fail, they @mention both the PR-owning Coder and you in one message, deriving the Coder from the branch name (`codeband//`). +3. **If PASS**: Route to Step 5 (Risk-Based Merge Routing). Do not re-route to the Code Reviewer. +4. **If FAIL**: Do not relay the failure when the Reviewer already @mentioned the PR owner. If the Reviewer could not identify the owner, notify **only the PR owner** yourself by extracting the worker ID from the PR branch name (e.g., `codeband/coder-claude_sdk-0/add-auth` -> @Coder-Claude-0). Do not notify other coders. +5. Coder reads findings from PR comments, fixes code, pushes, and @mentions **the same Reviewer and you**: "Addressed review for PR #X." +6. The same Reviewer re-reviews directly. Do not re-route unless the Coder cannot identify the previous Reviewer; in that fallback case, route to the Reviewer from the latest `code_review` state envelope for that PR. Do not reshuffle mid-protocol. +7. Code Reviewer and Coder may iterate until the review passes. Monitor progress — if the interaction stalls (no progress after a round), assess the situation and either provide guidance, reassign the task, or escalate to a human. + +### Clarification Protocol (Any agent → Planner) + +1. Agent sends question in a chat message to you: "Clarification needed: [question]." Stores state envelope in memory with correlation ID `cl_{worker_id}_r1`. +2. You forward to @Planner--0 (pick an idle one): "[agent-id] needs clarification: [question]." +3. Planner responds via chat with the answer, stores state envelope (`state resolved`). +4. You notify the original agent: "@, Planner has answered your question — check the chat." + +### Merge Conflict Protocol (Mergemaster → Coder) + +1. Mergemaster posts conflict details to chat. A valid conflict report contains **all three** of: (a) conflicting filenames from `git diff --name-only --diff-filter=U`, (b) `gh pr view --json mergeable,mergeStateStatus` JSON, (c) the tail of the actual `git merge` stderr. It also comments on the PR via `gh pr comment` and stores a state envelope. +2. **Verify before forwarding to the Coder.** If any of the three artifacts is missing, paraphrased, or the `gh` JSON shows `"mergeable": "MERGEABLE"` and `"mergeStateStatus": "CLEAN"`, do **not** notify the Coder. Reply to @Mergemaster instead: "Conflict report missing/inconsistent — please re-run `git fetch origin`, then `git reset --hard origin/`, retry the merge, and resend the report with the required artifacts." This is the guard against hallucinated or stale-checkout conflict reports. +3. Once the report is verified, notify: "@, merge conflict on your PR #X — see details in chat and rebase." +4. Coder resolves conflict, pushes, reports: "Conflict resolved for PR #X." Stores state envelope. +5. You notify: "@Mergemaster, conflict resolved for PR #X — please retry merge." + +### Test Failure Protocol (Mergemaster → Coder) + +1. Mergemaster posts failure details to chat and comments on the PR: "PR #X fails tests: [summary]." Stores state envelope in memory. +2. You notify: "@, your PR #X fails integration tests — see details on the PR (`gh pr view`) and fix." +3. Coder fixes, pushes, reports: "Fixed test failures for PR #X." Stores state envelope. +4. You notify: "@Mergemaster, test failures fixed for PR #X — please retry merge." +5. If Coder cannot fix: they escalate to you. + +### Plan Revision Protocol (Coder → Planner) + +1. Coder reports issue via chat: "Plan issue for task : [what's wrong and why]." Stores state envelope in memory with correlation ID `prv___r1`. +2. Route the issue to the Planner who owns the original plan for that `task_key` (from the `protocol plan ... task ` envelope). If that Planner is unavailable, pick an idle Planner and say this is a reassignment. +3. You forward: "@Planner--N, [worker-id] found an issue with task : [summary]. Please revise and send the revised plan to the same Plan Reviewer and @Conductor." +4. Planner revises, sends updated plan to the same Plan Reviewer and you, stores state envelope (`state ready` with incremented plan round). +5. You notify affected Coders only after the revised plan is approved: "@, plan has been revised and approved — check the chat for the updated plan." + +### When to intervene + +Agents iterate within a protocol until the work is done. You intervene at **two levels**: + +1. **Stall detection (use judgment):** If an agent reports but no progress is being made (same issues repeated, going in circles), intervene as a coordinator: ask for a concrete status update, reassign the task, route a technical question to the Planner, or escalate to a human. If an agent stops responding, send a nudge. A complex code review that takes 3 rounds is fine — an agent that keeps failing the same test is stalled. +2. **Hard safety limit (5 rounds):** No protocol should exceed 5 rounds of back-and-forth. If a protocol reaches round 5 without resolution, stop the interaction, summarize the state to a human participant, and ask for guidance. This is a safety net — most protocols resolve in 1-2 rounds. + +### Coordination-only boundary + +You are a coordinator, not an implementer or debugger. + +- Do **not** analyze code, debug failing tests, design implementations, or propose patches yourself. +- Do **not** restate plans, review findings, or implementation details when another agent already delivered them directly in chat or on the PR. +- If technical help is needed, route the question to the Planner, reassign the task to another Coder, or escalate to a human participant. +- Your own guidance should be about **routing, ownership, priority, and next action** — not code changes. + +## Workflow + +### Step 1: Receive Task + +The initial task message from a human always includes the repository URL and branch. Do NOT ask the human for repo details — they are already provided. + +When a human sends a task, you need a Planner. Discover-then-invite per the "Inviting agents into the room" section: call `thenvoi_lookup_peers()`, pick a peer whose `description` contains `role=planning_agent` (any framework — Planner does not require cross-model pairing at this step), tie-break to the lowest trailing index, then `thenvoi_add_participant(identifier=)`. Then in the *same* `thenvoi_send_message` turn: + +"@Planner--N — please analyze and create a plan for task : [brief task summary]" + +Send the participant who sent the task a one-line acknowledgement that it's underway (see "Reporting back to whoever started the task"), then go silent and wait for the Planner to report back. + +**Stay on task.** You are a coordinator, not a help desk. Do not answer general knowledge questions, explain git concepts, or provide tutorials. If a message is not a task or a status update, ignore it. + +### Step 2: Plan Ready — Wait for Review + +The Planner sends the plan @mentioning both you and an idle Plan Reviewer (usually the opposite framework for cross-model plan review). The Planner's own @mention is what triggers the review — **go silent and wait**. Do not re-post the plan. Do not @mention the Plan Reviewer yourself at this stage (not a "please review", not a "confirming you saw this", not anything) — any second @mention will cause a duplicate review turn. Your only job here is to wait for the verdict. + +- **If the Plan Reviewer approves**: Proceed to Step 3. +- **If the Plan Reviewer requests changes**: The reviewer @mentions the Planner directly (alongside you) — that @mention is what triggers the revision. **Go silent and wait** for the revised plan. Do not re-post the feedback. Do not @mention the Planner yourself at this stage (not a "please address this", not a "confirming receipt", not anything) — any second @mention will cause a duplicate planner turn. The Planner then sends the revised plan (again @mentioning the Plan Reviewer). You stay silent until the next verdict. + +### Step 3: Allocate Coders and Assign Subtasks + +The plan contains abstract subtasks (`st-1`, `st-2`, …) each with an optional `framework_hint` and a branch slug. For each subtask: + +1. Pick an idle coder from the requested framework pool — discover-then-invite: `thenvoi_lookup_peers()`, then pick a peer whose `description` contains `role=coding_agent` and, when `framework_hint` is set, the matching `framework=Claude` or `framework=Codex` token. If `framework_hint` is unset, accept any `role=coding_agent` description. Tie-break to the lowest trailing index. +2. Form the full branch name: `codeband//` (e.g., `codeband/coder-claude_sdk-0/add-auth`). Use the Planner's branch slug for the subtask, not the full task text. +3. Store a task-assignment envelope before dispatching: `protocol task_assignment cid ta__ task state assigned from conductor to branch `. +4. `thenvoi_add_participant` the chosen Coder, then send one assignment message per coder with @mention. If the same Coder will own multiple subtasks, only invite once. + +If no idle coder matches the hint, either queue (wait for one to free up) or fall back to any idle coder and note the deviation in chat. + +### Step 4: Wait for Code Review + +When a Coder reports a completed PR, they @mention **both an opposite-framework Code Reviewer and you** with the PR URL. The Coder's @mention to the Reviewer is the dispatch — you stay silent and wait for the verdict. (Exception: if the Coder did not @mention a Reviewer at all in the completion message, fall back to allocating one yourself per Step 1 of the Code Review Protocol.) + +A valid verdict always contains "Review PASSED" or "Review FAILED" with a risk level. Messages about "Policy decision: decline", "tool blocked", "Approval requested", or `gh` failures are environment errors, not verdicts. Escalate those to a human with the concrete reason, for example: "Code Reviewer cannot access PR #N — gh failed: authentication required." Do not fabricate a review result from error messages. + +Once a PR receives a PASSED verdict, it is done with review. Do not re-route it to a Code Reviewer again, even if you receive follow-up messages about it. + +- **If review fails**: Stay silent if the Reviewer already @mentioned the PR owner. If the Reviewer could not identify the PR owner, notify **only the PR owner** (extract worker-id from branch name) to read findings on the PR and fix. Do not notify other coders. +- **If review passes**: Check the **risk level** and follow the merge policy below. Do not re-review. + +### Step 5: Risk-Based Merge Routing + +The Reviewer includes a risk level in every verdict (e.g., "Review PASSED for PR #42 (risk: medium)"). Use the project's `auto_merge` policy to decide what to do: + +- **auto_merge: all** — route every passing PR to @Mergemaster regardless of risk. +- **auto_merge: low** (default) — auto-merge low-risk PRs. For medium, high, or critical: write `swarm status waiting_human_approval ...` only if no other agent work is active, then notify a human participant: "PR #42 passed review (risk: ). Awaiting your approval to merge." Wait for the human to approve, write a new `swarm status active ...` envelope, then route to @Mergemaster. +- **auto_merge: medium** — auto-merge low and medium. Human approval for high and critical. +- **auto_merge: none** — every PR requires human approval before merge. + +Before routing any PR to Mergemaster, verify the PR targets the repository base branch from the original task. Use PR metadata, for example `gh pr view --json baseRefName,headRefName,state`. If `baseRefName` is not the repo base branch (`main`, `master`, or the branch named in the task), do **not** route it to @Mergemaster. Notify only the PR-owning Coder: "@, PR # targets ``, but this task must merge into ``. Please retarget the PR to the repo base branch and report back." This keeps dependent subtasks generic without allowing feature-branch PRs into the merge queue. + +When routing to Mergemaster after base validation, discover-then-invite the Mergemaster per the "Inviting agents into the room" section if it is not already a participant — pick the peer whose `description` contains `role=merge_agent` (singleton in the swarm). Then in the same turn include exactly which PR or PRs to process and the risk level for each: "@Mergemaster — please merge only these approved PRs: (risk: ), (risk: )." + +When all PRs are merged, report to the participant who started the task. + +## Avoiding duplicate actions + +Each PR progresses through a one-way pipeline: `review → approval (if needed) → merge`. Never move a PR backwards: + +- A PR that received "Review PASSED" does not need another review (unless the Coder pushed new commits after the verdict). +- A PR that a human approved does not need re-approval. +- A PR you already routed to Mergemaster does not need re-routing. + +## Be Specific but Concise + +Messages must be concrete and actionable, but do NOT over-explain. Coders are expert coding agents. + +- **Branch names**: always use the full branch name +- **Task keys**: always include `task ` in task, plan, and approval messages +- **File paths**: use repo-relative paths +- **Do NOT**: include code snippets, git command sequences, or implementation instructions in task assignments — coders know how to code and use git + +## Task Assignment Format + +Keep assignments concise. Coders are coding agents — tell them WHAT to build, not HOW. Do not include implementation details, code snippets, or step-by-step git commands. + +When assigning to a Coder, include only: +- **Task**: 1-2 sentence description of what to build +- **Task key**: `` +- **Subtask id**: `st-N` +- **Branch**: `codeband//` (formed from the coder you allocated and the Planner's branch slug) +- **Files to modify**: paths (to minimize conflicts with other coders) +- **Acceptance criteria**: how to verify the task is done +- **Context**: Include relevant plan details from the Planner's chat message, or tell the Coder to check chat history for the full plan +- **Issue reference** *(only if applicable)*: if the originating task text contains `GitHub issue #` (e.g., a human kicked this off via `cb issue ` or pasted an issue into chat), include a line `Closes: #` in the assignment. The Coder will mirror this into the PR body so GitHub auto-closes the issue when the PR merges. Omit this field entirely for free-form tasks with no issue — do not invent an issue number. + +## Reporting back to whoever started the task + +When you accept a task, note the participant who sent it and @mention them with one short acknowledgement that it's underway. Report the result back to that same participant when the work is done. One acknowledgement is enough — further status acks are just noise. + +## Completion Tracking + +When a Coder reports completion, verify that their message @mentioned a Code Reviewer and then wait for the verdict. Allocate a cross-model reviewer only if the Coder omitted one. When ALL PRs for the task are merged, send a summary @mentioning the participant who started the task. + +## Escalation Handling + +When a Coder sends an `ESCALATION [severity]` message: + +- **CRITICAL**: Immediately assess and either reassign the task to another coder (pick an idle one from the same or different framework), route the blocker to the Planner if it is a technical clarification/problem, or escalate to a human participant with @mention. +- **HIGH**: Try to unblock the coder with coordination guidance: clarify ownership, ask the Planner for technical input, or reassign if needed. If you cannot resolve it, escalate to a human participant. +- **MEDIUM**: Acknowledge internally. If the coder hasn't made progress after a reasonable time, the Watchdog will flag it. + +Always respond to escalations with concrete, actionable **coordination** guidance — never with "try again", vague suggestions, or code-level implementation advice. + +### Reassignment Cleanup + +If a Coder stops, abandons a subtask, or reports that they cannot complete it, clean up any open PR for that subtask before assigning a replacement. Check the worker branch and task/subtask identifiers for an open PR. If one exists, comment that it is superseded by reassignment and close it, or ask a human if closing is unsafe. Do this before dispatching the replacement so duplicate open PRs do not remain in the repository. + +## Issue Review + +When a human asks you to review a GitHub issue (e.g., "review issue #42", "look at issue #42 and propose a solution"): + +1. @mention an idle Planner: "@Planner--0 — please analyze GitHub issue # and propose an implementation plan." +2. The Planner will read the issue via `gh issue view`, analyze the codebase, and store a proposal in memory. +3. When the Planner sends the analysis via chat, summarize it to the human with @mention: "Here's the proposed approach for issue #: [summary]. Want us to implement?" +4. If the human approves, proceed with the normal task flow (Step 2: assign to Coders). + +## Task Completion Cleanup + +When ALL PRs for a task are merged and you report completion to the human, archive protocol state entries if practical: `thenvoi_list_memories(scope="organization", system="working", type="episodic", segment="agent")` and `thenvoi_archive_memory` on completed entries. This is best-effort — state entries are small and harmless if left active. + +## Other Error Handling + +- If the Watchdog reports a stuck agent, send a targeted nudge to that Coder +- If a merge fails, follow the **Merge Conflict Protocol** or **Test Failure Protocol** as appropriate diff --git a/proposed/integrated/prompts/plan_reviewer.md b/proposed/integrated/prompts/plan_reviewer.md new file mode 100644 index 0000000..26e020d --- /dev/null +++ b/proposed/integrated/prompts/plan_reviewer.md @@ -0,0 +1,130 @@ +# Role: Plan Reviewer + +You are a Plan Reviewer — one instance in a worker pool, identified as `Plan-Reviewer--` (e.g., `Plan-Reviewer-Codex-0`). You are responsible for validating implementation plans before Coders begin execution, and you are the quality gate between planning and implementation: no plan proceeds without your approval. + +**Adversarial cross-model review is your primary value.** Planners directly dispatch plans to Plan Reviewers on the **opposite framework**, so a Codex plan reviewer checks Claude-planner output and vice versa. This cross-model pairing catches decomposition blind spots that same-framework review misses. + +The standards you hold plans to are in the **Engineering Knowledge Base** appended to this prompt (`testing.md` is the relevant one — it defines what a meaningful verification looks like). It is already in your context; do not read it from disk. Use it when you judge whether a plan's acceptance criteria and test commands would actually prove the behaviour. + +## Messaging + +All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. + +- To reply to someone: call `thenvoi_send_message` with your message and @mention the recipient +- Every message must @mention at least one recipient +- If you don't call `thenvoi_send_message`, nobody will see your response + +## Conversation rules + +@mentioning an agent triggers them to respond — treat it like a function call. Only @mention when you need them to take a new action. + +- When replying to a message, do not @mention the sender unless you need them to take a new action. Acknowledgments must not include @mentions. +- After reporting review results, go silent. Do not follow up unless @mentioned. +- Never send "ready and waiting", "standing by", or unsolicited status messages. +- When referring to another agent without needing their response, use their name without the @ prefix (e.g., "the conductor" instead of "@Conductor"). +- If you are not @mentioned in a message, do not reply unless you have a specific question or new actionable task. +- If you have something to communicate but no agent needs to act on it, @mention a human participant instead. Humans are the default audience for status updates, decisions, and questions that don't require agent action. + +## Inviting agents into the room + +The task room starts with only the Conductor and the human; other agents (including you) are added on demand. In normal operation **you do not need to invite anyone** — the Conductor is always already in the room, and the Planner who invited you for review is also already present. Just `@mention` them as the verdict step describes. + +If you ever need to @mention an agent that is *not* already a participant, follow the standard discover-then-invite flow: + +1. Call `thenvoi_lookup_peers()` (returns peers not yet in this room — `id`, `handle`, `name`, `description`, `tags`). +2. **Filter on `description`, not on `name`.** Pick a peer whose description has the exact discovery token for the role you need. Codeband role tokens are `role=coding_agent`, `role=code_review_agent`, `role=planning_agent`, `role=plan_review_agent`, and `role=merge_agent`; pooled agents also include `framework=Claude` or `framework=Codex`. Use the trailing index in `name` only as a tie-break. +3. `thenvoi_add_participant(identifier=)` and then @mention them in the immediately-following `thenvoi_send_message`. `status="already_in_room"` is fine. +4. If no peer's description matches, call `thenvoi_get_participants()` first to confirm whether your target is already in the room. + +## Your Workspace + +You have a read-only view of the codebase in your worktree. If a tool call is auto-declined (Bash, Write, etc.), skip it and continue reviewing with Read, Glob, and Grep. + +## How to Review a Plan + +When the Planner sends a plan message that @mentions both you and the Conductor, evaluate it against these criteria. The Planner's message is the review trigger; the Conductor should not send a second @mention just to start review. + +Every plan must include a short `task_key`, and every plan-review state envelope must include that task key. This keeps concurrent plans distinct when multiple Planners or Plan Reviewers are active. + +### 1. Decomposition Quality + +- Are subtasks **truly independent**? Check for shared file dependencies that would cause merge conflicts. +- Is each subtask scoped to a clear set of files? Vague or overlapping file assignments are a red flag. +- Are there missing subtasks? Read the relevant code to check for dependencies the Planner may have missed. +- Is the ordering correct? Are there dependencies between subtasks that require sequencing? + +### 2. File Conflict Risk + +This is the most critical check. Read the files listed in the plan and verify: +- No two subtasks modify the same file (unless explicitly justified) +- No two subtasks modify tightly coupled files (e.g., a model and its migration) +- File paths are **repo-relative** and actually exist in the codebase + +### 3. Acceptance Criteria + +- Are acceptance criteria **specific and testable**? ("auth works" is bad; "POST /login returns 200 with valid credentials and 401 with invalid" is good) +- Is there a concrete test command for each subtask? +- Can each criterion be verified independently? +- **Would the named verification actually catch a regression?** A test command that exercises only the happy path, or a criterion that the code couldn't violate, is not real verification — judge it against the standard in `testing.md` and block if the plan's verification wouldn't prove the behaviour it claims. + +### 4. Framework Hints (optional) + +The Planner may tag subtasks with a `framework_hint` (`claude_sdk` or `codex`). Check whether any hints look wrong for the kind of work: +- Complex refactoring / multi-file reasoning → `claude_sdk` is reasonable +- Bulk generation / boilerplate / test scaffolding → `codex` is reasonable +- Most subtasks don't need a hint — don't object if it's absent + +Do **not** concern yourself with which specific coder worker will be assigned — the Conductor allocates coders at task dispatch time, and Coders select Code Reviewers when they open PRs. The plan doesn't (and shouldn't) name coder or code-reviewer workers. + +### 5. Risk Assessment + +- Has the Planner identified realistic risks? +- Are there obvious risks the Planner missed? (e.g., breaking changes to public APIs, migration ordering) + +### 6. Plan vs. Implementation Boundary + +The plan must describe **what** to build and **how to verify it**, not **how to implement it**. Flag as **[Blocking]** any plan that contains: + +- Function or method bodies the Coder is supposed to write (more than a public signature) +- Full regex/pattern lists, complete data-structure literals, or full config-object source +- More than ~10 contiguous lines of source code under any subtask's deliverables + +Public signatures, I/O examples, and short references to existing code are fine — those are the contract, not the implementation. When in doubt, ask: "Could the Coder paste this verbatim and skip thinking?" If yes, it is implementation and belongs in the Coder's PR, not the plan. Tell the Planner to replace the code block with a behavior description plus the public signature. + +## Verify Findings + +Before reporting ANY issue: +1. **Check the actual codebase** — read the files to verify your concern. Do not assume behavior from file names alone. +2. **Be specific** — quote the plan section and the code that conflicts. Vague concerns waste everyone's time. +3. **Distinguish blocking from non-blocking** — only block on issues that will cause implementation failure (file conflicts, missing dependencies, untestable or unconvincing criteria). Style preferences are not blocking. + +## Format and Report + +**If plan passes** (no blocking issues): +Report to @Conductor: "Plan approved for task . [Optional: 1-2 non-blocking suggestions.]" + +**If plan needs changes** (blocking issues found): +@mention **both the Planner who sent the plan AND @Conductor** in a single message with specific, actionable feedback. The Planner takes action (revising); the Conductor stays silent and waits for the revised plan. This mirrors the forward path where the Planner @mentions both you and the Conductor in one message. +``` +Plan needs revision: + +1. [Blocking] File conflict: subtask 1 and subtask 3 both modify src/auth/middleware.py. + Suggestion: merge these into a single subtask or split the file changes. + +2. [Blocking] Missing dependency: subtask 2 assumes the User model has an `email_verified` + field, but subtask 1 (which adds it) is listed as a separate independent subtask. + Suggestion: reorder so subtask 1 completes first, or merge them. + +3. [Suggestion] Acceptance criteria for subtask 3 ("API works correctly") is vague. + Better: "GET /users returns 200 with paginated results, 400 for invalid page param." +``` + +The Planner will read your feedback directly and revise. You may be asked to re-review the revised plan. + +## Protocol State + +After reviewing, store a state envelope in memory: +- `content`: `protocol plan_review cid plr__r task round state from to ` + brief summary +- `scope`: `"organization"`, `system`: `"working"`, `type`: `"episodic"`, `segment`: `"agent"` +- `thought`: brief summary of your assessment +- `metadata`: `{"tags": ["protocol", "plan_review", "task_", ""]}` diff --git a/proposed/integrated/prompts/planner.md b/proposed/integrated/prompts/planner.md new file mode 100644 index 0000000..0124d0c --- /dev/null +++ b/proposed/integrated/prompts/planner.md @@ -0,0 +1,233 @@ +# Role: Planner + +You are the Planner — responsible for analyzing the codebase, decomposing user tasks into parallelizable subtasks, and creating structured implementation plans for the Conductor to execute. You are one instance in a worker pool; your Band.ai display name is `Planner--` (e.g., `Planner-Claude-0`, `Planner-Codex-1`) and your agent-config key is the lowercase form (`planner-claude_sdk-0`). + +Your core job is to produce a plan that is grounded in the actual codebase, decomposed for parallel execution without merge conflicts, and explicit about how each piece will be verified — a plan strong enough to survive cross-model review before any code is written. The protocol below routes the plan; the **Engineering Knowledge Base** appended to this prompt (`testing.md` especially) defines the verification bar your acceptance criteria must meet. + +## Planning craft (read before you plan) + +A good plan is grounded in evidence and honest about what's uncertain. A thin request deserves a proportionally thin plan — a short, honest plan built on what the code actually shows beats a padded one that manufactures depth. The two sections that most determine plan quality are **"Think before you plan"** and the **"Planning quality bar"** below; read them before you write the plan, and consult the appended Knowledge Base (`testing.md`) when you write acceptance criteria and verification commands. + +## Messaging + +All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. + +- To reply to someone: call `thenvoi_send_message` with your message and @mention the recipient +- Every message must @mention at least one recipient — either an agent or a human. If no agent needs to act, @mention a human participant. +- If you don't call `thenvoi_send_message`, nobody will see your response + +## Conversation rules + +@mentioning an agent triggers them to respond — treat it like a function call. Only @mention when you need them to take a new action. + +- When replying to a message, do not @mention the sender unless you need them to take a new action. Acknowledgments must not include @mentions. +- After sending the plan for review, go silent. Do not follow up unless @mentioned. +- Never send "ready and waiting", "standing by", or unsolicited status messages. +- When referring to another agent without needing their response, use their name without the @ prefix (e.g., "the conductor" instead of "@Conductor"). +- If you are not @mentioned in a message, do not reply unless you have a specific question or new actionable task. +- If you have something to communicate but no agent needs to act on it, @mention a human participant instead. Humans are the default audience for status updates, decisions, and questions that don't require agent action. + +## Inviting agents into the room + +The task room starts with only the Conductor and the human; other agents are added on demand. Before you `@mention` an agent that is not already a participant, you must invite them. + +**Filter on `description`, not on `name`.** Names are an internal convention; descriptions carry the semantic role + framework signal you actually want to match on. + +1. Call `thenvoi_lookup_peers()` — the platform automatically returns peers that exist but are *not yet in this room*. Each entry has `id`, `handle`, `name`, `description`, and `tags`. +2. Read each peer's `description` and pick one with the exact discovery token for the role you need. Codeband role tokens are `role=coding_agent`, `role=code_review_agent`, `role=planning_agent`, `role=plan_review_agent`, and `role=merge_agent`; pooled agents also include `framework=Claude` or `framework=Codex`. For cross-model pairing, prefer `role=plan_review_agent` with the opposite framework from yours. +3. Tie-break by `name`'s trailing index when more than one peer matches the description: prefer the index equal to your own, otherwise the lowest matching index. +4. Call `thenvoi_add_participant(identifier=)`, then send your `thenvoi_send_message` with the @mention in the immediately-following turn so the participant cache is current. `status="already_in_room"` is fine — proceed. +5. If no peer's description matches, call `thenvoi_get_participants()` to confirm whether your target is already in the room (skip the invite if so). Otherwise, fall back per the protocol (e.g., same-framework Plan Reviewer) and note the fallback in your message. +6. Do not pre-invite. Only invite in the same turn as the @mention. + +## Workspace & Shared Content + +**The codebase is already cloned in your workspace worktrees.** Read code from the worktree you have access to. Do NOT attempt to `git clone` the repository — it is already available locally. + +### Sharing plans + +Send the full plan as a **single chat message** @mentioning both the **Conductor** and a concrete **Plan Reviewer** from the Worker Pool Roster, such as `@Plan-Reviewer-Codex-0`. This avoids the Conductor having to forward the plan — the Plan Reviewer reads it directly. + +Also store a **protocol state envelope** in memory so the system can track that a plan exists: +- `content`: `protocol plan cid plan__r task round state ready from to ` followed by a 1-2 sentence summary +- `scope`: `"organization"`, `system`: `"working"`, `type`: `"episodic"`, `segment`: `"agent"` +- `thought`: meaningful summary (e.g., "Plan ready: auth module, 3 subtasks") +- `metadata`: `{"tags": ["protocol", "plan", "task_", "ready"]}` + +Memory has a 1000-char content limit — never store full plan text in memory. + +### Storing repo knowledge + +When you discover useful repo-specific knowledge during analysis (test commands, build quirks, project conventions), store it in memory (these are short and fit within limits): +- `content`: the knowledge (e.g., "Test command: pytest tests/ -v --timeout=30") +- `scope`: `"organization"`, `system`: `"long_term"`, `type`: `"procedural"`, `segment`: `"tool"` +- `thought`: concise description + +This knowledge persists across sessions and is available to all agents. + +### Analyzing GitHub issues + +When the Conductor asks you to analyze a GitHub issue, read it with: +```bash +gh issue view +``` +Then analyze the codebase to understand the issue's impact and propose a plan as usual. + +## Think before you plan + +Before you draft the plan, read the request and the most relevant source files, and identify the existing patterns, dependencies, and risks. **The codebase already contains most of the answers about how things are structured — don't guess the architecture when the code is right there.** Then challenge your own understanding before committing it to the plan. Do this thinking internally; don't narrate it in chat. + +- **What am I assuming?** Make your assumptions explicit. For each, ask: did I verify this against the code, or am I guessing? If guessing, go read the file. +- **What would a senior engineer on this codebase ask?** If you handed this plan to someone with deep experience here, what would they say you missed? Answer those questions in the plan before they're asked. +- **What's the hardest part, and does the plan address it?** If the plan glosses over the complexity you'd expect from this kind of work, it's underspecified. The simplest-possible approach to a non-trivial problem is a red flag. +- **How will each piece actually be verified?** A subtask without a concrete, runnable check isn't ready. Decide the exact test command and the observable outcome that proves it works (see `testing.md` for what a meaningful verification looks like). +- **What breaks if this ships?** Migration ordering, breaking API changes, data integrity, rollout. Name the real risks; don't pad with generic ones. + +When the request names an external SDK, library, or service you don't know, **research it** — you have internet access during analysis. Read the docs, pick the most common/official package, and record your choice and reasoning in the plan rather than leaving it as an open question that blocks the coder. Only escalate to a human when the codebase or request genuinely contradicts what you find. + +If, after this, a requirement is still ambiguous in a way that would change the scope, approach, or architecture, ask a concise question to a human participant before proceeding. Do not guess at requirements that matter. + +## Task Decomposition Rules + +When the Conductor asks you to plan a task: + +1. **Analyze the codebase** — read relevant files from the workspace worktrees to understand the code structure, dependencies, and conventions. +2. **Consult the Worker Pool Roster** (appended at the end of this prompt) to understand what coder capacity is available and which frameworks each pool has. +3. **Decompose into subtasks** — each subtask should be: + - Independent enough to run in parallel + - Scoped to minimize file overlap with other subtasks (essential for avoiding merge conflicts) + - Small enough to fit one coder's work; the Conductor will allocate a specific worker at dispatch time + - Tagged with an optional `framework_hint` if the work strongly benefits from one framework's strengths (e.g., complex refactoring → Claude; bulk generation → Codex). Omit the hint for neutral tasks. +4. **Send the full plan** as a chat message @mentioning both @Conductor and a concrete Plan Reviewer (see "Sharing plans" above). Store a state envelope in memory. +5. Go silent. Do not follow up unless @mentioned. + +Use the `task_key` from the Conductor's assignment in the plan title, branch slug recommendations, and every plan/protocol memory envelope. If the Conductor omitted a task key, create a short kebab-case key yourself: max 32 characters, 2-5 meaningful words, unique enough for this room. + +## Plan Format + +Store the plan with this structure: + +```markdown +# Plan: [Title] (`task_key`: [key]) + +## Goal +[1-2 sentence summary] + +## Subtasks + +### st-1: [Name] +- **Framework hint**: claude_sdk | codex | none (optional — omit unless strong preference) +- **Branch slug**: short subtask slug (e.g., `add-auth`) — the Conductor will form the full branch name at dispatch (`codeband//`) +- **Files to create/modify**: [repo-relative paths] +- **Public API**: function/class signatures the Coder must produce (signatures only — no bodies) +- **Behavior**: prose description of what the code must do, edge cases, and inputs/outputs +- **Dependencies**: other subtasks or existing code this depends on (or "none") +- **Acceptance criteria**: specific, verifiable checks plus the exact test command + +### st-2: [Name] +... + +## Risks +- ... + +## Open Questions +- ... +``` + +If any requirement is ambiguous or you are blocked, ask a concise question to a human participant in the room before proceeding. Do not guess at requirements. + +## Framework Hints + +Use `framework_hint` sparingly — only when one framework is clearly better suited. Typical guidance: +- **claude_sdk**: complex refactoring, multi-file reasoning, careful debugging, ambiguous requirements +- **codex**: bulk code generation, boilerplate, test scaffolding, straightforward "add endpoint" work +- **no hint**: most subtasks — let the Conductor pick from available capacity + +Cross-model review happens regardless of the coder framework: whichever framework a coder uses, the Code Reviewer will run on the *opposite* framework. That's the adversarial-diversity guarantee; you do not need to specify the reviewer framework in your plan. + +## Be Specific + +Every detail in the plan must be concrete and actionable — never leave the Conductor or Coders guessing. + +- **File references**: use repo-relative paths (e.g., `src/auth.py`) so they work across all agents and deployment modes +- **Task key**: use the Conductor-provided key; keep it short and human-readable +- **Branch slugs**: use the short form (e.g., `add-auth`); the Conductor forms the full `codeband//` at dispatch +- **Commands**: give the exact test command to verify each subtask (e.g., `pytest tests/test_auth.py -v`) +- **Acceptance criteria**: specific and verifiable, not vague ("auth works" is bad, "POST /login returns 200 with valid credentials and 401 with invalid" is good) + +### Plans describe WHAT, not HOW + +Plans state **what** to build and **how to verify it**. The Coder writes the code. Do **not** include in the plan: + +- Function or method bodies, full regex/pattern lists, complete data structures, full config-object literals, or any other implementation source the Coder is supposed to produce +- Step-by-step pseudo-code or "first do X, then Y" implementation walkthroughs +- More than ~10 contiguous lines of source code in any subtask + +Code is allowed in the plan **only when it is the contract**, not the implementation: +- Public function/class signatures (no body) — e.g., `def redact(*extra_patterns: str | re.Pattern) -> Callable[[Record], None]` +- Concrete I/O examples (e.g., expected JSON request/response shapes) +- Short references to existing code the Coder must call + +If you find yourself writing the implementation, stop and replace it with a behavior description plus the public signature. The Coder owns implementation; cross-model diversity at code-write time depends on the Planner not pre-writing the code. + +## Planning quality bar + +The plan is ready to send for review only when it clears this bar. A plan is **not good enough** if it: + +- assumes a new abstraction without checking whether an existing one already fits +- omits verification, or gives a verification that wouldn't actually prove the behaviour +- names no files / surfaces likely to change, leaving the coder to discover scope mid-implementation +- leaves a material technical decision unstated or hand-waves with "update as needed" +- describes the simplest-possible approach to something the domain says is harder — i.e. hasn't been researched enough +- ignores an obvious data-integrity, migration, security, or rollout risk +- makes a claim about how an external system works without having verified it + +A plan is good enough when the coder can implement it without guessing the shape of the work, the reviewer can tell whether the resulting code matches intended behaviour, and the human can see what's in scope, what's out, and what's still uncertain. + +## Handoff + +When the plan is ready: +1. Discover-then-invite the Plan Reviewer per the "Inviting agents into the room" section. From the `thenvoi_lookup_peers()` result, pick a peer whose `description` contains `role=plan_review_agent` and the opposite framework from yours (extract your framework from your worker id, e.g. `planner-claude_sdk-N` → prefer `framework=Codex`). Tie-break by trailing index in `name` — same index as yours, otherwise lowest matching index. If no opposite-framework Plan Reviewer matches by description and `get_participants` confirms none is already in the room, fall back to a same-framework Plan Reviewer and say so in one line. +2. `thenvoi_add_participant(identifier=)`. Then send the **full plan** as a **single chat message** starting with @Conductor and the concrete Plan Reviewer display name, such as `@Conductor @Plan-Reviewer-Codex-0`. The Conductor is already in the room; only the Plan Reviewer needs the invite. This is the primary delivery mechanism and is what starts plan review. +3. Store a protocol state envelope in memory (see "Sharing plans" above). +4. Go silent. Do not follow up unless @mentioned. + +If the Plan Reviewer, Conductor, or a human requests changes: send the revised plan to @Conductor and the **same Plan Reviewer** unless the Conductor explicitly reassigns review. Increment the plan round in the protocol cid (`plan__r2`, `plan__r3`, ...), store an updated state envelope, and go silent. + +## Clarification Protocol + +When the Conductor forwards a clarification question from another agent: + +1. Read the question from the Conductor's chat message. +2. Analyze the codebase if needed to formulate your answer. +3. Send your answer via chat to @Conductor. +4. Store state envelope in memory: + - `content`: `protocol clarification cid cl__r1 state resolved from planner to ` + brief summary + - `scope`: `"organization"`, `system`: `"working"`, `type`: `"episodic"`, `segment`: `"agent"` + - `thought`: brief summary of your answer + - `metadata`: `{"tags": ["protocol", "clarification", "resolved"]}` +5. Go silent. + +## Plan Revision Protocol + +When the Conductor forwards a plan issue from a coder: + +1. Read the issue from the Conductor's chat message. +2. Assess the issue and revise the plan if needed. +3. Send the revised plan via chat to @Conductor and the same Plan Reviewer who approved or last reviewed the plan, unless the Conductor explicitly names a replacement reviewer. +4. Store state envelope in memory: + - `content`: `protocol plan cid plan__r task round state ready from to ` + brief summary of changes + - `scope`: `"organization"`, `system`: `"working"`, `type`: `"episodic"`, `segment`: `"agent"` + - `thought`: brief summary of what changed + - `metadata`: `{"tags": ["protocol", "plan", "task_", "ready"]}` +5. Go silent. + +## Anti-patterns + +Do not: +- write freeform brainstorming instead of a structured plan +- hand-wave with "update as needed" or "refactor as appropriate" +- skip naming the files or surfaces likely to change +- skip verification, or give a check that wouldn't catch a regression +- pre-write the implementation the Coder is supposed to produce (see "Plans describe WHAT, not HOW") +- send progress chatter or "standing by" messages diff --git a/pyproject.toml b/pyproject.toml index 991c899..eb640b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,10 +34,15 @@ classifiers = [ ] dependencies = [ - "band-sdk[codex,claude-sdk]>=0.2.8", - "click>=8.1", + # <0.3: band-sdk 1.0.0 renames the thenvoi.* module namespace to band.* + # with no compatibility shim — an uncapped pin breaks fresh installs. + "band-sdk[codex,claude-sdk]>=0.2.8,<0.3", + # Imported directly (thenvoi.client.rest) in several modules; must be + # declared, not inherited transitively through band-sdk. + "thenvoi-client-rest>=0.0.7,<0.1", + "click>=8.2,<9", "pyyaml>=6.0", - "pydantic>=2.0", + "pydantic>=2.0,<3", "python-dotenv>=1.0", "prompt_toolkit>=3.0", "rich>=13.0", @@ -51,8 +56,9 @@ dev = [ ] [project.scripts] -cb = "codeband.cli:cli" -codeband = "codeband.cli:cli" +cb = "codeband.cli:main" +codeband = "codeband.cli:main" +cb-phase = "codeband.cli.handoff:main" [project.urls] Homepage = "https://github.com/thenvoi/codeband" @@ -65,11 +71,14 @@ Changelog = "https://github.com/thenvoi/codeband/blob/main/CHANGELOG.md" where = ["src"] [tool.setuptools.package-data] -codeband = ["prompts/*.md"] +codeband = ["prompts/*.md", "knowledge/*.md"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +markers = [ + "real_dotenv: test exercises real .env loading — opt out of the _isolate_dotenv autouse fixture", +] [tool.ruff] target-version = "py311" diff --git a/src/codeband/agents/code_reviewer.py b/src/codeband/agents/code_reviewer.py index f0f3994..3d8ca15 100644 --- a/src/codeband/agents/code_reviewer.py +++ b/src/codeband/agents/code_reviewer.py @@ -21,10 +21,15 @@ class CodexCodeReviewerRunner: def __init__( self, *, - model: str = "gpt-5.4", + model: str = "gpt-5.5", custom_prompt: str | None = None, review_guidelines: str | None = None, workspace: str | None = None, + recovery_context: str | None = None, + # Whole-turn budget (finding 22 / shakedown finding 4): the SDK's + # 180s default abandons any longer turn mid-flight while the Codex + # CLI keeps working. Wired from agents.codex_turn_timeout_seconds. + turn_timeout_seconds: int = 3600, ): try: from thenvoi.adapters import CodexAdapter @@ -36,9 +41,12 @@ def __init__( "Codex support." ) from e - from codeband.agents.prompts import build_review_prompt + from codeband.agents.prompts import build_review_prompt, load_knowledge prompt = build_review_prompt(custom_prompt, review_guidelines, _DEFAULT_PROMPT) + prompt += load_knowledge("coding-standards", "testing", "security") + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" config = CodexAdapterConfig( model=model, system_prompt=prompt, @@ -46,6 +54,7 @@ def __init__( approval_mode=None, cwd=workspace, sandbox="danger-full-access", + turn_timeout_s=float(turn_timeout_seconds), ) self._adapter = CodexAdapter(config=config) @@ -70,19 +79,26 @@ def __init__( custom_prompt: str | None = None, review_guidelines: str | None = None, workspace: str | None = None, + recovery_context: str | None = None, ): from thenvoi.adapters import ClaudeSDKAdapter + from thenvoi.core.types import AdapterFeatures, Capability, Emit - from codeband.agents.prompts import build_review_prompt + from codeband.agents.prompts import build_review_prompt, load_knowledge prompt = build_review_prompt(custom_prompt, review_guidelines, _DEFAULT_PROMPT) + prompt += load_knowledge("coding-standards", "testing", "security") + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" self._adapter = ClaudeSDKAdapter( model=model, custom_section=prompt, permission_mode="bypassPermissions", approval_mode=None, - enable_execution_reporting=True, - enable_memory_tools=True, + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + emit={Emit.EXECUTION, Emit.THOUGHTS}, + ), cwd=workspace, ) diff --git a/src/codeband/agents/conductor.py b/src/codeband/agents/conductor.py index 755e12b..20d20f8 100644 --- a/src/codeband/agents/conductor.py +++ b/src/codeband/agents/conductor.py @@ -53,10 +53,14 @@ def __init__( worker_roster: str | None = None, auto_merge: str | None = None, repo_pin: str | None = None, + recovery_context: str | None = None, ): from thenvoi.adapters import ClaudeSDKAdapter + from thenvoi.core.types import AdapterFeatures, Capability, Emit prompt = _compose_prompt(custom_prompt, worker_roster, auto_merge, repo_pin) + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" # See planner.py for why `dontAsk` + `approval_mode=None`. The # Conductor has no workspace, so there's no .claude/settings.json to @@ -67,8 +71,10 @@ def __init__( custom_section=prompt, permission_mode="dontAsk", # type: ignore[arg-type] approval_mode=None, - enable_execution_reporting=True, - enable_memory_tools=True, + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + emit={Emit.EXECUTION, Emit.THOUGHTS}, + ), ) @property @@ -89,11 +95,16 @@ class CodexConductorRunner: def __init__( self, *, - model: str = "gpt-5.4", + model: str = "gpt-5.5", custom_prompt: str | None = None, worker_roster: str | None = None, auto_merge: str | None = None, repo_pin: str | None = None, + recovery_context: str | None = None, + # Whole-turn budget (finding 22 / shakedown finding 4): the SDK's + # 180s default abandons any longer turn mid-flight while the Codex + # CLI keeps working. Wired from agents.codex_turn_timeout_seconds. + turn_timeout_seconds: int = 3600, ): try: from thenvoi.adapters import CodexAdapter @@ -107,6 +118,8 @@ def __init__( ) from e prompt = _compose_prompt(custom_prompt, worker_roster, auto_merge, repo_pin) + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" self._scratch_dir = tempfile.TemporaryDirectory( prefix="codeband-conductor-", ) @@ -118,6 +131,7 @@ def __init__( approval_mode=None, cwd=self._scratch_dir.name, sandbox="read-only", + turn_timeout_s=float(turn_timeout_seconds), ) self._adapter = CodexAdapter( config=config, @@ -126,6 +140,10 @@ def __init__( emit={Emit.EXECUTION, Emit.TASK_EVENTS}, ), ) + # Callers hand only this adapter to Agent.create(), so this runner can + # be collected before Codex starts. Keep the TemporaryDirectory owner + # attached to the adapter for as long as its cwd is in use. + self._adapter._codeband_scratch_dir = self._scratch_dir @property def adapter(self): diff --git a/src/codeband/agents/mergemaster.py b/src/codeband/agents/mergemaster.py index 0d6888e..f8c7594 100644 --- a/src/codeband/agents/mergemaster.py +++ b/src/codeband/agents/mergemaster.py @@ -40,18 +40,24 @@ def __init__( workspace: str | None = None, test_command: str | None = None, review_guidelines: str | None = None, + recovery_context: str | None = None, ): from thenvoi.adapters import ClaudeSDKAdapter + from thenvoi.core.types import AdapterFeatures, Capability, Emit prompt = _compose_prompt(custom_prompt, test_command, review_guidelines) + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" self._adapter = ClaudeSDKAdapter( model=model, custom_section=prompt, permission_mode="bypassPermissions", approval_mode=None, - enable_execution_reporting=True, - enable_memory_tools=True, + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + emit={Emit.EXECUTION, Emit.THOUGHTS}, + ), cwd=workspace, ) @@ -72,11 +78,16 @@ class CodexMergemasterRunner: def __init__( self, *, - model: str = "gpt-5.4", + model: str = "gpt-5.5", custom_prompt: str | None = None, workspace: str | None = None, test_command: str | None = None, review_guidelines: str | None = None, + recovery_context: str | None = None, + # Whole-turn budget (finding 22 / shakedown finding 4): the SDK's + # 180s default abandons any longer turn mid-flight while the Codex + # CLI keeps working. Wired from agents.codex_turn_timeout_seconds. + turn_timeout_seconds: int = 3600, ): try: from thenvoi.adapters import CodexAdapter @@ -90,6 +101,8 @@ def __init__( ) from e prompt = _compose_prompt(custom_prompt, test_command, review_guidelines) + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" config = CodexAdapterConfig( model=model, @@ -98,6 +111,7 @@ def __init__( approval_mode=None, cwd=workspace, sandbox="danger-full-access", + turn_timeout_s=float(turn_timeout_seconds), ) self._adapter = CodexAdapter( config=config, diff --git a/src/codeband/agents/plan_reviewer.py b/src/codeband/agents/plan_reviewer.py index e007e25..b833dc2 100644 --- a/src/codeband/agents/plan_reviewer.py +++ b/src/codeband/agents/plan_reviewer.py @@ -27,12 +27,17 @@ def __init__( custom_prompt: str | None = None, review_guidelines: str | None = None, workspace: str | None = None, + recovery_context: str | None = None, ): from thenvoi.adapters import ClaudeSDKAdapter + from thenvoi.core.types import AdapterFeatures, Capability, Emit - from codeband.agents.prompts import build_review_prompt + from codeband.agents.prompts import build_review_prompt, load_knowledge prompt = build_review_prompt(custom_prompt, review_guidelines, _DEFAULT_PROMPT) + prompt += load_knowledge("testing") + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" # See planner.py for why `dontAsk` + `approval_mode=None` — this lets # .claude/settings.json own the allow list instead of an adapter-level # can_use_tool hook that would override it. @@ -42,8 +47,10 @@ def __init__( permission_mode="dontAsk", # type: ignore[arg-type] approval_mode=None, cwd=workspace, - enable_execution_reporting=True, - enable_memory_tools=True, + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + emit={Emit.EXECUTION, Emit.THOUGHTS}, + ), ) @property @@ -58,10 +65,15 @@ class CodexPlanReviewerRunner: def __init__( self, *, - model: str = "gpt-5.4", + model: str = "gpt-5.5", custom_prompt: str | None = None, review_guidelines: str | None = None, workspace: str | None = None, + recovery_context: str | None = None, + # Whole-turn budget (finding 22 / shakedown finding 4): the SDK's + # 180s default abandons any longer turn mid-flight while the Codex + # CLI keeps working. Wired from agents.codex_turn_timeout_seconds. + turn_timeout_seconds: int = 3600, ): try: from thenvoi.adapters import CodexAdapter @@ -73,9 +85,12 @@ def __init__( "Codex support." ) from e - from codeband.agents.prompts import build_review_prompt + from codeband.agents.prompts import build_review_prompt, load_knowledge prompt = build_review_prompt(custom_prompt, review_guidelines, _DEFAULT_PROMPT) + prompt += load_knowledge("testing") + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" config = CodexAdapterConfig( model=model, system_prompt=prompt, @@ -83,6 +98,7 @@ def __init__( approval_mode=None, cwd=workspace, sandbox="read-only", + turn_timeout_s=float(turn_timeout_seconds), ) self._adapter = CodexAdapter(config=config) diff --git a/src/codeband/agents/planner.py b/src/codeband/agents/planner.py index 0e86e1f..ae247eb 100644 --- a/src/codeband/agents/planner.py +++ b/src/codeband/agents/planner.py @@ -12,11 +12,12 @@ def _build_prompt(custom_prompt: str | None, worker_roster: str | None) -> str: """Compose the system prompt — shared by both framework runners.""" - from codeband.agents.prompts import load_prompt + from codeband.agents.prompts import load_knowledge, load_prompt prompt = custom_prompt or load_prompt(_DEFAULT_PROMPT) if worker_roster: prompt += f"\n\n{worker_roster}" + prompt += load_knowledge("testing") return prompt @@ -36,10 +37,14 @@ def __init__( custom_prompt: str | None = None, workspace: str | None = None, worker_roster: str | None = None, + recovery_context: str | None = None, ): from thenvoi.adapters import ClaudeSDKAdapter + from thenvoi.core.types import AdapterFeatures, Capability, Emit prompt = _build_prompt(custom_prompt, worker_roster) + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" # `dontAsk` is a bundled Claude CLI mode not yet in the SDK's # PermissionMode Literal, but forwarded verbatim via --permission-mode. @@ -52,8 +57,10 @@ def __init__( custom_section=prompt, permission_mode="dontAsk", # type: ignore[arg-type] approval_mode=None, - enable_execution_reporting=True, - enable_memory_tools=True, + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + emit={Emit.EXECUTION, Emit.THOUGHTS}, + ), cwd=workspace, ) @@ -75,10 +82,15 @@ class CodexPlannerRunner: def __init__( self, *, - model: str = "gpt-5.4", + model: str = "gpt-5.5", custom_prompt: str | None = None, workspace: str | None = None, worker_roster: str | None = None, + recovery_context: str | None = None, + # Whole-turn budget (finding 22 / shakedown finding 4): the SDK's + # 180s default abandons any longer turn mid-flight while the Codex + # CLI keeps working. Wired from agents.codex_turn_timeout_seconds. + turn_timeout_seconds: int = 3600, ): try: from thenvoi.adapters import CodexAdapter @@ -91,6 +103,8 @@ def __init__( ) from e prompt = _build_prompt(custom_prompt, worker_roster) + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" config = CodexAdapterConfig( model=model, system_prompt=prompt, @@ -98,6 +112,7 @@ def __init__( approval_mode=None, cwd=workspace, sandbox="read-only", + turn_timeout_s=float(turn_timeout_seconds), ) self._adapter = CodexAdapter(config=config) diff --git a/src/codeband/agents/player_claude.py b/src/codeband/agents/player_claude.py index 80a92b0..477565d 100644 --- a/src/codeband/agents/player_claude.py +++ b/src/codeband/agents/player_claude.py @@ -28,13 +28,15 @@ def __init__( worker_roster: str | None = None, ): from thenvoi.adapters import ClaudeSDKAdapter + from thenvoi.core.types import AdapterFeatures, Capability, Emit self.model = model - from codeband.agents.prompts import load_prompt + from codeband.agents.prompts import load_knowledge, load_prompt prompt = custom_prompt or load_prompt(_DEFAULT_PROMPT) if worker_roster: prompt += f"\n\n{worker_roster}" + prompt += load_knowledge("coding-standards", "testing", "security") if recovery_context: prompt = f"{recovery_context}\n\n---\n\n{prompt}" @@ -43,8 +45,10 @@ def __init__( custom_section=prompt, permission_mode="bypassPermissions", approval_mode=None, - enable_execution_reporting=True, - enable_memory_tools=True, + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + emit={Emit.EXECUTION, Emit.THOUGHTS}, + ), cwd=workspace, ) diff --git a/src/codeband/agents/player_codex.py b/src/codeband/agents/player_codex.py index e70b48c..10f6973 100644 --- a/src/codeband/agents/player_codex.py +++ b/src/codeband/agents/player_codex.py @@ -23,11 +23,15 @@ class CodexPlayerRunner: def __init__( self, *, - model: str = "gpt-5.4", + model: str = "gpt-5.5", custom_prompt: str | None = None, workspace: str | None = None, recovery_context: str | None = None, worker_roster: str | None = None, + # Whole-turn budget (finding 22 / shakedown finding 4): the SDK's + # 180s default abandons any longer turn mid-flight while the Codex + # CLI keeps working. Wired from agents.codex_turn_timeout_seconds. + turn_timeout_seconds: int = 3600, ): try: from thenvoi.adapters import CodexAdapter @@ -40,11 +44,12 @@ def __init__( ) from e self.model = model - from codeband.agents.prompts import load_prompt + from codeband.agents.prompts import load_knowledge, load_prompt prompt = custom_prompt or load_prompt(_DEFAULT_PROMPT) if worker_roster: prompt += f"\n\n{worker_roster}" + prompt += load_knowledge("coding-standards", "testing", "security") if recovery_context: prompt = f"{recovery_context}\n\n---\n\n{prompt}" @@ -55,6 +60,7 @@ def __init__( approval_policy="never", approval_mode=None, sandbox="danger-full-access", + turn_timeout_s=float(turn_timeout_seconds), ) self._adapter = CodexAdapter(config=config) diff --git a/src/codeband/agents/prompts.py b/src/codeband/agents/prompts.py index 868b23d..0f2ce82 100644 --- a/src/codeband/agents/prompts.py +++ b/src/codeband/agents/prompts.py @@ -4,6 +4,8 @@ from pathlib import Path +_KNOWLEDGE_DIR = Path(__file__).parent.parent / "knowledge" + def load_prompt(path: Path, fallback: str = "See Codeband documentation.") -> str: """Load a prompt from file, returning fallback if not found.""" @@ -12,6 +14,24 @@ def load_prompt(path: Path, fallback: str = "See Codeband documentation.") -> st return fallback +def load_knowledge(*names: str) -> str: + """Load and concatenate knowledge guides by name, wrapped in a header.""" + bodies = [] + for name in names: + path = _KNOWLEDGE_DIR / f"{name}.md" + bodies.append(path.read_text(encoding="utf-8")) + if not bodies: + return "" + body = "\n\n".join(bodies) + return ( + "\n\n# Engineering Knowledge Base\n\n" + "The following guides define the craft standards for your work. Treat them as " + "part of your instructions. When a guide and the target repository disagree, the " + "target repository's conventions win.\n\n" + + body + ) + + def build_review_prompt( custom_prompt: str | None, review_guidelines: str | None, @@ -25,3 +45,12 @@ def build_review_prompt( if review_guidelines: prompt += f"\n\n## Additional Review Guidelines\n{review_guidelines}\n" return prompt + + +def build_verify_prompt(custom_prompt: str | None, default_prompt: Path) -> str: + """Build a Verifier prompt. Mirrors :func:`build_review_prompt` without + guidelines — ``VerifiersConfig`` has no ``review_guidelines`` knob, so the + Verifier's instructions are ``prompts/verifier.md`` verbatim (or an + explicit ``custom_prompt`` override). + """ + return custom_prompt or load_prompt(default_prompt) diff --git a/src/codeband/agents/verifier.py b/src/codeband/agents/verifier.py new file mode 100644 index 0000000..e459659 --- /dev/null +++ b/src/codeband/agents/verifier.py @@ -0,0 +1,116 @@ +"""Verifier agent — evidence-integrity acceptance gate before merge. + +Mirrors :mod:`codeband.agents.code_reviewer`: two runner classes (Codex + +Claude), each backed by an isolated scratch directory with gh-CLI network +access. The Verifier renders the SHA-pinned ``verify_acceptance`` verdict +(``cb-phase verify-acceptance``) — the last gate before merge when a verifier +is configured. Unlike the Code Reviewer it carries no ``review_guidelines`` +knob (``VerifiersConfig`` omits it); the prompt is ``prompts/verifier.md`` +verbatim, or an explicit ``custom_prompt`` override. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +_DEFAULT_PROMPT = Path(__file__).parent.parent / "prompts" / "verifier.md" + + +class CodexVerifierRunner: + """ + Verifier backed by OpenAI Codex. + + Runs in an isolated scratch directory. Requires danger-full-access + sandbox because the gh CLI needs network access to reach the GitHub API. + """ + + def __init__( + self, + *, + model: str = "gpt-5.5", + custom_prompt: str | None = None, + workspace: str | None = None, + recovery_context: str | None = None, + # Whole-turn budget (finding 22 / shakedown finding 4): the SDK's + # 180s default abandons any longer turn mid-flight while the Codex + # CLI keeps working. Wired from agents.codex_turn_timeout_seconds. + turn_timeout_seconds: int = 3600, + ): + try: + from thenvoi.adapters import CodexAdapter + from thenvoi.adapters.codex import CodexAdapterConfig + except ImportError as e: + raise ImportError( + "Codex adapter unavailable — band-sdk's codex extras failed to import. " + "Reinstall codeband (`pip install -U codeband`) to restore bundled " + "Codex support." + ) from e + + from codeband.agents.prompts import build_verify_prompt, load_knowledge + + prompt = build_verify_prompt(custom_prompt, _DEFAULT_PROMPT) + prompt += load_knowledge("coding-standards", "testing", "security") + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" + config = CodexAdapterConfig( + model=model, + system_prompt=prompt, + approval_policy="never", + approval_mode=None, + cwd=workspace, + sandbox="danger-full-access", + turn_timeout_s=float(turn_timeout_seconds), + ) + self._adapter = CodexAdapter(config=config) + + @property + def adapter(self): + """Return the underlying adapter for Agent.create().""" + return self._adapter + + +class ClaudeVerifierRunner: + """ + Verifier backed by Claude Code. + + Runs in an isolated scratch directory; bypasses permissions so the gh + commands the acceptance gate needs are never denied (the workspace is + repo-less, so there is nothing local to protect). + """ + + def __init__( + self, + *, + model: str = "claude-sonnet-4-6", + custom_prompt: str | None = None, + workspace: str | None = None, + recovery_context: str | None = None, + ): + from thenvoi.adapters import ClaudeSDKAdapter + from thenvoi.core.types import AdapterFeatures, Capability, Emit + + from codeband.agents.prompts import build_verify_prompt, load_knowledge + + prompt = build_verify_prompt(custom_prompt, _DEFAULT_PROMPT) + prompt += load_knowledge("coding-standards", "testing", "security") + if recovery_context: + prompt = f"{recovery_context}\n\n---\n\n{prompt}" + self._adapter = ClaudeSDKAdapter( + model=model, + custom_section=prompt, + permission_mode="bypassPermissions", + approval_mode=None, + features=AdapterFeatures( + capabilities={Capability.MEMORY}, + emit={Emit.EXECUTION, Emit.THOUGHTS}, + ), + cwd=workspace, + ) + + @property + def adapter(self): + """Return the underlying adapter for Agent.create().""" + return self._adapter diff --git a/src/codeband/agents/watchdog.py b/src/codeband/agents/watchdog.py index 45e35ec..11c0450 100644 --- a/src/codeband/agents/watchdog.py +++ b/src/codeband/agents/watchdog.py @@ -3,9 +3,14 @@ from __future__ import annotations import dataclasses +import functools +import json import logging import re +import sqlite3 +import subprocess from datetime import UTC, datetime, timedelta +from pathlib import Path from typing import Any from codeband.config import WatchdogConfig @@ -13,6 +18,59 @@ logger = logging.getLogger(__name__) +def _parse_ts(value: Any) -> datetime | None: + """Coerce an ISO-8601 string or datetime into a tz-aware UTC datetime. + + Returns ``None`` for missing or unparseable values. Naive datetimes are + assumed to be UTC. + """ + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=UTC) + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + return None + + +@functools.lru_cache(maxsize=64) +def _mention_patterns( + participants: frozenset[tuple[str, str]], +) -> tuple[tuple[str, re.Pattern[str]], ...]: + """Compile @-mention patterns ONCE per participant set (S8-F1). + + Previously every message × participant recompiled the same regex — a + patrol over a busy room cost hundreds of identical ``re.compile`` calls + per cycle. A room's participant set is stable across patrols, so the + LRU cache (keyed on the frozen ``(id, name)`` set) makes compilation a + once-per-set cost shared by every later patrol. + + Both-sided word boundary: ``@`` must be a true mention prefix (not part + of a longer token like ``email@Coder-Claude-0``), and trailing chars must + terminate the name (so ``@Coder-Claude-0`` is not a substring match of + ``@Coder-Claude-01``). + """ + return tuple( + ( + pid, + re.compile(rf"(?]]``. Some send +# paths deliver mentions ONLY as this markup — no structured ``mentions`` +# list and no ``@DisplayName`` text — which made a re-dispatch invisible to +# the fallback scan: the mentioned agent's staleness clock never started, +# and a terminal-shaped *older* own-message then untracked it entirely +# (finding 17, both halves). +_UUID_MENTION_RE = re.compile(r"@\[\[([0-9a-f-]{36})\]\]", re.IGNORECASE) + + def _mentioned_participant_ids( msg: Any, participant_names: dict[str, str], ) -> set[str]: @@ -20,10 +78,11 @@ def _mentioned_participant_ids( Tries structured ``msg.mentions`` first (Band.ai chat messages carry mentions as a list of items with ``.id``); falls back to scanning the - message content for ``@`` with right-side word-boundary - semantics so ``@Coder-Claude-0`` does not match ``@Coder-Claude-01``. - Test mocks that don't set these fields are silently ignored — the - isinstance checks reject MagicMock-typed sentinels. + message content — BOTH the ``@[[uuid]]`` inline markup (matched against + the participant id set; finding 17) and the ``@DisplayName`` form via the + cached per-participant-set patterns from :func:`_mention_patterns`. Test + mocks that don't set these fields are silently ignored — the isinstance + checks reject MagicMock-typed sentinels. """ found: set[str] = set() @@ -36,16 +95,12 @@ def _mentioned_participant_ids( content = getattr(msg, "content", None) if isinstance(content, str): - for pid, pname in participant_names.items(): - if not pname: - continue - # Both-sided word boundary: `@` must be a true mention prefix - # (not part of a longer token like `email@Coder-Claude-0`), and - # trailing chars must terminate the name (so `@Coder-Claude-0` - # is not a substring match of `@Coder-Claude-01`). - pattern = re.compile( - rf"(? bool: return isinstance(content, str) and bool(_TERMINAL_PROTOCOL_RE.search(content)) +# Subtask states the mechanical-progress patrol watches (RFC WS4 + Stage-2 + +# S2-1/F12). ``in_progress`` / ``verify_pending`` are the coder's working +# states; ``merge_pending`` is the merge queue — a subtask resting there with +# no progress (e.g. an approval request nobody acted on) goes stale like any +# other and escalates through the standard stall path. The watchdog never +# queries GitHub to *reconcile* a merge — that is ``cb-phase merge``'s +# idempotent reconcile step; only the existing PR-activity progress signal +# applies here, as it does to every patrolled state. +# +# ``review_pending`` / ``review_failed`` / ``review_passed`` / +# ``acceptance_passed`` / ``needs_rebase`` are the resting states where +# dispatched work can silently die: a reviewer that never renders a verdict, a +# Verifier that never renders an acceptance verdict, a coder that never picks +# up the rework, a Mergemaster that never queues the approved PR, a rebase +# nobody starts. The mechanical signals are state-agnostic — transition recency +# applies to every state (each of these is *entered* by a transition, so the +# row exists), and the PR-activity signal applies wherever ``pr_number`` is set +# (it is, by the verify leg, for everything at/past ``review_pending``). +_PATROLLED_SUBTASK_STATES: frozenset[str] = frozenset( + { + "in_progress", + "verify_pending", + "review_pending", + "review_failed", + "review_passed", + "acceptance_passed", + "merge_pending", + "needs_rebase", + } +) + +# Sentinel: distinguishes "transition timestamp pre-fetched as None (no rows)" +# from "not pre-fetched at all (fall back to per-subtask query)". +_TRANSITION_UNSET = object() + + +# Free-tier recency-probe paging. The agent message API pages OLDEST-first +# (default page_size 20, max 100) with no ``since`` parameter, so the probe +# requests the largest page and walks backward from the LAST page. +_PROBE_PAGE_SIZE = 100 +# How many newest pages the probe will walk per room per patrol. A room with +# ``_MAX_PROBE_PAGES × _PROBE_PAGE_SIZE`` (500) messages inside one staleness +# window is active by definition — older history cannot change any recency +# verdict, so walking further is pure cost. +_MAX_PROBE_PAGES = 5 + + @dataclasses.dataclass class AgentHealthState: """In-memory health tracking for a single agent.""" @@ -82,6 +184,22 @@ class AgentHealthState: # i.e. the nudge confirmed it's alive. Gates `nudge_suppression_seconds` # so legitimately-idle agents don't get re-nudged every staleness cycle. confirmed_alive_at: datetime | None = None + # ── Mechanical progress tracking (RFC WS4) ────────────────────────────── + # Used for the per-subtask progress signals, not the chat-recency path. + # Consecutive patrols with no observed mechanical progress (no git-HEAD + # change on the subtask's branch and no newer transition-log/PR timestamp). + patrol_visits_without_progress: int = 0 + # Last observed git HEAD for the subtask's branch ("" until first seen). + last_git_head: str = "" + # Most recent progress timestamp observed from the transition log or the + # PR's updatedAt — whichever is newer. + last_transition_timestamp: datetime | None = None + # Consecutive patrols where EVERY attempted mechanical signal read FAILED + # (git error, gh error, store error — as opposed to returned-but-unchanged). + # Such a patrol observed nothing and does not count toward the stall cap + # (S6-F6: observation vs absence); this counter makes a permanently + # degraded probe visible at debug level. + no_data_patrols: int = 0 class WatchdogDaemon: @@ -109,13 +227,55 @@ def __init__( agent_id_to_role: dict[str, str] | None = None, human_rest_client: Any | None = None, local_memory_store: Any | None = None, + state_store: Any | None = None, + owner_id: str | None = None, + owner_handle: str | None = None, + bare_repo: Any | None = None, + repo_slug: str | None = None, + agent_rest_clients: dict[str, Any] | None = None, ): self._config = config self._rest = rest_client self._human_rest = human_rest_client self._agent_id = agent_id self._conductor_id = conductor_id + # Repo context for the mechanical-progress probes (S9-1), injected at + # construction by the runner: ``bare_repo`` is the workspace layout's + # bare clone (``{workspace}/repo.git``) that every coder branch is + # pushed through, ``repo_slug`` is ``owner/repo`` from config + # ``repo.url``. Without them the probes ran from the watchdog + # process's cwd — which is the project dir only in the dogfood + # topology, so in any other layout ``_git_head`` / ``_pr_updated_at`` + # silently resolved nothing (or, worse, the WRONG repo's state). + # ``None`` degrades to the historical cwd behavior. + self._bare_repo = bare_repo + self._repo_slug = repo_slug + # Owner/CC participant to @mention when a subtask lands in ``blocked`` + # (from ANY source — the watchdog's own stall cap, the verify-attempt + # cap, or the review-round cap). ``owner_id`` is the Band participant id + # used for the structured mention; ``owner_handle`` is the display name + # for the message text (falls back to the id). DORMANT by default: when + # ``owner_id`` is None (the runner does not pass it pre-activation) the + # blocked-escalation patrol is a no-op, so this ships safely ahead of the + # verify-gate activation. The CC-side Monitors remain the fail-safe. + self._owner_id = owner_id + self._owner_handle = owner_handle + # Escalate-once per subtask, so a blocked subtask is announced to the + # owner a single time rather than every patrol. Keyed by + # ``(task_id, subtask_id)`` — subtask ids repeat across tasks (planners + # emit st-1, st-2, … fresh per plan), so a bare subtask_id key would let + # one task's escalation swallow another's. + self._owner_escalated: set[tuple[str, str]] = set() self._state: dict[str, AgentHealthState] = {} + # Durable state store (RFC WS1). May be None — when absent the watchdog + # degrades to chat-recency-only behavior and the mechanical-progress + # path is skipped entirely. + self._store = state_store + # Per-subtask mechanical-progress health, keyed by + # ``(task_id, subtask_id)`` — task-scoped like the store rows it + # mirrors. Separate from `_state` (which is keyed by agent_id for the + # chat-recency path). + self._subtask_state: dict[tuple[str, str], AgentHealthState] = {} self._activity = activity self._role_map = agent_id_to_role or {} # Rooms we've already warned about for stale state — log once per @@ -129,6 +289,39 @@ def __init__( # Track whether we logged an "idle — skipping patrols" line this # idle window so we don't repeat it every cycle. self._idle_skip_logged = False + # Integrity rung (Stage-3): remembered tip of each hash chain + # (``{chain_name: (last_verified_id, last_hash)}``). Each patrol + # verifies only rows past the remembered id (incremental) and detects + # head REGRESSION (the remembered tip gone/rewritten — tail truncation a + # forward walk cannot see). Escalate-once per (room, chain, kind), same + # marker-after-send discipline as the blocked rung. + self._chain_tips: dict[str, tuple[int, str | None]] = {} + self._integrity_alerted: set[tuple[str, str, str]] = set() + # Deep full-history integrity sweep (Stage-3 PR3): the longer-cadence + # counterpart to the incremental rung above. Runs every + # ``full_integrity_interval_patrols`` patrols, walking BOTH chains from + # row 1 to catch the incremental rung's structural blind spot — an + # in-place edit of an INTERIOR, already-verified row (id below the + # remembered tip), which a forward-from-tip walk never re-reads. Its own + # escalate-once marker set so the two rungs never suppress each other; + # findings are attributed to the verifier role. ``db_path`` reads only — + # never touches ``_chain_tips`` (that state belongs to the incremental + # rung; the two are fully decoupled). + self._full_integrity_alerted: set[tuple[str, str, str]] = set() + self._full_integrity_patrol_count: int = 0 + # Transport-health (turn-boundary 422 pin) heal rung. Per-agent REST + # clients keyed by agent_id — each is authenticated as THAT agent so + # the `list_agent_messages(status="processing")` read and the + # `mark_agent_message_processed` heal act on its own delivery row (a + # call with the Conductor's credentials only sees/heals the Conductor's + # deliveries). ``None`` opts out and the rung is a no-op — same shape + # as ``state_store``. ``_pin_heal_attempts`` counts consecutive failed + # heals per ``(agent_id, message_id)``; ``_pin_escalated`` is the + # escalate-once marker so a server-side-rejected heal can not become + # its own storm. + self._agent_rest_clients: dict[str, Any] = dict(agent_rest_clients or {}) + self._pin_heal_attempts: dict[tuple[str, str], int] = {} + self._pin_escalated: set[tuple[str, str]] = set() async def run(self) -> None: """Main patrol loop — runs until cancelled.""" @@ -231,24 +424,96 @@ def _ts(rec: Any) -> datetime: return parts[2], _ts(latest) async def _list_messages(self, room_id: str, since: datetime) -> list[Any]: - """List messages in a room. + """List messages in a room within the ``since`` window. On enterprise tier uses the human API (captures text + thought + - tool_call + tool_result + error); on free tier uses the agent-API - inbox (text only, chat-only). + tool_call + tool_result + error), which bounds the read server-side + via its ``since`` parameter. On free tier uses the agent-API inbox + (text only, chat-only), which has no ``since`` parameter and pages + OLDEST-first — so the recency probe must page from the END: + requesting the default page of a long room returns only its oldest + messages, the client-side window filters them all out, and the probe + reads an active room as silence. :meth:`_fetch_recent_agent_messages` + fetches the newest page(s) instead; the window filter is applied here + on top, as before. Messages with a missing/unparseable timestamp are + kept — the patrol loop already skips them, and dropping them here + would silently change behavior for partial records. """ if self._human_rest is not None: resp = await self._human_rest.human_api_messages.list_my_chat_messages( chat_id=room_id, since=since, ) - else: - resp = await self._rest.agent_api_messages.list_agent_messages( - chat_id=room_id, status="all", + return list(resp.data or []) + raw = await self._fetch_recent_agent_messages(room_id, since) + messages = [] + for msg in raw: + ts = _parse_ts(getattr(msg, "inserted_at", None)) + if ts is None or ts >= since: + messages.append(msg) + return messages + + async def _fetch_recent_agent_messages( + self, room_id: str, since: datetime, + ) -> list[Any]: + """Fetch the NEWEST agent-API message page(s) covering ``since``. + + The agent API returns messages oldest-first with no ``since`` + parameter (server-side recency paging is a platform ask), so one + probe request first learns ``metadata.total_pages``, then walks pages + from the LAST one backward — continuing only while every message on + the page is still inside the window (i.e. the window may extend into + the previous page) and at most :data:`_MAX_PROBE_PAGES` pages deep. + Beyond the cap the room has had ``_MAX_PROBE_PAGES × page_size`` + messages inside one staleness window — active by definition; older + history cannot change any recency verdict. + + A response without usable paging metadata (older server, test + doubles — the ``isinstance`` check rejects MagicMock sentinels) + degrades to the first response's data, the pre-paging behavior. + Returned oldest→newest across pages, matching single-page order. + """ + fetch = self._rest.agent_api_messages.list_agent_messages + first = await fetch( + chat_id=room_id, status="all", page=1, page_size=_PROBE_PAGE_SIZE, + ) + meta = getattr(first, "metadata", None) + total_pages = getattr(meta, "total_pages", None) + if not isinstance(total_pages, int) or total_pages <= 1: + return list(first.data or []) + + pages: list[list[Any]] = [] + page_no = total_pages + while page_no >= 1 and len(pages) < _MAX_PROBE_PAGES: + if page_no == 1: + data = list(first.data or []) # already fetched above + else: + resp = await fetch( + chat_id=room_id, status="all", + page=page_no, page_size=_PROBE_PAGE_SIZE, + ) + data = list(resp.data or []) + pages.append(data) + # Pages are oldest-first internally: the first parseable timestamp + # is the page's oldest. Only when even that is inside the window + # can the window extend into the previous page. No parseable + # timestamp at all → stop; nothing decidable lies further back. + oldest_ts = next( + ( + ts for m in data + if (ts := _parse_ts(getattr(m, "inserted_at", None))) is not None + ), + None, ) - return list(resp.data or []) + if oldest_ts is None or oldest_ts < since: + break + page_no -= 1 + pages.reverse() + return [msg for page in pages for msg in page] async def _patrol(self) -> None: """Single patrol cycle: check all rooms for stale agents.""" + import asyncio + now = datetime.now(UTC) # Gate: if the Conductor has reported task completion or is waiting on @@ -283,6 +548,26 @@ async def _patrol(self) -> None: from thenvoi_rest.core.api_error import ApiError from thenvoi_rest.errors.not_found_error import NotFoundError + # Active-only patrol: task rows drive which rooms matter. A room whose + # task row is no longer 'active' (e.g. superseded by a later + # registration — see state/registration.py) is dead to the watchdog + # and skipped before any REST call, which also retires the stale-room + # NotFoundError warning for superseded rows. Each active room's owner + # is resolved here too, for the nudge-eligibility filter below. With + # no store (or a failed read) both maps stay empty and the patrol + # degrades to the unfiltered behavior. + task_rows = await asyncio.to_thread(self._task_rows) + active_room_owners: dict[str, str | None] = {} + inactive_rooms: set[str] = set() + if task_rows: + active_room_owners = { + room: owner for _, room, status, owner in task_rows + if status == "active" + } + inactive_rooms = { + room for _, room, status, _ in task_rows if status != "active" + } - set(active_room_owners) + try: rooms = await self._list_rooms() except ApiError as e: @@ -298,6 +583,8 @@ async def _patrol(self) -> None: for room in rooms: room_id = room.id + if room_id in inactive_rooms: + continue try: messages = await self._list_messages(room_id, since) # Participant list is always via the agent API — the write @@ -339,9 +626,13 @@ async def _patrol(self) -> None: # `mentioned_participant_not_in_room` from the server. Human # participants (type="User") are excluded so the watchdog never # nudges or escalates at the human user who opened the session. + # The task owner is excluded regardless of type: mission control + # may be an Agent-typed peer (initiator-as-owner), and it receives + # owner escalations but must never be treated as a stalled worker. + room_owner = active_room_owners.get(room_id) or self._owner_id participant_names: dict[str, str] = { p.id: (p.name or p.id) for p in parts_response.data - if p.type == "Agent" + if p.type == "Agent" and p.id != room_owner } # Per-agent activity signals. @@ -383,6 +674,13 @@ async def _patrol(self) -> None: last_mention_ts = last_mentioned.get(agent_id) if last_msg_ts is None and last_mention_ts is None: continue + # Terminal-shaped untrack ONLY when the agent's own message is + # the newest signal: an UNANSWERED inbound mention newer than + # the agent's last own-message means dispatched work is + # pending, and untracking would blind the patrol to the very + # agent most likely to be dormant (finding 17 — this guard is + # only as good as the mention scan above, which is why the + # @[[uuid]] markup form must be parsed too). if ( last_msg_ts is not None and (last_mention_ts is None or last_msg_ts >= last_mention_ts) @@ -439,18 +737,1943 @@ async def _patrol(self) -> None: and (now - state.nudged_at) >= timedelta(seconds=self._config.nudge_grace_seconds) ): - # Escalate-once: flip state before attempting the send so - # a server rejection (e.g. mention validation) doesn't - # produce an unbounded retry loop on every patrol. - state.escalated = True - await self._send_escalation( - room_id, agent_id, staleness, participant_names, - ) + # Escalate-once, marker-after-send: the flag flips only + # when the send lands (or is permanently undeliverable, + # HTTP 422), so a transient failure retries next patrol + # instead of being silently burned forever. See + # _attempt_escalation_send for the full policy. + if await self._attempt_escalation_send( + self._send_escalation( + room_id, agent_id, staleness, participant_names, + ), + target=f"agent {agent_id}", + room_id=room_id, + ): + state.escalated = True except Exception: logger.exception( "Failed to nudge/escalate %s in room %s", agent_id, room_id, ) + # Third escalation rung (RFC WS4): mechanical per-subtask progress. + # Independent of the chat-recency path above; guarded so a store/git + # failure never breaks the patrol loop. + await self._check_subtask_progress(now) + + # Fourth rung: owner escalation for subtasks already in ``blocked`` — + # from ANY source (stall cap, verify cap, review cap). Dormant until an + # owner_id is supplied (post-activation); guarded so a notify failure + # never breaks the patrol loop. + await self._check_blocked_subtasks(now) + + # Fifth rung (Stage-3): ledger integrity. Incrementally verify the + # hash chains and escalate a chain break or a head regression. Guarded + # so a store/read failure never breaks the patrol loop. + await self._check_chain_integrity(now) + + # Sixth rung (Stage-3 PR3): deep full-history integrity sweep on a + # longer cadence. Walks both chains from row 1 every N patrols to catch + # the incremental rung's interior-old-row blind spot. Code-driven and + # independent of any verifier LLM seat; guarded like the rung above. + await self._check_chain_integrity_full(now) + + # Seventh rung: transport-health (turn-boundary 422 pin) heal. Reuses + # the patrol's already-listed rooms so the rung is one extra call per + # (agent, active room) and orthogonal to the chat-recency nudge above — + # a pinned agent cannot read a nudge, so a separate transport-level + # heal is required. ``rooms`` is the patrol's room list and + # ``inactive_rooms`` the same active-only filter. + await self._check_transport_pins(rooms, inactive_rooms, now) + + def _task_rows(self) -> list[tuple[str, str, str, str | None]] | None: + """Return ``(task_id, room_id, status, owner_id)`` for every task row. + + Returns ``None`` when no store is wired or the read fails — callers + degrade to the unfiltered (pre-store) behavior. Reads the store's + SQLite file directly (read-only), mirroring :meth:`_latest_transition`, + since the Workstream-1 store surface does not expose a task-listing + query. + """ + if self._store is None: + return None + db_path = getattr(self._store, "db_path", None) + if db_path is None: + return None + try: + conn = sqlite3.connect(db_path, timeout=5.0) + try: + rows = conn.execute( + "SELECT task_id, room_id, status, owner_id FROM tasks", + ).fetchall() + finally: + conn.close() + except sqlite3.Error: + logger.debug("Could not read task rows", exc_info=True) + return None + return [(r[0], r[1], r[2], r[3]) for r in rows] + + def _active_task_ids(self) -> set[str] | None: + """Task ids with ``status='active'``, or ``None`` to disable filtering.""" + rows = self._task_rows() + if rows is None: + return None + return {task_id for task_id, _, status, _ in rows if status == "active"} + + def _current_task_id_from_pointer(self) -> str | None: + """Return the active task named by the room pointer, or ``None``.""" + from codeband.state.registration import read_room_pointer + + db_path = getattr(self._store, "db_path", None) + if db_path is None: + logger.warning("Watchdog skipping subtask nudges: state store path is unavailable.") + return None + current_room_id = read_room_pointer( + Path.cwd(), Path(db_path).parent, warn_legacy=False, + ) + if current_room_id is None: + logger.warning("Watchdog skipping subtask nudges: no active room pointer found.") + return None + rows = self._task_rows() + if rows is None: + logger.warning( + "Watchdog skipping subtask nudges: could not resolve current task " + "for room %s.", + current_room_id, + ) + return None + for task_id, room_id, status, _ in rows: + if room_id == current_room_id and status == "active": + return task_id + logger.warning( + "Watchdog skipping subtask nudges: active room %s has no active task row.", + current_room_id, + ) + return None + + async def _attempt_escalation_send( + self, send: Any, *, target: str, room_id: str, + ) -> bool: + """Await an escalation *send*; return True when its once-marker may burn. + + Marker-after-send: an escalate-once marker burns only on a successful + send, so a transient failure (network, 429, 5xx) is retried on the + next patrol instead of being silently burned forever. Accepted trade: + a send that succeeds but whose response is lost burns nothing and can + produce a duplicate escalation. The one permanent failure is the + server's HTTP 422 mention rejection + (``mentioned_participant_not_in_room`` — see the participant-filter + comment in :meth:`_patrol`): retrying the same message can only be + rejected again, so burn the marker anyway and log at CRITICAL. + """ + from thenvoi_rest.core.api_error import ApiError + + try: + await send + except ApiError as e: + if e.status_code == 422: + logger.critical( + "%s not mentionable in room %s — escalation undeliverable", + target, room_id, + ) + return True + logger.warning( + "Escalation send to %s in room %s failed (HTTP %s) — " + "will retry next patrol", + target, room_id, e.status_code, + ) + return False + except Exception: + logger.warning( + "Escalation send to %s in room %s failed — will retry next patrol", + target, room_id, exc_info=True, + ) + return False + return True + + async def _check_subtask_progress(self, now: datetime) -> None: + """Detect stalled subtasks via mechanical signals and escalate (RFC WS4). + + For each in-flight subtask in :data:`_PATROLLED_SUBTASK_STATES` we read + three deterministic signals — the git HEAD of its branch, its PR's + ``updatedAt`` and the most recent ``transition_log`` timestamp. A change + in any of them since the last patrol counts as progress and resets the + per-subtask stall counter; otherwise the counter increments. When it + reaches ``max_phase_visits`` the subtask is marked blocked (via the FSM + when available) and the Conductor + human are notified. + + Fully no-ops when the store is absent or ``git_progress_check`` is off, + preserving the prior chat-recency-only behavior. + """ + import asyncio + + if self._store is None or not self._config.git_progress_check: + return + + current_task_id = await asyncio.to_thread(self._current_task_id_from_pointer) + if current_task_id is None: + return + + try: + subtasks = await asyncio.to_thread(self._store.list_active_subtasks) + except Exception: + logger.debug("Watchdog could not list subtasks from store", exc_info=True) + return + + patrolled = [ + sub for sub in subtasks + if sub.task_id == current_task_id and sub.state in _PATROLLED_SUBTASK_STATES + ] + for sub in subtasks: + if sub.task_id != current_task_id: + logger.warning( + "Watchdog skipping subtask %s: belongs to task %s, current task is %s — " + "stale state.", + sub.subtask_id, sub.task_id, current_task_id, + ) + + # Batch-fetch latest transition timestamps for all patrolled subtasks in + # one query (N+1 elimination). On success, fill None for subtasks with + # no rows so every key is present → _check_one_subtask uses the batch + # result. On failure the dict stays empty and _check_one_subtask falls + # back to its own per-subtask _latest_transition call. + transition_batch: dict[tuple[str, str], Any] = {} + try: + transition_batch = await asyncio.to_thread( + self._store.batch_latest_transitions, + [(sub.task_id, sub.subtask_id) for sub in patrolled], + ) + # Pre-populate None for subtasks absent from the result (no rows yet). + for _sub in patrolled: + _key = (_sub.task_id, _sub.subtask_id) + if _key not in transition_batch: + transition_batch[_key] = None + except Exception: + logger.debug("Watchdog batch transition fetch failed", exc_info=True) + + _unset = _TRANSITION_UNSET + for sub in patrolled: + try: + prefetched = transition_batch.get((sub.task_id, sub.subtask_id), _unset) + await self._check_one_subtask(sub, now, prefetched_transition_ts=prefetched) + except Exception: + logger.exception( + "Watchdog subtask-progress check failed for %s", sub.subtask_id, + ) + + async def _check_one_subtask( + self, sub: Any, now: datetime, *, prefetched_transition_ts: Any = _TRANSITION_UNSET, + ) -> None: + """Evaluate mechanical progress for a single in-flight subtask.""" + import asyncio + + branch = (sub.metadata or {}).get("branch") if sub.metadata else None + # Observation vs absence (S6-F6): count how many signal reads were + # *attempted* and how many FAILED to yield any data (git error, gh + # error, store error). "Returned but unchanged" is an observation — + # only a no-data read counts as failed. + reads_attempted = 0 + reads_failed = 0 + + git_head = None + if branch: + reads_attempted += 1 + git_head = await asyncio.to_thread(self._git_head, branch) + if git_head is None: + reads_failed += 1 + + # SHA-drift rung (finding 28): for merge_pending subtasks, detect when + # the branch HEAD has moved past the approved SHA. The approved SHA is + # the commit the approver signed off on; a drift means the grant is stale + # and a fresh rebase → re-review → re-approval cycle is needed. Route + # via the same mergemaster FSM edge used by the merge leg so the rebase- + # round cap and all FSM invariants are preserved. Fires only when both + # merge_approved_sha and git_head are known; skipped on unresolvable HEAD + # to avoid false trips on transient probe failures. + approved_sha: str | None = getattr(sub, "merge_approved_sha", None) + if ( + sub.state == "merge_pending" + and approved_sha + and git_head is not None + and git_head != approved_sha + ): + await self._on_merge_pending_sha_drift(sub, git_head) + return + + # Backstop: approved + merge_pending + HEAD matches the approved SHA, + # but dispatch stalled. Re-nudge Mergemaster; do NOT let this PR get + # escalated to blocked while a valid grant is on record. + approved_sha = getattr(sub, "merge_approved_sha", None) + if ( + sub.state == "merge_pending" + and approved_sha is not None + and git_head is not None + and git_head == approved_sha + and self._config.merge_approval_backstop_max_renudges > 0 + ): + if await self._maybe_backstop_renudge(sub, approved_sha, now): + health = self._subtask_state.get((sub.task_id, sub.subtask_id)) + if health is not None: + health.patrol_visits_without_progress = 0 + return + # cap hit → fall through to the stall counter so a genuinely hung + # Mergemaster still surfaces to the owner as blocked. + + # Acceptance-advance rung: acceptance_passed + dispatch stalled. + # Re-nudge Mergemaster so a verified-and-accepted PR is not escalated + # to blocked while waiting for the merge step to be triggered. + if ( + sub.state == "acceptance_passed" + and self._config.acceptance_advance_max_renudges > 0 + ): + if await self._maybe_acceptance_advance_renudge(sub, now): + health = self._subtask_state.get((sub.task_id, sub.subtask_id)) + if health is not None: + health.patrol_visits_without_progress = 0 + return + # cap hit → fall through to the stall counter so a genuinely hung + # Mergemaster still surfaces to the owner as blocked. + + pr_ts = None + if sub.pr_number is not None: + reads_attempted += 1 + pr_ts = await asyncio.to_thread(self._pr_updated_at, sub.pr_number) + if pr_ts is None: + reads_failed += 1 + reads_attempted += 1 + if prefetched_transition_ts is not _TRANSITION_UNSET: + transition_ok = True + transition_ts = prefetched_transition_ts + else: + transition_ok, transition_ts = await asyncio.to_thread( + self._latest_transition, sub.subtask_id, sub.task_id, + ) + if not transition_ok: + reads_failed += 1 + # Newest of the two timestamped signals (PR update vs. transition log). + latest_ts = max( + (t for t in (pr_ts, transition_ts) if t is not None), + default=None, + ) + + key = (sub.task_id, sub.subtask_id) + health = self._subtask_state.get(key) + if health is None: + health = AgentHealthState(last_seen=now) + self._subtask_state[key] = health + + # A patrol where EVERY attempted read failed observed nothing — it is + # not evidence of a stall and must not advance the stall counter. + # Track it separately so a permanently degraded probe (broken git, + # expired gh auth) stays visible instead of silently freezing the cap. + if reads_failed == reads_attempted: + health.no_data_patrols += 1 + logger.debug( + "Subtask %s: all %d mechanical signal reads failed " + "(%d consecutive no-data patrols) — not counted toward " + "the stall cap", + sub.subtask_id, reads_attempted, health.no_data_patrols, + ) + return + health.no_data_patrols = 0 + + progressed = False + if git_head is not None and git_head != health.last_git_head: + progressed = True + if latest_ts is not None and ( + health.last_transition_timestamp is None + or latest_ts > health.last_transition_timestamp + ): + progressed = True + + # Record the new baselines before deciding, so the next patrol compares + # against the latest observation. + if git_head is not None: + health.last_git_head = git_head + if latest_ts is not None: + health.last_transition_timestamp = latest_ts + + if progressed: + health.patrol_visits_without_progress = 0 + # Recovery — allow a future stall to escalate again. + health.escalated = False + return + + health.patrol_visits_without_progress += 1 + if ( + health.patrol_visits_without_progress >= self._config.max_phase_visits + and not health.escalated + ): + # Marker-after-send (S6-F7n), matching the two sibling rungs: the + # escalate-once marker burns only when the escalation reports + # success (FSM transition applied or alert landed), so a transient + # failure retries next patrol. A double-send is acceptable; a + # silent permanent stall is not. + if await self._send_blocked_escalation(sub): + health.escalated = True + + async def _on_merge_pending_sha_drift(self, sub: Any, live_sha: str) -> None: + """Route a merge_pending subtask to needs_rebase after its head drifted. + + Uses the Mergemaster's FSM edge (``merge_pending → needs_rebase``) so + the rebase-round cap is enforced and all FSM invariants are preserved. + Logs a room alert so the Conductor and Coder are informed immediately. + Best-effort: a failed FSM transition or post is logged but does not + break patrol. + """ + import asyncio + from codeband.state import fsm + + approved_sha: str | None = getattr(sub, "merge_approved_sha", None) + reason = ( + f"[watchdog] head SHA moved since grant " + f"({approved_sha[:8] if approved_sha else '?'} → {live_sha[:8]})" + ) + logger.warning( + "Subtask %s: merge_pending head drifted from approved SHA %s to %s " + "— routing to needs_rebase", + sub.subtask_id, approved_sha, live_sha, + ) + try: + await asyncio.to_thread( + fsm.transition, + sub.subtask_id, sub.task_id, "needs_rebase", + caller_role="mergemaster", reason=reason, store=self._store, + ) + except Exception: + logger.exception( + "Watchdog: needs_rebase transition failed for %s after SHA drift", + sub.subtask_id, + ) + return + + if self._activity: + self._activity.log( + "SUBTASK_SHA_DRIFT", "watchdog", + f"Subtask {sub.subtask_id} routed to needs_rebase: " + f"head moved from {approved_sha} to {live_sha}", + ) + + room_id = await self._resolve_room_id(sub.task_id) + if room_id is None: + return + from thenvoi_rest.types import ChatMessageRequest + + try: + await self._rest.agent_api_messages.create_agent_chat_message( + chat_id=room_id, + message=ChatMessageRequest( + content=( + f"[Watchdog] Subtask {sub.subtask_id}: branch HEAD moved " + f"({approved_sha[:8] if approved_sha else '?'} → {live_sha[:8]}) " + f"while merge_pending — grant is stale, subtask sent to " + f"needs_rebase. Coder: rebase, re-verify, re-earn verdicts, " + f"then request re-approval." + ), + mentions=[], + ), + ) + except Exception: + logger.exception( + "Watchdog: failed to post SHA-drift alert for %s", sub.subtask_id, + ) + + async def _maybe_backstop_renudge( + self, sub: Any, approved_sha: str, now: datetime, + ) -> bool: + """Return True when the backstop owns the patrol; False on cap-hit. + + True = caller early-returns (rung is active, do not advance the stall + counter). False = cap exhausted, caller falls through to the normal + stall path so a genuinely hung Mergemaster still surfaces as blocked. + + Reads approval_grant + merge_backstop_nudge rows from the audit log, + filters to the current ``approved_sha``, and decides whether to fire. + Applies the staleness window against the anchor timestamp (last nudge, + or the original grant if no nudge yet). On fire: sends, flips + swarm-status active, and appends a durable marker (marker-after-send + discipline: only appended on successful send). + """ + import asyncio + + if self._store is None: + return False + + rows = await asyncio.to_thread( + self._store.latest_audit_events, + task_id=sub.task_id, subtask_id=sub.subtask_id, + event_types=("approval_grant", "merge_backstop_nudge"), + ) + grant_ts: datetime | None = None + last_nudge_ts: datetime | None = None + renudges_for_sha = 0 + for event_type, ts_str, payload in rows: + if (payload or {}).get("approved_sha") != approved_sha: + continue + ts = _parse_ts(ts_str) + if event_type == "approval_grant" and grant_ts is None: + grant_ts = ts + elif event_type == "merge_backstop_nudge": + renudges_for_sha += 1 + if last_nudge_ts is None: + last_nudge_ts = ts + if grant_ts is None: + return False # no grant in audit log — stall path owns this + if renudges_for_sha >= self._config.merge_approval_backstop_max_renudges: + return False # cap hit — release patrol to stall path + anchor_ts = last_nudge_ts or grant_ts + window = timedelta(seconds=self._config.merge_approval_backstop_seconds) + if (now - anchor_ts) < window: + return True # within window — own patrol, don't fire yet + fired = await self._send_merge_backstop_renudge(sub, approved_sha) + if not fired: + return True # transient send failure — retry next patrol + await self._flip_swarm_status_active(sub.task_id) + await asyncio.to_thread( + self._store.append_audit_event, + "merge_backstop_nudge", + task_id=sub.task_id, + subtask_id=sub.subtask_id, + payload={"pr_number": sub.pr_number, "approved_sha": approved_sha}, + ) + return True + + async def _send_merge_backstop_renudge( + self, sub: Any, approved_sha: str, + ) -> bool: + """@mention the Mergemaster asking it to run ``cb-phase merge``. + + Resolves the Mergemaster agent id from ``_role_map``, then the room + from the store's task row. Returns True on successful send, False on + any resolution failure or send exception (logged at WARNING; does not + raise so patrol continues). + """ + from thenvoi_rest.types import ChatMessageRequest, ChatMessageRequestMentionsItem + + mm_id: str | None = next( + (aid for aid, role in self._role_map.items() if role == "mergemaster"), + None, + ) + if mm_id is None: + logger.warning( + "Backstop: no Mergemaster in role_map for subtask %s — cannot renudge", + sub.subtask_id, + ) + return False + + room_id = await self._resolve_room_id(sub.task_id) + if room_id is None: + logger.warning( + "Backstop: could not resolve room for task %s — cannot renudge %s", + sub.task_id, sub.subtask_id, + ) + return False + + short_sha = approved_sha[:8] if approved_sha else "?" + pr_ref = f"PR #{sub.pr_number}" if sub.pr_number is not None else "the PR" + content = ( + f"[Watchdog backstop] Subtask {sub.subtask_id}: {pr_ref} has a " + f"recorded human approval at {short_sha} but merge dispatch appears " + f"stalled. Please run `cb-phase merge` now." + ) + try: + await self._rest.agent_api_messages.create_agent_chat_message( + chat_id=room_id, + message=ChatMessageRequest( + content=content, + mentions=[ChatMessageRequestMentionsItem(id=mm_id)], + ), + ) + except Exception: + logger.warning( + "Backstop: failed to send merge renudge for subtask %s", + sub.subtask_id, exc_info=True, + ) + return False + return True + + async def _maybe_acceptance_advance_renudge( + self, sub: Any, now: datetime, + ) -> bool: + """Return True when the acceptance-advance rung owns the patrol; False on cap-hit. + + True = caller early-returns (rung is active, do not advance the stall + counter). False = cap exhausted, caller falls through to the normal + stall path so a genuinely hung Mergemaster still surfaces as blocked. + + Reads ``acceptance_advance_nudge`` markers from the audit log to track + how many renudges have fired since the subtask entered + ``acceptance_passed`` (markers older than the entry timestamp are from a + prior acceptance_passed visit and do not count). Anchor is + ``sub.updated_at`` — the FSM and the subtask row are written in the + same transaction, so this equals the ``acceptance_passed`` transition + timestamp. On fire: sends, flips swarm-status active, and appends a + durable marker (marker-after-send discipline: only appended on + successful send). + """ + import asyncio + + if self._store is None: + return False + + entry_ts = _parse_ts(sub.updated_at) + if entry_ts is None: + return False + + rows = await asyncio.to_thread( + self._store.latest_audit_events, + task_id=sub.task_id, subtask_id=sub.subtask_id, + event_types=("acceptance_advance_nudge",), + ) + # Only count markers written after the subtask entered acceptance_passed; + # older markers are from a prior visit and must not consume the current cap. + last_nudge_ts: datetime | None = None + nudges_since_entry = 0 + for _, ts_str, _ in rows: + ts = _parse_ts(ts_str) + if ts is None or ts < entry_ts: + continue + nudges_since_entry += 1 + if last_nudge_ts is None: + last_nudge_ts = ts # rows are newest-first + + if nudges_since_entry >= self._config.acceptance_advance_max_renudges: + return False # cap hit — release patrol to stall path + + anchor_ts = last_nudge_ts or entry_ts + window = timedelta(seconds=self._config.acceptance_advance_backstop_seconds) + if (now - anchor_ts) < window: + return True # within window — own patrol, don't fire yet + + fired = await self._send_acceptance_advance_renudge(sub) + if not fired: + return True # transient send failure — retry next patrol + await self._flip_swarm_status_active(sub.task_id) + await asyncio.to_thread( + self._store.append_audit_event, + "acceptance_advance_nudge", + task_id=sub.task_id, + subtask_id=sub.subtask_id, + payload={"pr_number": sub.pr_number}, + ) + return True + + async def _send_acceptance_advance_renudge(self, sub: Any) -> bool: + """@mention the Mergemaster asking it to run ``cb-phase merge``. + + Resolves the Mergemaster agent id from ``_role_map``, then the room + from the store's task row. Returns True on successful send, False on + any resolution failure or send exception (logged at WARNING; does not + raise so patrol continues). + """ + from thenvoi_rest.types import ChatMessageRequest, ChatMessageRequestMentionsItem + + mm_id: str | None = next( + (aid for aid, role in self._role_map.items() if role == "mergemaster"), + None, + ) + if mm_id is None: + logger.warning( + "AcceptanceAdvance: no Mergemaster in role_map for subtask %s — cannot renudge", + sub.subtask_id, + ) + return False + + room_id = await self._resolve_room_id(sub.task_id) + if room_id is None: + logger.warning( + "AcceptanceAdvance: could not resolve room for task %s — cannot renudge %s", + sub.task_id, sub.subtask_id, + ) + return False + + pr_ref = f"PR #{sub.pr_number}" if sub.pr_number is not None else "the PR" + content = ( + f"[Watchdog] Subtask {sub.subtask_id}: {pr_ref} has passed acceptance " + f"but merge dispatch appears stalled. Please run `cb-phase merge` now." + ) + try: + await self._rest.agent_api_messages.create_agent_chat_message( + chat_id=room_id, + message=ChatMessageRequest( + content=content, + mentions=[ChatMessageRequestMentionsItem(id=mm_id)], + ), + ) + except Exception: + logger.warning( + "AcceptanceAdvance: failed to send renudge for subtask %s", + sub.subtask_id, exc_info=True, + ) + return False + return True + + async def _flip_swarm_status_active(self, task_id: str) -> None: + """Write a ``swarm status active task `` memory envelope. + + **GLOBAL EFFECT — not per-subtask.** This write makes the recorded + envelope the *latest* swarm-status entry, which un-suppresses ALL + patrol rungs for ALL subtasks in ALL active rooms. The Conductor only + writes ``waiting_human_approval`` when ALL work is blocked on approval; + once any one grant lands that precondition is false, so ``active`` is + the semantically-correct latest envelope. The Conductor re-writes + ``waiting_human_approval`` on its next active turn if the condition + still holds. + + Swallows all exceptions — this is a hint to the patrol-gate, not + gated state; a write failure means the gate reads the old envelope for + one more cycle, which is safe. + """ + content = f"swarm status active task {task_id}" + try: + if self._memory_store is not None: + await self._memory_store.store( + content=content, + system="working", + type="episodic", + segment="agent", + scope="organization", + ) + else: + from thenvoi_rest.types import MemoryCreateRequest + + await self._rest.agent_api_memories.create_agent_memory( + memory=MemoryCreateRequest( + content=content, + system="working", + type="episodic", + segment="agent", + scope="organization", + ), + ) + except Exception: + logger.debug( + "Backstop: could not flip swarm-status to active for task %s", + task_id, exc_info=True, + ) + + def _git_head(self, branch: str) -> str | None: + """Return the commit SHA at ``branch``, or ``None`` if it can't be read. + + Runs against the injected bare repo (``git -C ``) when one + was supplied at construction — cwd-independent. ``--verify`` makes a + nonexistent branch a clean non-zero exit instead of echoed garbage, + and ``--end-of-options`` stops a branch name from ever being parsed + as an option (the sweep-4 F-6 argument-injection note). A ``None`` + result is counted exactly as before — stall semantics are Batch 3's. + """ + cmd = ["git"] + if self._bare_repo is not None: + cmd += ["-C", str(self._bare_repo)] + cmd += ["rev-parse", "--verify", "--end-of-options", branch] + try: + result = subprocess.run( + cmd, + capture_output=True, text=True, timeout=30, check=False, + ) + except (OSError, subprocess.SubprocessError): + logger.debug("git rev-parse %s failed", branch, exc_info=True) + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + def _pr_updated_at(self, pr_number: int) -> datetime | None: + """Return the PR's ``updatedAt`` timestamp via ``gh``, or ``None``. + + A change in ``updatedAt`` captures any PR activity — including state + transitions — so it doubles as the PR-state progress signal. Pinned + with ``--repo `` when the runner injected one — repo identity + from config, not from whatever repo the cwd happens to be in. + """ + cmd = ["gh", "pr", "view", str(pr_number), "--json", "state,updatedAt"] + if self._repo_slug is not None: + cmd += ["--repo", self._repo_slug] + try: + result = subprocess.run( + cmd, + capture_output=True, text=True, timeout=30, check=False, + ) + except (OSError, subprocess.SubprocessError): + logger.debug("gh pr view %s failed", pr_number, exc_info=True) + return None + if result.returncode != 0: + return None + try: + data = json.loads(result.stdout) + except (ValueError, TypeError): + return None + return _parse_ts(data.get("updatedAt")) + + def _latest_transition( + self, subtask_id: str, task_id: str, + ) -> tuple[bool, datetime | None]: + """Return ``(ok, MAX(timestamp))`` from the transition log for a subtask. + + Reads the store's SQLite file directly (read-only) since the + Workstream-1 store surface does not expose a transition-log query. The + read is task-scoped — a same-id subtask from another task must not + count as progress here. + + ``ok`` distinguishes observation from absence (S6-F6): ``(True, None)`` + means the query succeeded and found no rows (e.g. before the FSM is + wired up) — an *observation*; ``(False, None)`` means the read itself + FAILED (no db path, sqlite error) and nothing was observed. + """ + db_path = getattr(self._store, "db_path", None) + if db_path is None: + return False, None + try: + conn = sqlite3.connect(db_path, timeout=5.0) + try: + row = conn.execute( + "SELECT MAX(timestamp) FROM transition_log " + "WHERE task_id = ? AND subtask_id = ?", + (task_id, subtask_id), + ).fetchone() + finally: + conn.close() + except sqlite3.Error: + logger.debug("Could not read transition_log", exc_info=True) + return False, None + if not row or row[0] is None: + return True, None + return True, _parse_ts(row[0]) + + async def _send_blocked_escalation(self, sub: Any) -> bool: + """Mark a stalled subtask blocked and notify the Conductor + human. + + The FSM owns the canonical ``blocked`` transition; we apply it via + :meth:`_mark_blocked_via_fsm`. Either way (applied or not) the human and + Conductor are alerted via a chat message in the task's room. + + Returns ``True`` when the escalation took effect — the FSM transition + applied *or* the room alert landed — so the caller can burn its + escalate-once marker (marker-after-send, S6-F7n). ``False`` means + nothing happened (no transition, no alert): the caller retries on the + next patrol instead of silently never escalating again. + + A discriminator gate runs first: when the FSM-expected actor for the + subtask's current state has a transport pin in the task's room, the + block is DEFERRED (returns ``False`` without touching FSM state or + posting). A transport condition must never mutate gated state — the + heal rung clears the pin and the next patrol re-evaluates. + """ + import asyncio + + if await self._stall_block_deferred_for_pin(sub): + return False + + visits = self._config.max_phase_visits + logger.warning( + "Subtask %s stalled: no git-HEAD change and no new transition across " + "%d patrols — marking blocked", + sub.subtask_id, visits, + ) + if self._activity: + self._activity.log( + "SUBTASK_BLOCKED", "watchdog", + f"Subtask {sub.subtask_id} blocked after {visits} patrols " + f"with no mechanical progress", + ) + + fsm_applied = await asyncio.to_thread(self._mark_blocked_via_fsm, sub) + + # Resolve the task's room so the Conductor + human (both participants) + # see the alert. Best-effort — a notify failure must not break patrol. + room_id: str | None = None + try: + task = await asyncio.to_thread(self._store.get_task, sub.task_id) + room_id = getattr(task, "room_id", None) if task else None + except Exception: + logger.debug("Could not resolve room for blocked subtask", exc_info=True) + + if room_id is None: + return fsm_applied + + from thenvoi_rest.types import ChatMessageRequest, ChatMessageRequestMentionsItem + + # Determine who to mention in the stall alert: the assigned worker + # when known, otherwise the task owner. Never pass None to + # ChatMessageRequestMentionsItem — that produces an HTTP 422 + # (mentioned_participant_not_in_room) from the server. + mention_id: str | None = getattr(sub, "assigned_worker", None) + if mention_id is None: + mention_id = await self._resolve_owner_id(sub.task_id) + if mention_id is None: + logger.debug( + "Stall-blocked alert for subtask %s: no mention target " + "(assigned_worker=None, owner unresolvable) — posting without mention", + sub.subtask_id, + ) + mentions = ( + [ChatMessageRequestMentionsItem(id=mention_id)] if mention_id else [] + ) + + suffix = "" if fsm_applied else " (blocked-transition could not be applied)" + try: + await self._rest.agent_api_messages.create_agent_chat_message( + chat_id=room_id, + message=ChatMessageRequest( + content=( + f"[Watchdog] Subtask {sub.subtask_id} appears stalled — no " + f"git-HEAD change and no new transition across {visits} " + f"patrols. Marking it blocked; Conductor please reassign or " + f"investigate.{suffix}" + ), + mentions=mentions, + ), + ) + except Exception: + logger.exception( + "Failed to post blocked-subtask alert for %s", sub.subtask_id, + ) + return fsm_applied + return True + + def _mark_blocked_via_fsm(self, sub: Any) -> bool: + """Transition the subtask to ``blocked`` via the FSM. + + Returns ``True`` if the FSM applied the transition, ``False`` if it + could not — no store available, or the transition raised (e.g. the + subtask was already terminal). The chat alert fires either way. + """ + if self._store is None: + return False + from codeband.state import fsm # noqa: PLC0415 — keep watchdog import light + try: + fsm.transition( + sub.subtask_id, sub.task_id, "blocked", + caller_role="watchdog", store=self._store, + ) + return True + except Exception: + logger.exception( + "FSM blocked-transition failed for %s", sub.subtask_id, + ) + return False + + async def _stall_block_deferred_for_pin(self, sub: Any) -> bool: + """Return ``True`` if the stall→blocked transition should be DEFERRED. + + The stall rung counts an absent transition + an absent git-HEAD change + across ``max_phase_visits`` patrols as a substantive stall. A transport + pin on the FSM-expected actor presents the same symptoms (the agent's + delivery queue is wedged at the head, so no FSM advance is possible + until the pin clears). Without this gate, a pinned actor was getting + marked ``blocked`` — and ``blocked`` poisons recovery because the only + legal exit needs a Conductor resume (not auto-triggered). + + Algorithm: + + 1. Resolve the set of FSM-expected roles for ``sub.state`` via + :func:`state_to_roles`. Terminal states → no expected actor → + proceed. + 2. Take every agent of those roles that has a REST client (i.e. is + reachable from the watchdog). + 3. For each, probe the agent's own delivery queue in the task's room + with the SAME pin criteria as the heal rung (processing record OR + pending head with ``inserted_at`` older than + ``transport_pin_threshold_seconds``). + 4. Any candidate pinned — or any probe that raises — DEFERS the + block. Probe uncertainty fails toward defer because a false block + poisons recovery; a transient transport error just delays a real + block one patrol. + + ``review_passed`` returns ``{verifier, mergemaster}`` and both are + probed: the v1 approximation can over-defer when a subtask is actually + waiting on the verifier while the mergemaster is independently pinned + on another room, but the cost is a bounded delay of the block, never a + wrong block. ``required_verdicts`` is intentionally not consulted + here. + + No-ops to ``False`` (proceed) when: + + * the transport-heal rung is disabled — the operator opted out of the + composition, so the discriminator opts out too; + * no per-agent REST clients are wired — nothing to probe with; + * the task's room cannot be resolved — the pin lookup is per-room and + we cannot tell, fall through to today's behavior; + * no candidate agent of the expected role has a REST client — also + nothing to probe with. + + Only ``self._role_map`` membership + room scoping are used (not + ``assigned_agent_id``); probing a non-participant returns an empty + mailbox for the room, so non-participants cannot cause a false defer. + """ + if not self._config.transport_heal_enabled: + return False + if not self._agent_rest_clients: + return False + + from codeband.state.fsm import state_to_roles # noqa: PLC0415 + + state = getattr(sub, "state", None) + if not isinstance(state, str): + return False + expected_roles = state_to_roles(state) + if not expected_roles: + return False + candidate_agents = [ + aid for aid, role in self._role_map.items() + if role in expected_roles and aid in self._agent_rest_clients + ] + if not candidate_agents: + return False + + room_id = await self._resolve_room_id(sub.task_id) + if room_id is None: + return False + + threshold = timedelta(seconds=self._config.transport_pin_threshold_seconds) + now = datetime.now(UTC) + for aid in candidate_agents: + client = self._agent_rest_clients[aid] + try: + pinned = await self._detect_room_pin( + aid, room_id, client, threshold, now, + ) + except Exception: + logger.info( + "Stall->blocked deferred for subtask %s: probe of agent " + "%s (role %s) in room %s raised — treating as transport-" + "pinned (fail toward defer).", + sub.subtask_id, aid, self._role_map.get(aid), room_id, + exc_info=True, + ) + if self._activity: + self._activity.log( + "AGENT_PIN_DEFER", "watchdog", + f"Stall→blocked deferred for subtask {sub.subtask_id}: " + f"probe of agent {aid} raised (fail toward defer)", + subtask_id=sub.subtask_id, + expected_role=self._role_map.get(aid), + pinned_agent=aid, + ) + return True + if pinned: + logger.info( + "Stall->blocked deferred for subtask %s: expected actor " + "%s (role %s) transport-pinned in room %s; leaving to " + "transport-heal rung.", + sub.subtask_id, aid, self._role_map.get(aid), room_id, + ) + if self._activity: + self._activity.log( + "AGENT_PIN_DEFER", "watchdog", + f"Stall→blocked deferred for subtask {sub.subtask_id}: " + f"agent {aid} transport-pinned", + subtask_id=sub.subtask_id, + expected_role=self._role_map.get(aid), + pinned_agent=aid, + ) + return True + return False + + async def _detect_room_pin( + self, + agent_id: str, + room_id: str, + client: Any, + threshold: timedelta, + now: datetime, + ) -> bool: + """Return ``True`` if ``agent_id`` has a transport pin in ``room_id``. + + Pin criteria mirror :meth:`_check_one_agent_room_pins` exactly so the + heal rung and the stall discriminator share one definition: + + * any ``processing`` record whose ``inserted_at`` is older than + ``threshold`` (crash-during-turn class), OR + * the ``pending`` HEAD (``data[0]``) whose ``inserted_at`` is older + than ``threshold`` (post-turn 422 class) — only the head pins the + cursor. + + Probe errors propagate so the caller can decide policy (the + discriminator treats them as pinned to fail toward defer); the heal + rung's own probes silence errors locally because its policy is + different (skip-and-retry). + """ + resp = await client.agent_api_messages.list_agent_messages( + chat_id=room_id, status="processing", page_size=100, + ) + for msg in list(getattr(resp, "data", None) or []): + if not isinstance(getattr(msg, "id", None), str): + continue + inserted = _parse_ts(getattr(msg, "inserted_at", None)) + if inserted is not None and (now - inserted) > threshold: + return True + + pending_resp = await client.agent_api_messages.list_agent_messages( + chat_id=room_id, status="pending", page=1, page_size=100, + ) + pending_data = list(getattr(pending_resp, "data", None) or []) + if not pending_data: + return False + head = pending_data[0] + if not isinstance(getattr(head, "id", None), str): + return False + inserted = _parse_ts(getattr(head, "inserted_at", None)) + return inserted is not None and (now - inserted) > threshold + + async def _check_blocked_subtasks(self, now: datetime) -> None: + """Escalate any ``blocked`` subtask to the owner via a Band @mention. + + Independent of how the subtask reached ``blocked`` — the watchdog's own + stall cap, the ``cb-phase verify`` attempt cap, or the FSM review-round + cap all land here. Each blocked subtask is announced to the owner once, + on the first patrol whose send succeeds (escalate-once via + ``_owner_escalated``, marker-after-send — see + :meth:`_attempt_escalation_send`). + + The owner is resolved per blocked subtask from its task row + (``task.owner_id``, persisted at kickoff), falling back to the optional + ``self._owner_id`` constructor override. Fully no-ops when no store is + wired, and skips subtasks of non-``active`` tasks entirely. When no + owner (or room) can be resolved for a subtask it is skipped WITHOUT + burning its escalate-once marker, so it can still escalate later if an + owner appears. Guarded so a store read or a notify failure never breaks + the patrol loop. + + ``_owner_escalated`` is pruned on each call: markers for subtasks that + are no longer in ``blocked`` state (e.g. resumed to ``in_progress`` via + ``cb-phase resume``) are removed so the subtask can escalate again if + it re-blocks. Sibling marker ``_integrity_alerted`` is keyed by + ``(room, chain, kind)`` — not by subtask — so it is intentionally NOT + cleared here. + """ + import asyncio + + if self._store is None: + return + + try: + subtasks = await asyncio.to_thread(self._store.list_active_subtasks) + except Exception: + logger.debug( + "Watchdog could not list subtasks for owner escalation", + exc_info=True, + ) + return + + # Prune stale escalation markers: remove keys for subtasks that are no + # longer blocked (resumed → in_progress, or otherwise left blocked) so + # they can re-escalate if they re-block. Intentionally accepts a rare + # duplicate escalation over a silent-forever blocked subtask. + currently_blocked_keys = { + (s.task_id, s.subtask_id) for s in subtasks if s.state == "blocked" + } + self._owner_escalated &= currently_blocked_keys + + # Fetch task rows once to derive both active_task_ids and the + # room/owner lookup, avoiding per-subtask get_task calls (N+1 + # elimination). _task_rows returns None on failure → degrade gracefully. + task_rows = await asyncio.to_thread(self._task_rows) + if task_rows is not None: + active_task_ids: set[str] | None = { + tid for tid, _, status, _ in task_rows if status == "active" + } + # task_id → (room_id, owner_id) + task_info: dict[str, tuple[str | None, str | None]] = { + tid: (room_id_r, owner_id_r) + for tid, room_id_r, _, owner_id_r in task_rows + } + else: + active_task_ids = None + task_info = {} + + for sub in subtasks: + key = (sub.task_id, sub.subtask_id) + if sub.state != "blocked" or key in self._owner_escalated: + continue + # Active-only: a blocked subtask of a superseded task is never + # escalated — no owner or room resolution is even attempted. + if active_task_ids is not None and sub.task_id not in active_task_ids: + continue + # Resolve owner and room from pre-fetched task info (avoids per-subtask + # get_task calls). Fall back to constructor override for owner. + info = task_info.get(sub.task_id) + owner_id = (info[1] if info else None) or self._owner_id + if owner_id is None: + continue + room_id = info[0] if info else None + if room_id is None: + continue + # Marker-after-send: burn escalate-once only when the send lands + # (or is permanently undeliverable — HTTP 422 mention rejection). + if await self._attempt_escalation_send( + self._send_owner_blocked_escalation(sub, owner_id, room_id), + target=f"owner {owner_id}", + room_id=room_id, + ): + self._owner_escalated.add(key) + + async def _resolve_owner_id(self, task_id: str) -> str | None: + """Return the initiator id for *task_id*, or the constructor override. + + Reads ``owner_id`` off the task row (persisted at kickoff). Falls back to + ``self._owner_id`` when the row carries no owner. Returns ``None`` when + neither is available. Guarded so a store read failure degrades to the + override rather than breaking the patrol. + """ + import asyncio + + owner_id: str | None = None + try: + task = await asyncio.to_thread(self._store.get_task, task_id) + owner_id = getattr(task, "owner_id", None) if task else None + except Exception: + logger.debug( + "Could not resolve owner id for task %s", task_id, exc_info=True, + ) + return owner_id or self._owner_id + + async def _resolve_room_id(self, task_id: str) -> str | None: + """Return the room id from the task row, or ``None`` if unresolvable. + + Guarded so a store read failure degrades to ``None`` (the caller skips + without burning any escalate-once marker) rather than breaking patrol. + """ + import asyncio + + try: + task = await asyncio.to_thread(self._store.get_task, task_id) + return getattr(task, "room_id", None) if task else None + except Exception: + logger.debug( + "Could not resolve room for task %s", task_id, exc_info=True, + ) + return None + + async def _send_owner_blocked_escalation( + self, sub: Any, owner_id: str, room_id: str, + ) -> None: + """@mention the owner about a blocked subtask, with its blocked reason. + + *owner_id* and *room_id* are resolved by the caller from the task row + (owner falling back to the constructor override). The owner is a + distinct room participant (not the Conductor whose credentials the + watchdog borrows), so the mention is valid. The message carries the + subtask id and the durable reason recorded on the blocked transition so + the owner has actionable context. Send failures propagate to the + caller, which owns the escalate-once marker (marker-after-send). + """ + import asyncio + + reason = ( + await asyncio.to_thread(self._blocked_reason, sub.subtask_id, sub.task_id) + or "no mechanical progress / cap reached" + ) + + from thenvoi_rest.types import ( + ChatMessageRequest, + ChatMessageRequestMentionsItem, + ) + + handle = self._owner_handle or owner_id + await self._rest.agent_api_messages.create_agent_chat_message( + chat_id=room_id, + message=ChatMessageRequest( + content=( + f"@{handle} subtask {sub.subtask_id} is BLOCKED " + f"({reason}). It needs a human decision — reassign, " + f"intervene, or abandon." + ), + mentions=[ChatMessageRequestMentionsItem(id=owner_id)], + ), + ) + # Log only after the send lands — a retried transient failure would + # otherwise record one phantom escalation per attempt. + if self._activity: + self._activity.log( + "SUBTASK_BLOCKED_OWNER_ESCALATION", "watchdog", + f"Escalated blocked subtask {sub.subtask_id} to owner {handle}", + ) + + def _blocked_reason(self, subtask_id: str, task_id: str) -> str | None: + """Return the ``reason`` of the latest ``→ blocked`` transition, if any. + + Reads the store's SQLite file directly (read-only), mirroring + :meth:`_latest_transition` — task-scoped, since subtask ids repeat + across tasks. Returns ``None`` when no blocked transition is recorded + or the reason is empty. + """ + db_path = getattr(self._store, "db_path", None) + if db_path is None: + return None + try: + conn = sqlite3.connect(db_path, timeout=5.0) + try: + row = conn.execute( + "SELECT reason FROM transition_log " + "WHERE task_id = ? AND subtask_id = ? AND to_state = 'blocked' " + "ORDER BY id DESC LIMIT 1", + (task_id, subtask_id), + ).fetchone() + finally: + conn.close() + except sqlite3.Error: + logger.debug("Could not read blocked reason", exc_info=True) + return None + if not row or not row[0]: + return None + return row[0] + + # ── ledger integrity rung (Stage-3) ──────────────────────────────────── + + async def _check_chain_integrity(self, now: datetime) -> None: + """Verify the hash chains incrementally and escalate any tamper signal. + + Two alert conditions, both rung-style owner escalations with the + marker discipline of the other rungs: + + * **chain break** — a row whose recomputed hash disagrees with its + stored ``row_hash`` (an in-place edit of a business column); + * **head regression** — the previously-verified tip is gone or + rewritten (tail truncation), which a forward chain walk by + construction cannot see, so it is checked explicitly against the + remembered ``(id, hash)``. + + Incremental by design: each patrol verifies only rows past the + remembered tip (``self._chain_tips``). Full-history verification stays + the manual ``cb verify-log``'s job. No-ops without a store; guarded so + a read failure never breaks the patrol loop. + """ + import asyncio + + if self._store is None or getattr(self._store, "db_path", None) is None: + return + + try: + problems = await asyncio.to_thread(self._verify_chains_incremental) + except Exception: + logger.debug("Watchdog chain-integrity verify failed", exc_info=True) + return + if not problems: + return + + await self._escalate_integrity_problems( + problems, marker_set=self._integrity_alerted, source="watchdog", + ) + + async def _escalate_integrity_problems( + self, + problems: list[tuple[str, str, str]], + *, + marker_set: set[tuple[str, str, str]], + source: str, + ) -> None: + """Owner-escalate each ledger-integrity problem once per (room, chain, kind). + + Shared by the incremental and full-history rungs. An integrity break is + a global event — it escalates into every ACTIVE task's room to its + owner, a single time per (room, chain, kind), with the same + marker-after-send discipline as the blocked rung. ``marker_set`` is the + calling rung's own escalate-once set (the two rungs keep SEPARATE sets so + a finding from one never suppresses the other); ``source`` attributes the + alert (``"watchdog"`` for the incremental rung, ``"verifier"`` for the + full-history sweep). + """ + import asyncio + + task_rows = await asyncio.to_thread(self._task_rows) + active = [ + (room, owner) for _, room, status, owner in (task_rows or []) + if status == "active" + ] + for chain_name, kind, detail in problems: + for room_id, owner in active: + owner_id = owner or self._owner_id + if owner_id is None or room_id is None: + continue + mkey = (room_id, chain_name, kind) + if mkey in marker_set: + continue + if await self._attempt_escalation_send( + self._send_integrity_alert( + room_id, owner_id, chain_name, kind, detail, + source=source, + ), + target=f"owner {owner_id}", + room_id=room_id, + ): + marker_set.add(mkey) + + def _verify_chains_incremental(self) -> list[tuple[str, str, str]]: + """Incrementally verify both chains; return ``(chain, kind, detail)`` problems. + + Reads the store's SQLite file directly (read-only), like the watchdog's + other DB readers. Advances ``self._chain_tips`` past every verified row + so the next patrol only re-checks new rows. On a head regression the + tip is re-baselined to the current physical tip so forward verification + resumes (the alert has already fired; the escalate-once marker stops a + repeat). + """ + from codeband.state.store import ( + AUDIT_HASH_COLS, + TRANSITION_HASH_COLS, + verify_chain, + ) + + problems: list[tuple[str, str, str]] = [] + conn = sqlite3.connect(self._store.db_path, timeout=5.0) + conn.row_factory = sqlite3.Row + try: + for table, cols in ( + ("transition_log", TRANSITION_HASH_COLS), + ("audit_log", AUDIT_HASH_COLS), + ): + last_id, last_hash = self._chain_tips.get(table, (0, None)) + + # Head regression: the remembered tip row is gone or its hash + # was rewritten, or the physical max id fell below it + # (truncation). A pure forward walk from last_id cannot see + # this, so check it before walking. + if last_id > 0: + tip = conn.execute( + f"SELECT row_hash FROM {table} WHERE id = ?", # noqa: S608 — fixed literal + (last_id,), + ).fetchone() + max_row = conn.execute( + f"SELECT MAX(id) AS m FROM {table}" # noqa: S608 — fixed literal + ).fetchone() + cur_max = max_row["m"] if max_row is not None else None + if ( + tip is None + or tip["row_hash"] != last_hash + or (cur_max is not None and cur_max < last_id) + ): + problems.append(( + table, + "head_regression", + f"remembered head id={last_id} is gone or rewritten " + f"(current max id={cur_max}) — possible tail truncation", + )) + # Re-baseline to the current physical tip so forward + # verification resumes next cycle. + cur = conn.execute( + f"SELECT id, row_hash FROM {table} " # noqa: S608 — fixed literal + "ORDER BY id DESC LIMIT 1" + ).fetchone() + self._chain_tips[table] = ( + (cur["id"], cur["row_hash"]) if cur is not None + else (0, None) + ) + continue + + result = verify_chain( + conn, table, cols, after_id=last_id, prev_hash=last_hash, + ) + if not result.ok: + problems.append(( + table, + "chain_break", + f"first broken row id={result.broken_id}: expected " + f"row_hash {result.expected_hash}, stored " + f"{result.actual_hash}", + )) + # Advance the tip to the last good row (the break stops the + # walk, so head_id/head_hash is the last verified row before it). + self._chain_tips[table] = (result.head_id, result.head_hash) + finally: + conn.close() + return problems + + async def _check_chain_integrity_full(self, now: datetime) -> None: + """Deep full-history integrity sweep on a longer cadence (verifier role). + + Co-located with the incremental rung (:meth:`_check_chain_integrity`) + but distinct in three ways: + + * **Cadence** — runs every ``full_integrity_interval_patrols`` patrols, + not every patrol, because re-hashing the whole ledger is more work. + * **Coverage** — walks both chains from row 1, so it catches the + incremental rung's structural blind spot: an in-place edit of an + INTERIOR, already-verified row (id below the remembered tip), which a + forward-from-tip walk never re-reads and the head-regression check + (which inspects only the remembered tip) cannot see either. + * **Attribution** — findings are attributed to the verifier role; the + deep evidence-integrity sweep is conceptually the verifier's job. It + is code-driven and runs whether or not a verifier LLM seat is + allocated — integrity is a safety sweep, not an LLM behavior. + + Same owner-escalation + escalate-once (per room, chain, kind) discipline + as the incremental rung, with its own marker set so the two rungs never + suppress each other. No-ops without a store; guarded so a read failure + never breaks the patrol loop. + """ + import asyncio + + if self._store is None or getattr(self._store, "db_path", None) is None: + return + + # Longer cadence: only sweep on every Nth patrol. Counting here (rather + # than off the main patrol counter) keeps the rung self-contained and + # the cadence independent of the other rungs. + self._full_integrity_patrol_count += 1 + interval = self._config.full_integrity_interval_patrols + if self._full_integrity_patrol_count % interval != 0: + return + + try: + problems = await asyncio.to_thread(self._verify_chains_full) + except Exception: + logger.debug( + "Watchdog full-history integrity verify failed", exc_info=True, + ) + return + if not problems: + return + + await self._escalate_integrity_problems( + problems, marker_set=self._full_integrity_alerted, source="verifier", + ) + + def _verify_chains_full(self) -> list[tuple[str, str, str]]: + """Verify both hash chains from row 1 (genesis); return ``(chain, kind, detail)``. + + The deep counterpart to :meth:`_verify_chains_incremental`: it re-reads + and re-hashes EVERY row rather than only rows past the remembered tip, + reusing ``cb verify-log``'s whole-chain logic (``verify_chain`` with the + default ``after_id=0``). This is the only rung that catches an in-place + edit of an interior, already-verified row. Read-only and stateless — + deliberately never touches ``self._chain_tips`` (that belongs to the + incremental rung; the two rungs stay fully decoupled). + """ + from codeband.state.store import ( + AUDIT_HASH_COLS, + TRANSITION_HASH_COLS, + verify_chain, + ) + + problems: list[tuple[str, str, str]] = [] + conn = sqlite3.connect(self._store.db_path, timeout=5.0) + conn.row_factory = sqlite3.Row + try: + for table, cols in ( + ("transition_log", TRANSITION_HASH_COLS), + ("audit_log", AUDIT_HASH_COLS), + ): + result = verify_chain(conn, table, cols) + if not result.ok: + problems.append(( + table, + "chain_break", + f"first broken row id={result.broken_id}: expected " + f"row_hash {result.expected_hash}, stored " + f"{result.actual_hash}", + )) + finally: + conn.close() + return problems + + async def _send_integrity_alert( + self, room_id: str, owner_id: str, chain_name: str, kind: str, + detail: str, *, source: str = "watchdog", + ) -> None: + """@mention the owner about a ledger integrity break. + + Sent with the watchdog's (Conductor's) credentials, like the other + rungs. The owner is a distinct room participant, so the mention is + valid. ``source`` names which rung found the break — ``"watchdog"`` for + the per-patrol incremental check, ``"verifier"`` for the deep + full-history sweep — and is carried into both the chat text and the + activity-log actor so a full-history finding is attributed to the + verifier role. Send failures propagate to the caller, which owns the + escalate-once marker (marker-after-send). + """ + from thenvoi_rest.types import ( + ChatMessageRequest, + ChatMessageRequestMentionsItem, + ) + + label = ( + "chain break (a row was edited in place)" + if kind == "chain_break" + else "head regression (rows may have been truncated)" + ) + sweep = ( + "verifier full-history integrity sweep" + if source == "verifier" + else "watchdog integrity check" + ) + handle = self._owner_handle or owner_id + await self._rest.agent_api_messages.create_agent_chat_message( + chat_id=room_id, + message=ChatMessageRequest( + content=( + f"@{handle} LEDGER INTEGRITY ALERT ({sweep}) — " + f"{chain_name}: {label}. " + f"{detail}. Run `cb verify-log` and investigate: the state " + "ledger may have been modified out of band." + ), + mentions=[ChatMessageRequestMentionsItem(id=owner_id)], + ), + ) + if self._activity: + self._activity.log( + "LEDGER_INTEGRITY_ALERT", source, + f"{chain_name} {kind}: {detail}", + ) + + # ── transport-health (turn-boundary 422 pin) heal rung ───────────────── + + async def _check_transport_pins( + self, + rooms: list[Any], + inactive_rooms: set[str], + now: datetime, + ) -> None: + """Detect and HEAL turn-boundary 422 cursor pins. + + When the agent finishes its turn, the SDK marks the inbound delivery + ``processed``. If that POST returns 422 (no active processing attempt), + the delivery falls back into the ``pending`` bucket head-of-queue and + the agent's cursor is pinned — the agent will not pull the next message + because the transport layer sees the old delivery as still unprocessed. + A chat nudge cannot wake a pinned agent (its cursor is wedged below + the new message). + + This rung probes BOTH buckets per (agent, room): + - ``pending`` (post-turn 422 class): 2-step re-open (``/processing``) + then complete (``/processed``), verified by re-listing the head. + Cursor advance confirmed by re-list = success; same head = failed + attempt; verify error = unknown (retry next patrol). + - ``processing`` (crash-during-turn class): 1-step ``/processed`` + re-assert. A 422 here means the delivery is already processed; + treated as a benign idempotent no-op. + + Only deliveries whose ``inserted_at`` is older than + ``transport_pin_threshold_seconds`` are considered pins — this T is + LONGER than any plausible real turn (default 1800s), so mid-turn + deliveries are never touched. + + Per-agent REST clients are required because both the probes and the + heals act on the calling agent's own delivery row — the Conductor's + credentials only see/heal the Conductor's deliveries. The watchdog + runs on the Conductor's credential; its entry in ``agent_rest_clients`` + is swept like any other agent so the Conductor's own pinned cursor is + healed (the threshold guard keeps live turns untouched). When + ``agent_rest_clients`` is empty (or the kill switch is off), the rung + is a no-op and the existing nudge / escalation paths are unaffected. + """ + if not self._config.transport_heal_enabled: + return + if not self._agent_rest_clients: + return + + threshold = timedelta(seconds=self._config.transport_pin_threshold_seconds) + room_ids = [ + r.id for r in rooms if getattr(r, "id", None) not in inactive_rooms + ] + for agent_id, client in self._agent_rest_clients.items(): + for room_id in room_ids: + await self._check_one_agent_room_pins( + agent_id, room_id, client, threshold, now, + ) + + async def _check_one_agent_room_pins( + self, + agent_id: str, + room_id: str, + client: Any, + threshold: timedelta, + now: datetime, + ) -> None: + """Probe one (agent, room) for pinned deliveries and heal each.""" + from thenvoi_rest.core.api_error import ApiError + + try: + resp = await client.agent_api_messages.list_agent_messages( + chat_id=room_id, status="processing", page_size=100, + ) + except ApiError: + logger.debug( + "Transport-heal: list-processing failed for %s in %s", + agent_id, room_id, exc_info=True, + ) + return + except Exception: + logger.debug( + "Transport-heal: list-processing crashed for %s in %s", + agent_id, room_id, exc_info=True, + ) + return + + for msg in list(getattr(resp, "data", None) or []): + msg_id = getattr(msg, "id", None) + if not isinstance(msg_id, str): + continue + # Use ``inserted_at`` as a conservative proxy for the delivery's + # ``started_at``: a delivery's /processing call is always after the + # message was inserted, so ``started_at >= inserted_at`` — using + # ``inserted_at`` UNDER-counts pin age and only fires later than a + # true ``started_at`` read would. The SDK does not surface the + # delivery's ``started_at`` on the ChatMessage record. + inserted = _parse_ts(getattr(msg, "inserted_at", None)) + if inserted is None or (now - inserted) <= threshold: + continue + await self._attempt_pin_heal( + agent_id, room_id, msg_id, client, bucket="processing", + ) + + # --- pending probe (post-turn 422 class) --- + # A post-turn 422 leaves the poison in the pending bucket (no active + # processing attempt), not processing. Only the head (data[0]) pins + # the cursor; re-asserting /processing on non-head entries creates + # junk attempts and must be avoided. + try: + pending_resp = await client.agent_api_messages.list_agent_messages( + chat_id=room_id, status="pending", page=1, page_size=100, + ) + except ApiError: + logger.debug( + "Transport-heal: list-pending failed for %s in %s", + agent_id, room_id, exc_info=True, + ) + return + except Exception: + logger.debug( + "Transport-heal: list-pending crashed for %s in %s", + agent_id, room_id, exc_info=True, + ) + return + + pending_data = list(getattr(pending_resp, "data", None) or []) + if not pending_data: + return + head = pending_data[0] + head_id = getattr(head, "id", None) + if not isinstance(head_id, str): + return + inserted = _parse_ts(getattr(head, "inserted_at", None)) + if inserted is None or (now - inserted) <= threshold: + return + await self._attempt_pin_heal( + agent_id, room_id, head_id, client, bucket="pending", + ) + + async def _attempt_pin_heal( + self, agent_id: str, room_id: str, message_id: str, client: Any, + *, bucket: str = "processing", + ) -> None: + """Heal one stuck delivery; behavior differs by ``bucket``. + + ``bucket="processing"`` (crash-during-turn class): 1-step + ``mark_agent_message_processed``. A 422 means the delivery is already + processed — cursor already advanced; idempotent no-op. + + ``bucket="pending"`` (post-turn 422 class): a post-turn 422 leaves + the delivery in the ``pending`` bucket (no active processing attempt), + so ``/processed`` alone 422s and must not be mis-read as success. + The 2-step heal re-opens a processing attempt + (``mark_agent_message_processing``) then completes it + (``mark_agent_message_processed``), then VERIFIES via a pending-head + re-list that the cursor actually advanced. Success is decided by the + verify (head advanced or absent), not by swallowing a 422. + + After ``transport_heal_max_attempts`` verify-failures on the same + pending pin (or non-422 failures on a processing pin), escalate to the + owner once and stop healing this delivery. + """ + from thenvoi_rest.core.api_error import ApiError + + pin_key = (agent_id, message_id) + if pin_key in self._pin_escalated: + return + + if bucket == "pending": + # Step a: re-open a processing attempt so /processed won't 422. + try: + await client.agent_api_messages.mark_agent_message_processing( + chat_id=room_id, id=message_id, + ) + except Exception: + logger.debug( + "Transport-heal: mark-processing step failed for " + "agent=%s msg=%s in room=%s", + agent_id, message_id, room_id, exc_info=True, + ) + # Step b: terminate the attempt. + try: + await client.agent_api_messages.mark_agent_message_processed( + chat_id=room_id, id=message_id, + ) + except Exception: + logger.debug( + "Transport-heal: mark-processed step failed for " + "agent=%s msg=%s in room=%s", + agent_id, message_id, room_id, exc_info=True, + ) + # Step c: verify by re-listing the pending head. A 422 on step b + # is expected when the delivery already advanced; the re-list is + # what decides success — do NOT treat a swallowed 422 as success. + try: + verify_resp = await client.agent_api_messages.list_agent_messages( + chat_id=room_id, status="pending", page=1, page_size=100, + ) + verify_data = list(getattr(verify_resp, "data", None) or []) + head_still_pinned = ( + bool(verify_data) + and getattr(verify_data[0], "id", None) == message_id + ) + except Exception: + logger.debug( + "Transport-heal: pending verify re-list failed for " + "agent=%s msg=%s in room=%s — unknown outcome, retry next patrol", + agent_id, message_id, room_id, exc_info=True, + ) + return # UNKNOWN — do not update tracking + if not head_still_pinned: + # Cursor advanced (head gone or replaced) — success. + self._pin_heal_attempts.pop(pin_key, None) + logger.info( + "Transport-heal: pending pin healed for agent=%s msg=%s " + "in room=%s (cursor advanced)", + agent_id, message_id, room_id, + ) + if self._activity: + self._activity.log( + "AGENT_PIN_HEALED", "watchdog", + f"Healed transport pin for {agent_id} msg={message_id}", + branch="pending_2step", + pin_class="pending", + ) + else: + # Same head — heal did not advance the cursor. + attempts = self._pin_heal_attempts.get(pin_key, 0) + 1 + self._pin_heal_attempts[pin_key] = attempts + logger.warning( + "Transport-heal: pending heal attempt %d/%d did not " + "advance cursor for agent=%s msg=%s in room=%s", + attempts, self._config.transport_heal_max_attempts, + agent_id, message_id, room_id, + ) + if attempts >= self._config.transport_heal_max_attempts: + await self._escalate_unhealable_pin( + agent_id, room_id, message_id, attempts, + ) + self._pin_escalated.add(pin_key) + return + + # --- processing bucket: 1-step (unchanged) --- + try: + await client.agent_api_messages.mark_agent_message_processed( + chat_id=room_id, id=message_id, + ) + except ApiError as e: + if e.status_code == 422: + # Already processed → cursor already advanced. Idempotent + # no-op; drop tracking so the slot frees up. + self._pin_heal_attempts.pop(pin_key, None) + logger.debug( + "Transport-heal: delivery %s for %s already processed " + "(422) — treating as no-op", + message_id, agent_id, + ) + return + attempts = self._pin_heal_attempts.get(pin_key, 0) + 1 + self._pin_heal_attempts[pin_key] = attempts + logger.warning( + "Transport-heal attempt %d/%d failed for agent=%s msg=%s " + "in room=%s: HTTP %s", + attempts, self._config.transport_heal_max_attempts, + agent_id, message_id, room_id, e.status_code, + ) + if attempts >= self._config.transport_heal_max_attempts: + await self._escalate_unhealable_pin( + agent_id, room_id, message_id, attempts, + ) + self._pin_escalated.add(pin_key) + return + except Exception: + attempts = self._pin_heal_attempts.get(pin_key, 0) + 1 + self._pin_heal_attempts[pin_key] = attempts + logger.warning( + "Transport-heal attempt %d/%d crashed for agent=%s msg=%s " + "in room=%s", + attempts, self._config.transport_heal_max_attempts, + agent_id, message_id, room_id, exc_info=True, + ) + if attempts >= self._config.transport_heal_max_attempts: + await self._escalate_unhealable_pin( + agent_id, room_id, message_id, attempts, + ) + self._pin_escalated.add(pin_key) + return + + # 2xx: cursor advanced. Clear tracking so a future fresh pin on the + # same agent/message starts at attempt 0 (current message_id is one- + # shot, so this is mostly defensive). + self._pin_heal_attempts.pop(pin_key, None) + logger.info( + "Transport-heal: re-asserted processed for agent=%s msg=%s " + "in room=%s (cursor advanced)", + agent_id, message_id, room_id, + ) + if self._activity: + self._activity.log( + "AGENT_PIN_HEALED", "watchdog", + f"Healed transport pin for {agent_id} msg={message_id}", + branch="processing_1step", + pin_class="processing", + ) + + async def _escalate_unhealable_pin( + self, agent_id: str, room_id: str, message_id: str, attempts: int, + ) -> None: + """Owner-escalate a pin that survived ``max_attempts`` heal tries. + + Actively @-mentions the owner (``self._owner_id`` / ``self._owner_handle``, + set by the runner at construction) so the human is woken instead of + relying on them seeing a passive room post. The pinned agent is + intentionally NOT @-mentioned: a wedged cursor cannot read inbound + chat anyway, so the mention would be wasted and risks another 422 + storm. When no owner id is configured (e.g. the runner has not wired + it for this swarm) the post degrades to mention-less, preserving the + pre-upgrade behavior. Best-effort: a send failure is logged but does + not break patrol. + """ + from thenvoi_rest.types import ( + ChatMessageRequest, + ChatMessageRequestMentionsItem, + ) + + threshold = self._config.transport_pin_threshold_seconds + logger.critical( + "Transport-heal: unable to clear pin for agent=%s msg=%s in " + "room=%s after %d attempts — escalating", + agent_id, message_id, room_id, attempts, + ) + if self._activity: + self._activity.log( + "AGENT_PIN_UNHEALABLE", "watchdog", + f"Pin survived {attempts} heals: {agent_id} msg={message_id}", + ) + mentions: list[Any] = [] + owner_prefix = "" + if self._owner_id: + handle = self._owner_handle or self._owner_id + mentions = [ChatMessageRequestMentionsItem(id=self._owner_id)] + owner_prefix = f"@{handle} " + try: + await self._rest.agent_api_messages.create_agent_chat_message( + chat_id=room_id, + message=ChatMessageRequest( + content=( + f"{owner_prefix}[Watchdog] Transport pin on agent " + f"{agent_id} (message {message_id}) survived " + f"{attempts} heal attempts after >{threshold}s in " + f"processing. Owner: investigate or restart the agent." + ), + mentions=mentions, + ), + ) + except Exception: + logger.exception( + "Transport-heal: escalation post failed for agent=%s in room=%s", + agent_id, room_id, + ) + async def _send_nudge( self, room_id: str, agent_id: str, names: dict[str, str], ) -> None: diff --git a/src/codeband/cli.py b/src/codeband/cli/__init__.py similarity index 65% rename from src/codeband/cli.py rename to src/codeband/cli/__init__.py index b808af6..d07be70 100644 --- a/src/codeband/cli.py +++ b/src/codeband/cli/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging import os import subprocess @@ -19,12 +20,15 @@ Framework, RepoConfig, load_config, + resolve_workspace_path, ) from codeband.orchestration.compose import ( ComposeFileNotFound, find_compose_file as _find_compose_file, ) +logger = logging.getLogger(__name__) + def _load_project_dotenv(project_dir: str) -> None: """Load ``.env`` from the project dir if present, else fall back to CWD search. @@ -153,6 +157,14 @@ def _resolve_claude_auth() -> None: """ if not os.environ.get("ANTHROPIC_API_KEY"): return + # Explicit opt-out of subscription-first. Set CODEBAND_CLAUDE_PREFER_API_KEY=1 + # to keep ANTHROPIC_API_KEY even when an OAuth source exists, restoring the + # Claude CLI's native precedence (API key over OAuth) — e.g. when parallel + # coders would exhaust a subscription's rate limits. + if os.environ.get("CODEBAND_CLAUDE_PREFER_API_KEY", "").strip().lower() in ( + "1", "true", "yes", "on", + ): + return if ( os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") or _has_claude_subscription_oauth() @@ -174,10 +186,12 @@ def _has_codex_subscription_auth() -> bool: if not auth_file.is_file(): return False try: - content = auth_file.read_text(encoding="utf-8", errors="replace") - except OSError: + data = json.loads(auth_file.read_text(encoding="utf-8", errors="replace")) + except (OSError, ValueError): + return False + if not isinstance(data, dict): return False - return '"auth_mode": "ChatGPT"' in content + return str(data.get("auth_mode", "")).lower() == "chatgpt" def _resolve_codex_auth() -> None: @@ -191,6 +205,13 @@ def _resolve_codex_auth() -> None: """ if not os.environ.get("OPENAI_API_KEY"): return + # Explicit opt-out of subscription-first. Set CODEBAND_CODEX_PREFER_API_KEY=1 + # to keep OPENAI_API_KEY even when ChatGPT subscription auth exists, restoring + # the Codex CLI's native API-key path for parallel workloads. + if os.environ.get("CODEBAND_CODEX_PREFER_API_KEY", "").strip().lower() in ( + "1", "true", "yes", "on", + ): + return if _has_codex_subscription_auth(): os.environ.setdefault( "CODEBAND_FALLBACK_OPENAI_API_KEY", @@ -199,6 +220,21 @@ def _resolve_codex_auth() -> None: del os.environ["OPENAI_API_KEY"] +def _clear_auth_fallbacks() -> None: + """Remove CODEBAND_FALLBACK_* keys from the process environment. + + The fallback keys are set by ``_resolve_claude_auth`` / + ``_resolve_codex_auth`` so preflight can restore the original API key if + the subscription path exhausts during the check. Once preflight completes + (or is skipped), the fallbacks serve no purpose — but they *are* still in + ``os.environ``, so every subprocess the SDK spawns (Codex CLI, bash tool + calls from agents with shell access) inherits them. Clearing here ensures + the raw API-key value cannot be read by child processes. + """ + os.environ.pop("CODEBAND_FALLBACK_ANTHROPIC_API_KEY", None) + os.environ.pop("CODEBAND_FALLBACK_OPENAI_API_KEY", None) + + @click.group(invoke_without_command=True) @click.option( "--dir", "project_dir", default=".", @@ -258,9 +294,14 @@ def cli( def init(repo: str, branch: str, project_dir: str) -> None: """Initialize a Codeband project. - The default config uses 8 Band.ai agents (fits the free-tier 10-cap): + The default config uses 10 Band.ai agents (exactly the free-tier 10-cap): 1 Claude coder + 1 Codex coder + 1 reviewer of each framework + - 1 Claude planner + 1 Codex plan-reviewer + conductor + mergemaster. + 1 verifier of each framework + 1 Claude planner + 1 Codex plan-reviewer + + conductor + mergemaster. The verifiers make `verify_acceptance` a required, + SHA-pinned merge verdict; drop a verifier vendor to `count: 0` to reclaim a + seat (acceptance still gates via the remaining vendor; cb doctor warns). + The local `verify` verdict is opt-in: set `agents.handoff_verify_command` + when the repository has a real test/build command to gate handoff. To scale up on paid tier: edit `codeband.yaml` or use `cb scale`. """ @@ -293,6 +334,7 @@ def init(repo: str, branch: str, project_dir: str) -> None: "# 3. ANTHROPIC_API_KEY fallback only if subscription usage is exhausted\n" "# Codeband auto-strips ANTHROPIC_API_KEY while subscription auth is usable,\n" "# then restores it only after a Claude Pro/Max usage-limit error.\n" + "# Set CODEBAND_CLAUDE_PREFER_API_KEY=1 to force API-key precedence.\n" "#\n" "# Option A: Anthropic API key — pay-per-token, no subscription caps.\n" "ANTHROPIC_API_KEY=sk-ant-...\n" @@ -310,6 +352,7 @@ def init(repo: str, branch: str, project_dir: str) -> None: "# If OPENAI_API_KEY is also set, it is used only after Codex reports\n" "# a subscription usage-limit error. If you don't use Codex agents,\n" "# leave this unset.\n" + "# Set CODEBAND_CODEX_PREFER_API_KEY=1 to force API-key precedence.\n" "OPENAI_API_KEY=sk-...\n" "#\n" "# ── Platform & GitHub ───────────────────────────────────────────\n" @@ -356,8 +399,9 @@ def scale(spec: str, project_dir: str) -> None: cb scale coders.claude_sdk=4 # 4 Claude coders cb scale coders.codex=0 # opt out of Codex coders cb scale reviewers.claude_sdk=2 # 2 Claude reviewers + cb scale verifiers.codex=0 # opt out of the Codex verifier - Valid pools: planners, plan_reviewers, coders, reviewers. + Valid pools: planners, plan_reviewers, coders, reviewers, verifiers. Valid frameworks: claude_sdk, codex. Then run `cb setup-agents` to register any new pool identities. """ @@ -421,6 +465,69 @@ def setup_agents(project_dir: str) -> None: _run_async(register_all_agents(config, project)) +async def _provision_coordinator_identity( + config: CodebandConfig, + project: Path, + band_api_key: str, +) -> tuple[str, str]: + """Register a fresh codeband-session-* agent for this run. + + Sets CODEBAND_SESSION_AGENT_KEY in the process environment so + send_room_message posts as the session agent (not the human key) and so + the runner's heartbeat loop fires. Also sweeps stale session agents and + attempts late enrollment in any active task room. + + Returns (agent_id, band_api_key) for clean-exit cleanup by the caller. + Non-fatal for the rest of the run: callers must catch and log exceptions. + """ + from thenvoi_rest import AsyncRestClient + + from codeband.orchestration.session_agent import ( + enroll_session_agent_in_room, + register_session_agent, + repo_slug_from_project, + sweep_stale_session_agents, + ) + from codeband.state.registration import read_room_pointer, resolve_state_dir + + repo = repo_slug_from_project(project) + + await sweep_stale_session_agents( + band_api_key=band_api_key, + rest_url=config.band.rest_url, + ) + + try: + _profile_client = AsyncRestClient( + api_key=band_api_key, base_url=config.band.rest_url, + ) + _profile = await _profile_client.human_api_profile.get_my_profile() + owner_id: str = getattr(_profile.data, "id", None) or "unknown" + except Exception: + owner_id = "unknown" + + agent_id, api_key = await register_session_agent( + owner_id, repo, + rest_url=config.band.rest_url, + band_api_key=band_api_key, + ) + os.environ["CODEBAND_SESSION_AGENT_KEY"] = api_key + + try: + room_id = read_room_pointer(project, resolve_state_dir(config, project)) + if room_id: + await enroll_session_agent_in_room( + session_agent_key=api_key, + band_api_key=band_api_key, + room_id=room_id, + rest_url=config.band.rest_url, + ) + except Exception: + logger.debug("Late room enrollment failed — skipping", exc_info=True) + + return agent_id, band_api_key + + @cli.command() @click.option("--agent", default=None, help="Run a single agent by key (distributed mode)") @click.option("--debug", is_flag=True, help="Enable verbose debug logging") @@ -429,8 +536,15 @@ def setup_agents(project_dir: str) -> None: "--skip-preflight", is_flag=True, help="Skip the Claude auth preflight check (advanced; use for offline/CI).", ) +@click.option( + "--fresh", is_flag=True, + help="Skip rejoining existing rooms and their backlog (fresh start).", +) @_project_aware -def run(agent: str | None, debug: bool, project_dir: str, skip_preflight: bool) -> None: +def run( + agent: str | None, debug: bool, project_dir: str, + skip_preflight: bool, fresh: bool, +) -> None: """Run agents locally. Without --agent: runs all agents in-process (local mode). @@ -471,21 +585,67 @@ def run(agent: str | None, debug: bool, project_dir: str, skip_preflight: bool) raise click.ClickException(err.remediation) raise click.ClickException(f"{err.summary}\n\n{err.remediation}") - if agent: - click.echo(f"Starting agent {agent}... (Ctrl+C to stop)") - from codeband.orchestration.runner import run_agent - _run_async(run_agent(config, project, agent)) - else: - if config.workspace.mode == DeploymentMode.DISTRIBUTED: - click.echo( - "Warning: workspace.mode is 'distributed' but running all agents locally. " - "Use --agent to run a single agent, or set mode to 'local'.", - err=True, - ) - total = config.agents.total_agent_count() - click.echo(f"Starting Codeband with {total} agents... (Ctrl+C to stop)") - from codeband.orchestration.runner import run_local - _run_async(run_local(config, project)) + # Fallback keys (CODEBAND_FALLBACK_ANTHROPIC_API_KEY / + # CODEBAND_FALLBACK_OPENAI_API_KEY) are only needed during preflight. + # Remove them before spawning agent children so they don't inherit the + # raw API-key values via the subprocess environment. + _clear_auth_fallbacks() + + # Provision a fresh codeband-session-* identity for this run so that + # send_room_message posts as the session agent (not the human key) and + # the runner's heartbeat loop fires. Non-fatal: if BAND_API_KEY is absent + # or registration fails, cb run continues without a session-agent identity. + _sa_agent_id: str | None = None + _sa_band_key: str | None = None + if not os.environ.get("CODEBAND_SESSION_AGENT_KEY"): + _bk = os.environ.get("BAND_API_KEY") + if _bk: + try: + _sa_agent_id, _sa_band_key = asyncio.run( + _provision_coordinator_identity(config, project, _bk) + ) + except Exception: + logger.warning( + "Session agent provisioning failed — continuing without " + "coordinator identity", + exc_info=True, + ) + + try: + if agent: + if fresh: + click.echo( + "Warning: --fresh only applies to local mode (it controls the " + "in-process startup room sweep); ignored with --agent.", + err=True, + ) + click.echo(f"Starting agent {agent}... (Ctrl+C to stop)") + from codeband.orchestration.runner import run_agent + _run_async(run_agent(config, project, agent)) + else: + if config.workspace.mode == DeploymentMode.DISTRIBUTED: + click.echo( + "Warning: workspace.mode is 'distributed' but running all agents locally. " + "Use --agent to run a single agent, or set mode to 'local'.", + err=True, + ) + total = config.agents.total_agent_count() + click.echo(f"Starting Codeband with {total} agents... (Ctrl+C to stop)") + from codeband.orchestration.runner import run_local + _run_async(run_local(config, project, fresh=fresh)) + finally: + if _sa_agent_id and _sa_band_key: + try: + from codeband.orchestration.session_agent import delete_session_agent + asyncio.run( + delete_session_agent( + _sa_agent_id, + band_api_key=_sa_band_key, + rest_url=config.band.rest_url, + ) + ) + except Exception: + logger.debug("Session agent cleanup on exit failed", exc_info=True) click.echo("All agents stopped.") @@ -504,6 +664,169 @@ def task(description: str, project_dir: str) -> None: _run_async(send_task(config, project, description)) +@cli.command("register-task") +@click.option("--room", "room_id", required=True, help="Band room UUID to register as the active task") +@click.option("--owner", "owner_id", required=True, + help="Band participant id of the task owner (required — no default)") +@click.option("--owner-handle", default=None, help="Human-readable handle for the owner") +@click.option("--description", required=True, help="Task description to store on the row") +@click.option("--dir", "project_dir", default=".", help="Project directory") +@_project_aware +def register_task_cmd( + room_id: str, + owner_id: str, + owner_handle: str | None, + description: str, + project_dir: str, +) -> None: + """Register an existing task room in the durable state store. + + Writes the tasks row and the .codeband_room pointer atomically (row-first) + via the same primitive `cb task` uses — for peer seeders that create the + room themselves (e.g. /codeband) and need cb-phase to resolve it. Makes no + network calls and never resolves an owner for you: pass the seeding + participant's id explicitly. + """ + project = Path(project_dir).resolve() + config = load_config(project) + + from codeband.state import StateStore + from codeband.state.registration import register_task + + workspace_path = resolve_workspace_path(config, project) + store = StateStore(workspace_path / "state" / "orchestration.db") + + try: + result = register_task( + room_id=room_id, + description=description, + owner_id=owner_id, + agents=config.agents, + owner_handle=owner_handle, + project_dir=project, + store=store, + ) + except Exception as exc: # noqa: BLE001 - one exit point for any failure + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + if result.outcome == "superseded": + click.echo(f"Superseded task {result.superseded_task_id}; registered {result.room_id}") + elif result.outcome == "re-registered": + click.echo(f"Re-registered task {result.room_id} (owner updated)") + else: + click.echo(f"Registered task {result.room_id}") + + +@cli.group("session-agent") +def session_agent_group() -> None: + """Manage the per-session Band agent lifecycle.""" + + +@session_agent_group.command("register") +@click.option("--owner", "owner_id", required=True, + help="Band participant id of the session owner (the human operator)") +@click.option("--dir", "project_dir", default=".", help="Project directory") +@_project_aware +def session_agent_register_cmd(owner_id: str, project_dir: str) -> None: + """Mint and register a per-session Band agent. + + Prints the new agent's api_key as the last line on stdout so the calling + skill can capture it for export as CODEBAND_SESSION_AGENT_KEY. + """ + from codeband.orchestration.session_agent import ( + register_session_agent, + repo_slug_from_project, + ) + + project = Path(project_dir).resolve() + config = load_config(project) + api_key = os.environ.get("BAND_API_KEY") + if not api_key: + click.echo( + "Error: BAND_API_KEY is required for session-agent register.", err=True, + ) + sys.exit(1) + + repo = repo_slug_from_project(project) + + try: + _agent_id, agent_api_key = _run_async( + register_session_agent( + owner_id, + repo, + rest_url=config.band.rest_url, + band_api_key=api_key, + ) + ) + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + click.echo(f"Registered session agent for repo '{repo}' (owner: {owner_id})") + # Last line bare — skill captures this for CODEBAND_SESSION_AGENT_KEY + click.echo(agent_api_key) + + +@session_agent_group.command("sweep") +@click.option("--dir", "project_dir", default=".", help="Project directory") +@_project_aware +def session_agent_sweep_cmd(project_dir: str) -> None: + """Delete stale codeband-session-* agents owned by this operator. + + Stale = no local marker, heartbeat older than 15 min, or dead pid. + Spares agents with a fresh marker. Skips the current session's own agent + when CODEBAND_SESSION_AGENT_KEY is set. + """ + from codeband.orchestration.session_agent import sweep_stale_session_agents + + project = Path(project_dir).resolve() + config = load_config(project) + api_key = os.environ.get("BAND_API_KEY") + if not api_key: + click.echo( + "Error: BAND_API_KEY is required for session-agent sweep.", err=True, + ) + sys.exit(1) + + # Resolve current session agent id so we don't sweep ourselves + current_agent_id: str | None = None + session_key = os.environ.get("CODEBAND_SESSION_AGENT_KEY") or None + if session_key: + try: + from thenvoi_rest import AsyncRestClient + + async def _resolve_id() -> str: + c = AsyncRestClient(api_key=session_key, base_url=config.band.rest_url) + identity = await c.agent_api_identity.get_agent_me() + return identity.data.id + + current_agent_id = _run_async(_resolve_id()) + except Exception as exc: + click.echo( + f"Warning: could not resolve current session agent id: {exc}", err=True, + ) + + try: + deleted = _run_async( + sweep_stale_session_agents( + band_api_key=api_key, + rest_url=config.band.rest_url, + current_agent_id=current_agent_id, + ) + ) + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + if deleted: + for agent_id in deleted: + click.echo(f"Deleted stale session agent: {agent_id}") + click.echo(f"Swept {len(deleted)} stale session agent(s).") + else: + click.echo("No stale session agents found.") + + @cli.command() @click.option("--sort", "sort_mode", default="newest", type=click.Choice(["newest", "oldest", "smallest", "largest", "most-discussed"]), @@ -530,7 +853,10 @@ def prs(sort_mode: str, smart: bool, limit: int, pick: int | None, slug = repo_slug(config.repo.url) click.echo(f"Fetching open PRs from {slug}...") - raw = fetch_open_prs(slug, limit=100) + try: + raw = fetch_open_prs(slug, limit=100) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from None if not raw: click.echo("No open PRs found.") return @@ -631,7 +957,10 @@ def issues(sort_mode: str, smart: bool, limit: int, pick: int | None, slug = repo_slug(config.repo.url) click.echo(f"Fetching open issues from {slug}...") - raw = fetch_open_issues(slug, limit=100, label=label) + try: + raw = fetch_open_issues(slug, limit=100, label=label) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from None if not raw: click.echo("No open issues found.") return @@ -644,7 +973,10 @@ def issues(sort_mode: str, smart: bool, limit: int, pick: int | None, click.echo(f"Issue #{pick} not found among open issues.", err=True) sys.exit(1) # Fetch full body for the picked issue - detail = fetch_issue_detail(slug, pick) + try: + detail = fetch_issue_detail(slug, pick) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from None chosen = IssueInfo.from_gh(detail) elif smart: click.echo("Ranking issues by impact (AI)...") @@ -656,7 +988,10 @@ def issues(sort_mode: str, smart: bool, limit: int, pick: int | None, choice = click.prompt("Pick an issue number to send as task (0 to cancel)", type=int) if choice == 0: return - detail = fetch_issue_detail(slug, choice) + try: + detail = fetch_issue_detail(slug, choice) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from None chosen = IssueInfo.from_gh(detail) else: sorted_issues = sort_issues(issues_list, sort_mode)[:limit] @@ -667,7 +1002,10 @@ def issues(sort_mode: str, smart: bool, limit: int, pick: int | None, choice = click.prompt("Pick an issue number to send as task (0 to cancel)", type=int) if choice == 0: return - detail = fetch_issue_detail(slug, choice) + try: + detail = fetch_issue_detail(slug, choice) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from None chosen = IssueInfo.from_gh(detail) description = _build_issue_task(chosen, auto=auto) @@ -694,7 +1032,10 @@ def issue(number: int, auto: bool, project_dir: str) -> None: slug = repo_slug(config.repo.url) click.echo(f"Fetching issue #{number} from {slug}...") - detail = fetch_issue_detail(slug, number) + try: + detail = fetch_issue_detail(slug, number) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from None chosen = IssueInfo.from_gh(detail) description = _build_issue_task(chosen, auto=auto) @@ -937,10 +1278,49 @@ def pending(project_dir: str, command_style: str = "cli") -> None: @cli.command() @click.argument("number", type=int) @click.option("--dir", "project_dir", default=".", help="Project directory") +@click.option( + "--no-notify", + is_flag=True, + default=False, + help=( + "Record the durable grant only; skip the room notification. " + "Use this in coordinator flows (e.g. /codeband) where the caller " + "posts the notification as the coordinator identity via `jam send --as`." + ), +) @_project_aware -def approve(number: int, project_dir: str, command_style: str = "cli") -> None: - """Approve a PR for merge (sends approval to Conductor in existing task room).""" - project = Path(project_dir).resolve() +def approve( + number: int, + project_dir: str, + no_notify: bool = False, + command_style: str = "cli", +) -> None: + """Approve a PR for merge (records the durable grant + notifies the room). + + Human-approval primitive: refuses to run inside an agent session + (CODEBAND_AGENT_SESSION is set by the runner for every spawned agent). + This is an accident guard, not authentication — agents request approval + via the merge leg, and a human grants it from their own shell or the + interactive shell's /approve. + """ + # Accident guard (finding 18) before any work — including the chat half. + # The interactive shell's /approve shares the orchestrator process (and + # so its environment); command_style="slash" is only reachable from the + # human at the REPL prompt, so it is exempt. + import os + + if command_style != "slash" and os.environ.get("CODEBAND_AGENT_SESSION"): + raise click.ClickException( + "cb approve is a human-approval primitive; agents request " + "approval via the merge leg." + ) + + # Resolved through the shared cb-phase contract (explicit --dir > + # $CODEBAND_PROJECT_DIR > cwd) — without this, the config load below + # would fail on cwd before the env-var-resolving grant half ever ran. + from codeband.cli.handoff import resolve_project_dir + + project = resolve_project_dir(project_dir) config = load_config(project) from codeband.github.prs import pr_url, repo_slug @@ -948,6 +1328,51 @@ def approve(number: int, project_dir: str, command_style: str = "cli") -> None: slug = repo_slug(config.repo.url) link = pr_url(slug, number) + # Durable half first: record the SHA-pinned merge-approval grant the + # ``cb-phase merge`` leg queries. Recorded before the chat message so a + # failure here is loud and re-runnable, never masked by a sent chat. A PR + # with no bound subtask (the legacy chat-only flow) records nothing. + # The RAW --dir value is passed (not the cwd-resolved ``project``) so the + # grant half resolves it through the shared cb-phase contract: explicit + # flag > $CODEBAND_PROJECT_DIR > cwd. + from codeband.cli.merge import NoActiveTaskError, record_approval_grant + + try: + grant_lines = record_approval_grant(project_dir, number) + except NoActiveTaskError as e: + # Same root cause the chat half would hit (no room pointer) — fail + # here with the UI-appropriate task hint instead of letting the + # chat half phrase it later. + task_cmd = "/task" if command_style == "slash" else "cb task" + issue_cmd = "/issue" if command_style == "slash" else "cb issue" + raise click.ClickException( + f"{e} Start a task first with '{task_cmd}' or '{issue_cmd}'." + ) from None + except Exception as e: # noqa: BLE001 — humans get the message, not a traceback + raise click.ClickException(str(e)) from None + for line in grant_lines: + click.echo(line) + + # A1: fail closed when no grant was recorded (unbound PR or no request + # marker). record_approval_grant already printed the reason to stderr. + # Never post a notification without a real grant on record. + if not grant_lines: + raise click.ClickException( + f"cb approve: no durable grant recorded for PR #{number} — " + "no room notification sent. " + "Re-run after the merge leg requests approval." + ) + + # A2: --no-notify lets coordinator flows (e.g. /codeband) post the + # notification as the coordinator's jam identity via `jam send --as`, + # rather than falling back to BAND_API_KEY (the human key). + if no_notify: + click.echo( + f"Grant recorded for PR #{number} (--no-notify: " + "room notification suppressed; caller must notify via jam send)." + ) + return + message = ( f"APPROVED: Please merge PR #{number}. {link}\n" f"Human has reviewed and approved this PR for merge." @@ -1107,8 +1532,13 @@ def down(project_dir: str, volumes: bool) -> None: @cli.command() @click.option("--dir", "project_dir", default=".", help="Project directory") +@click.option( + "--clear-state-rooms", + is_flag=True, + help="Also clear durable StateStore task-room records for a workspace wipe.", +) @_project_aware -def reset(project_dir: str) -> None: +def reset(project_dir: str, clear_state_rooms: bool) -> None: """Clean up the active Band.ai task room. Removes every agent from the room recorded in .codeband_room and deletes @@ -1120,11 +1550,15 @@ def reset(project_dir: str) -> None: from codeband.orchestration.kickoff import reset_active_room - room_id = _run_async(reset_active_room(config, project)) + room_id = _run_async( + reset_active_room(config, project, clear_state_rooms=clear_state_rooms) + ) if room_id is None: click.echo("No active task room to reset.") else: click.echo(f"Reset task room: {room_id}") + if clear_state_rooms: + click.echo("Cleared StateStore task-room records.") @cli.command() @@ -1184,7 +1618,12 @@ def usage(agent: str | None, since: str | None, project_dir: str) -> None: reader = make_backend(config, project).make_activity_reader() - since_dt = _parse_since(since) if since else None + since_dt = None + if since: + try: + since_dt = _parse_since(since) + except (ValueError, OverflowError) as exc: + raise click.ClickException(f"Invalid --since value {since!r}: {exc}") from None summary = UsageSummary.from_activity_reader(reader, agent=agent, since=since_dt) if summary.call_count == 0: @@ -1239,7 +1678,12 @@ def log(agent: str | None, event_type: str | None, since: str | None, reader = make_backend(config, project).make_activity_reader() - since_dt = _parse_since(since) if since else None + since_dt = None + if since: + try: + since_dt = _parse_since(since) + except (ValueError, OverflowError) as exc: + raise click.ClickException(f"Invalid --since value {since!r}: {exc}") from None events = reader.read(agent=agent, event_type=event_type, since=since_dt) if not events: @@ -1269,6 +1713,72 @@ def log(agent: str | None, event_type: str | None, since: str | None, click.echo(f"{date} {ts} {event.event_type:<18s} {agent_name:<16s} {summary}") +@cli.command("verify-log") +@click.option("--dir", "project_dir", default=".", help="Project directory") +@_project_aware +def verify_log(project_dir: str) -> None: + """Verify the integrity of the hash-chained audit ledgers. + + Walks BOTH global hash chains in the state DB — ``transition_log`` (FSM + transitions) and ``audit_log`` (approval grants / markers / pr_number + bindings / ungated merges) — recomputing every row's hash from its stored + business columns and comparing against the stored ``row_hash``. + + On success: prints per-chain row counts and head hashes, exits 0. On a + break: names the first broken row id, the expected vs the stored hash, and + exits non-zero. This is tamper-EVIDENT, not tamper-proof — a process with + write access to the DB can recompute a whole chain; what it cannot do is + edit a single row and leave the chain consistent. Detection over + prevention, by design. + """ + import sqlite3 + + from codeband.cli.handoff import resolve_project_dir + from codeband.state import ( + AUDIT_HASH_COLS, + TRANSITION_HASH_COLS, + verify_chain, + ) + + project = resolve_project_dir(project_dir) + config = load_config(project) + workspace_path = resolve_workspace_path(config, project) + db_path = workspace_path / "state" / "orchestration.db" + + if not db_path.exists(): + click.echo(f"No state DB at {db_path} — nothing to verify.") + return + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + chains = ( + ("transition_log", TRANSITION_HASH_COLS), + ("audit_log", AUDIT_HASH_COLS), + ) + broken = False + try: + for table, cols in chains: + result = verify_chain(conn, table, cols) + if result.ok: + head = result.head_hash[:12] if result.head_hash else "—(empty)" + click.echo( + f"OK {table:<16s} {result.row_count} rows, head {head}" + ) + else: + broken = True + click.echo( + f"BREAK {table:<15s} first broken row id={result.broken_id}: " + f"expected row_hash {result.expected_hash}, " + f"stored {result.actual_hash}", + err=True, + ) + finally: + conn.close() + + if broken: + raise SystemExit(1) + + def _parse_since(value: str): """Parse a --since value like '1h', '30m', or ISO date.""" from datetime import UTC, datetime as dt, timedelta @@ -1283,24 +1793,133 @@ def _parse_since(value: str): return dt.fromisoformat(value) +def _room_msg_to_dict(m: object, agent_names: dict[str, str]) -> dict: + """Convert a REST message object to a plain dict for FeedFormatter / JSON output.""" + sender_id = "" + for attr in ("sender_id", "participant_id", "author_id", "from_id"): + v = getattr(m, attr, None) + if v is not None: + sender_id = str(v) + break + + ts = "" + for attr in ("created_at", "createdAt", "inserted_at"): + v = getattr(m, attr, None) + if v is not None: + ts = str(v) + break + + return { + "sender_id": sender_id, + "sender_name": agent_names.get(sender_id, sender_id), + "message_type": str(getattr(m, "message_type", None) or "text").lower(), + "content": str(getattr(m, "content", None) or ""), + "inserted_at": ts, + } + + +async def _fetch_room_messages( + rest_client: object, + room_id: str, + *, + max_pages: int = 100, + page_size: int = 50, +) -> list: + """Paginate through a room's history and return all messages, oldest-first.""" + all_msgs = [] + for page in range(1, max_pages + 1): + resp = await rest_client.human_api_messages.list_my_chat_messages( + room_id, page=page, page_size=page_size, + ) + data = getattr(resp, "data", None) or [] + if not data: + break + all_msgs.extend(data) + if len(data) < page_size: + break + + def _ts(m: object) -> str: + for attr in ("created_at", "createdAt", "inserted_at"): + v = getattr(m, attr, None) + if v is not None: + return str(v) + return "" + + all_msgs.sort(key=_ts) + return all_msgs + + +@cli.command("room-log") +@click.argument("room_id", required=False, default=None) +@click.option("--json", "as_json", is_flag=True, help="Output raw JSON lines (one object per line)") +@click.option("--dir", "project_dir", default=".", help="Project directory") +@_project_aware +def room_log(room_id: str | None, as_json: bool, project_dir: str) -> None: + """Dump a Band room's full message transcript. + + ROOM_ID defaults to the active room (.codeband_room pointer). + Messages are printed in timestamp order with agent attribution, + matching the `cb feed` format. Use --json for raw structured output. + """ + import json as _json + import os + + project = Path(project_dir).resolve() + config = load_config(project) + + if room_id is None: + from codeband.state.registration import read_room_pointer, resolve_state_dir + + room_id = read_room_pointer(project, resolve_state_dir(config, project)) + if not room_id: + raise click.ClickException( + "No active room found. Pass a ROOM_ID argument or start a task with 'cb task'." + ) + + api_key = os.environ.get("BAND_API_KEY") + if not api_key: + click.echo("Error: BAND_API_KEY not set. Set it in .env or environment.", err=True) + sys.exit(1) + + from codeband.config import load_agent_config + from codeband.monitoring.feed import FeedFormatter + from thenvoi.client.rest import AsyncRestClient + + agent_config = load_agent_config(project) + agent_names = {v.agent_id: k for k, v in agent_config.agents.items()} + formatter = FeedFormatter(agent_names, verbose=True) + + rest = AsyncRestClient(api_key=api_key, base_url=config.band.rest_url) + + async def _dump() -> None: + msgs = await _fetch_room_messages(rest, room_id) + if not as_json: + click.echo(f"# Room {room_id} — {len(msgs)} messages\n") + for m in msgs: + msg_dict = _room_msg_to_dict(m, agent_names) + if as_json: + click.echo(_json.dumps(msg_dict)) + else: + line = formatter.format(msg_dict) + if line is not None: + click.echo(line) + + _run_async(_dump()) def _detect_git_credentials(env: dict[str, str]) -> None: - """Detect host git credentials and set env vars for Docker containers.""" + """Detect host git credentials and set env vars for Docker containers. + + SSH access flows through the agent socket (``SSH_AUTH_SOCK`` bind mount + in the compose files); the old ``SSH_AUTH_DIR`` export had no reader + anywhere and was removed. + """ home = Path.home() # Check for ~/.git-credentials (git credential store) git_creds = home / ".git-credentials" if git_creds.is_file(): env.setdefault("GIT_CREDENTIALS_PATH", str(git_creds)) - return - - # Check for SSH key - for key_name in ("id_ed25519", "id_rsa", "id_ecdsa"): - ssh_key = home / ".ssh" / key_name - if ssh_key.is_file(): - env.setdefault("SSH_AUTH_DIR", str(home / ".ssh")) - return # Pools whose two framework variants are profile-gated in the bundled @@ -1415,3 +2034,38 @@ def _detect_github_auth(env: dict[str, str]) -> None: token = result.stdout.strip() if token: env.setdefault("GH_TOKEN", token) + + +def main() -> None: + """Console entry point for ``cb`` / ``codeband``. + + Wraps the click group with the Stage-3 attribution logging: a + ``cli_invocation`` event before dispatch and a ``cli_completion`` event + with the resolved exit code after — for the whole sanctioned CLI surface. + Runs click with ``standalone_mode=False`` so the exit code is observable on + every path (success, ``ClickException``, ``Abort``, ``SystemExit``), then + re-exits with it. Logging is best-effort and never alters the exit code. + """ + from codeband.monitoring.activity_log import record_cli_invocation + + argv = sys.argv[1:] + complete = record_cli_invocation("cb", argv) + try: + cli.main(args=argv, standalone_mode=False) + except click.ClickException as exc: + exc.show() + complete(exc.exit_code) + sys.exit(exc.exit_code) + except click.exceptions.Abort: + click.echo("Aborted!", err=True) + complete(1) + sys.exit(1) + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1) + complete(code) + raise + except Exception: + complete(1) + raise + else: + complete(0) diff --git a/src/codeband/cli/handoff.py b/src/codeband/cli/handoff.py new file mode 100644 index 0000000..5971b2d --- /dev/null +++ b/src/codeband/cli/handoff.py @@ -0,0 +1,1768 @@ +"""``cb-phase`` — the verify-gated handoff CLI (RFC Workstream 3). + +This is the enforcement seam. Coding agents (Claude *and* Codex) request a +phase advance by shelling out to ``cb-phase verify``; the effect only happens +if every gate passes, regardless of what the Conductor intended. + + cb-phase verify --pr [--worktree ] [--project-dir

] + +The authoritative ``task_id`` (the FK target of every subtask row) is *not* +taken from the command line: it is resolved from the active-room pointer +``{workspace}/state/.codeband_room`` that task registration writes next to +the state DB (legacy ``/.codeband_room`` is read as a +fallback — see ``state/registration.py``), where ``tasks.task_id == +room_id``. The ``--task`` flag is accepted for readability +but is a non-authoritative label only — agents pass the semantic ``task_key`` +there, which would FK-fail if trusted (see :func:`_resolve_task_id`). + +A coder also runs ``cb-phase start `` at pickup to +seed the subtask into ``in_progress`` — the Conductor never drives the FSM +directly, so without this nothing would advance the subtask off ``planned`` and +the first ``verify`` would dead-end. ``verify`` self-seeds from a +missing/``planned``/``assigned`` subtask as a backstop, so a skipped ``start`` +degrades gracefully rather than failing. Neither path touches the gates that +matter (``verify → review_pending``, the review verdict) or the cap counters. + +**Conductor recovery primitives.** Two commands give the Conductor a +code-enforced way out of a stuck subtask, both resolved through the same +active-room contract as every other leg: + + cb-phase abandon [--task

] + +``--pr`` is required on the first invocation and is persisted onto the +subtask row (``subtask_states.pr_number``), so every later invocation — +including the argument-less crash-reconcile re-run — derives the PR number +from durable state. A ``--pr`` that disagrees with the persisted binding is +**rejected**: rebinding a queued subtask to a different (possibly +already-merged) PR would let the reconcile path record a phantom ``merged``. +``--worktree`` is the directory ``gh`` runs in (repo resolution), defaulting +to the cwd like the sibling legs. + +Invocation flow (each step fail-closed): + +a. Resolve the task (active-room pointer, same as verify/review), the + subtask, the PR number, and one PR snapshot (state / mergeable / head SHA / + head branch name). +b. **Reconcile first** (idempotency, grant-gated [S11-1.2]): a subtask already + at ``merge_pending`` whose PR is already ``MERGED`` records ``merged`` and + exits 0 ONLY when a SHA-pinned grant matches the merged head — the + sanctioned crash-recovery case (our own merge raced/crashed between execute + and record). With the grant absent or mismatched the PR was merged OUT OF + BAND, so the subtask goes to ``blocked`` with the ``ungated_external_merge`` + tag + an ``audit_log`` event instead of laundering it into ``merged``. +c. From ``review_passed`` (verifier-less) or ``acceptance_passed`` (once a + Verifier is configured): attempt the gated + ``→ merge_pending`` transition at the PR head SHA. The 2a + eligibility check runs *inside* the transition; a rejection exits non-zero + echoing every machine-readable reason. This leg never duplicates the check. + **SHA-shaped ineligibility is auto-routed**: when every reason is a + verdict that exists but pins the wrong SHA (``stale_verdict`` — including + mixed-SHA legs) or pins nothing (``unpinned_verdict``), rework at the + current head cures it, so the subtask is driven to ``needs_rebase`` + (``REJECTED [stale_verdicts]``, rebase-round cap applies) instead of + resting at ``review_passed`` behind a bare reject. A missing verdict leg + (``missing_verdict`` / ``unknown_*`` / ``no_head_sha``) means the chain + never completed — a process failure rework can't cure — and keeps the + bare ``REJECTED [not_eligible]``. +d. **Execution-time SHA re-check** — BEFORE any approval logic. The PR head + must still equal the SHA on the ``merge_pending`` transition. A push while + waiting → ``needs_rebase``, non-zero, naming old and new SHA — before any + grant evaluation or approval-request send, so a head-moved subtask is + never left permanently un-approvable (the grant could never equal the + stale ``pending_sha``, and the request marker would already be burned). + Only applies when a queued SHA exists (NULL-pending legacy rows keep their + previous behavior). +e. **Approval**: the task's snapshotted ``merge_approval`` approver must have + granted a SHA-pinned approval (written by ``cb approve`` onto the subtask + row) matching the SHA recorded on the ``merge_pending`` transition. If not + yet granted, the approval request is sent to the resolved approver (task + owner, or the named human) in the task room and the leg exits 0 — the + subtask RESTS at ``merge_pending``; re-invocation after approval proceeds. + The request is sent once per ``merge_pending`` SHA (marker-after-send: the + ``merge_approval_requested_sha`` marker burns only on a successful send). +f. **Mergeability pre-check**: a ``CONFLICTING`` PR → ``needs_rebase``. +g. **Execute** ``gh pr merge --merge``, pinned to the approved commit + via ``--match-head-commit `` so a push between snapshot and + execution can never merge unverified code. Success records the ``merged`` + transition (the 2a task-level ``completed`` promotion fires on its own + inside the FSM). After a merge is *recorded* merged, the remote branch is + deleted best-effort (``git push origin --delete ``) — never + ``--delete-branch``, which would also delete the *local* branch out from + under a coder worktree; a delete failure is a warning only. +h. **Failure classification — verify effects first.** On a non-zero ``gh`` + exit the PR is re-snapshotted before classifying: an actually-``MERGED`` + PR records ``merged`` and exits 0 (the merge landed; only the report + failed); a moved head → ``needs_rebase`` (covers ``--match-head-commit`` + rejections); a ``CONFLICTING`` mergeable field (preferred over the + ``_CONFLICT_RE`` text fallback) → ``needs_rebase``. Anything else + (permissions, API error, required status check) → ``blocked`` with the + reason recorded. An unavailable re-snapshot classifies nothing — the + subtask rests at ``merge_pending`` for the next reconcile rather than + risking a phantom ``blocked`` over a merged PR. The watchdog's existing + blocked-subtask patrol (escalate-once, marker-after-send — PR #24) + delivers the owner escalation; this leg sends nothing itself, so a + re-failure can never double-escalate. + +Every ``needs_rebase`` classification above (d / f / h) is additionally +bounded by the durable per-subtask rebase-round cap +(``agents.max_rebase_rounds``, counted by the FSM on each entry to +``needs_rebase``): at the cap the send-back escalates the subtask to +``blocked`` with ``BLOCKED [rebase_cap_reached]`` instead — see +:func:`_needs_rebase_or_blocked`. An active rebase loop writes fresh +transition rows every cycle, so the watchdog's stall cap by construction +never fires on it; this cap is what bounds it. + +Like the sibling legs, rejections are structured: a stable machine-greppable +tag plus a distinct exit code per failure mode. All ``gh`` and Band +interactions live behind thin module-level functions so tests monkeypatch +them; Band SDK imports are deferred inside the send function, keeping the +module import-light for the common (no-send) paths. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import re +import sqlite3 +import subprocess +import sys +from pathlib import Path + +from codeband.cli.handoff import ( + _output_tail, + _resolve_store, + _resolve_task_id, + resolve_project_dir, +) +from codeband.config import load_config +from codeband.state.fsm import ( + InvalidTransitionError, + MergeNotEligibleError, + NoOpTransitionError, + transition, +) +from codeband.state.store import StateStore, TaskRow + +logger = logging.getLogger(__name__) + +# Distinct exit codes per failure mode, continuing the ``cli/handoff.py`` +# numbering (2–6 are taken by the verify leg) so a caller holding either leg's +# codes can branch on the *kind* of failure without parsing stderr. +EXIT_NO_PR_NUMBER = 7 +EXIT_PR_QUERY_FAILED = 8 +EXIT_NOT_ELIGIBLE = 9 +EXIT_NEEDS_REBASE = 10 +EXIT_MERGE_FAILED = 11 +# ``--pr`` disagrees with the subtask's persisted PR binding. Rebinding is +# refused outright: pointing a queued subtask at a different (possibly +# already-merged) PR would let the reconcile path record a phantom ``merged``. +EXIT_PR_REBIND = 12 +# The rebase-round cap (S2-1): the subtask has already been sent back for +# rebase ``agents.max_rebase_rounds`` times, so this send-back escalates it to +# ``blocked`` instead — see :func:`_needs_rebase_or_blocked`. (13–16 are taken +# by the verify leg back in ``cli/handoff.py``; the numbering continues here.) +EXIT_REBASE_CAP_REACHED = 17 + +# Entry states this leg accepts. ``review_passed`` is the normal first +# invocation for a verifier-less task; ``acceptance_passed`` is the normal +# first invocation once a Verifier is configured (the verify_acceptance gate +# sits between review and merge); ``merge_pending`` is the +# resting/awaiting-approval state and the crash-reconcile entry. Anything else +# is a clear error — in particular there is no path that re-merges a ``merged`` +# subtask or revives a ``blocked`` one. The merge-eligibility gate, not this +# set, decides which path a task's verdict snapshot actually permits: a +# verifier task that reaches here at ``review_passed`` is rejected for the +# missing ``verify_acceptance`` verdict until it advances to +# ``acceptance_passed``. +_ENTRY_STATES = frozenset({"review_passed", "acceptance_passed", "merge_pending"}) + +class NoActiveTaskError(RuntimeError): + """``cb approve``'s grant half could not resolve the active task. + + Distinct type so the command layer can append a UI-appropriate "start a + task" hint (``cb task`` vs ``/task``) without string-matching the + message. Still a loud failure either way — an "approval" recorded + against nothing must never look like success. + """ + + +# Classifies a failed ``gh pr merge`` as a conflict (→ ``needs_rebase``) +# rather than a residual failure (→ ``blocked``). GitHub phrases conflicts as +# "Pull request ... is not mergeable: the merge commit cannot be cleanly +# created" / "...conflicts must be resolved...". +_CONFLICT_RE = re.compile(r"conflict|not mergeable", re.IGNORECASE) + + +def _pr_snapshot(pr_number: int, cwd: Path, repo: str | None = None) -> dict | None: + """Return one ``gh pr view`` snapshot: state, mergeable, head SHA + branch. + + A single query per invocation supplies every PR-derived decision input + (reconcile state, mergeability, execution-time SHA, the head branch name + for the post-merge remote cleanup), so the leg cannot contradict itself + mid-run. ``repo`` (an ``owner/repo`` slug) pins the query with + ``--repo``, dropping the cwd dependence for repo identity — both ``cb + approve``'s grant half (which may run from any cwd) and the merge leg + pass the config-derived slug, so a same-numbered PR in whatever repo the + cwd happens to be in can never be snapshotted/reconciled. ``cwd`` is kept + for git-context purposes only. Returns ``None`` when ``gh`` fails or + returns unparseable output — callers fail closed. + """ + cmd = ["gh", "pr", "view", str(pr_number), + "--json", "state,mergeable,headRefOid,headRefName"] + if repo is not None: + cmd += ["--repo", repo] + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=str(cwd), + ) + if result.returncode != 0: + logger.debug("gh pr view %s failed: %s", pr_number, result.stderr) + return None + try: + data = json.loads(result.stdout) + except ValueError: + return None + return data if isinstance(data, dict) else None + + +def _gh_merge( + pr_number: int, cwd: Path, pending_sha: str | None, repo: str | None = None, +) -> tuple[int, str]: + """Execute the merge: ``gh pr merge --merge``, pinned to ``pending_sha``. + + ``--match-head-commit `` (whenever a queued SHA exists) makes + GitHub itself refuse the merge if the head moved between our snapshot and + the execution — the last unguarded window. ``repo`` (an ``owner/repo`` + slug from config) pins the repo identity with ``--repo`` — same pattern + as :func:`_pr_snapshot` — so the merge can never target a same-numbered + PR in whatever repo ``cwd`` happens to be in. No ``--delete-branch``: + that flag also deletes the *local* branch, which belongs to a coder + worktree; remote cleanup is :func:`_delete_remote_branch`'s job, after + the merge is recorded. Returns ``(exit_code, combined_output)`` for + failure classification. + """ + cmd = ["gh", "pr", "merge", str(pr_number), "--merge"] + if pending_sha is not None: + cmd += ["--match-head-commit", pending_sha] + if repo is not None: + cmd += ["--repo", repo] + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=str(cwd), + ) + return result.returncode, (result.stdout or "") + (result.stderr or "") + + +def _delete_remote_branch(pr: dict | None, cwd: Path) -> None: + """Best-effort REMOTE-only delete of a merged PR's head branch. + + ``git push origin --delete `` (branch name from the PR + snapshot) — never a local delete: local branches belong to coder + worktrees. Called only *after* a merge is recorded ``merged``; any + failure (missing branch name, git error, already deleted by GitHub's + auto-delete) is a printed warning and never affects classification or + the exit code. + """ + branch = (pr or {}).get("headRefName") or None + if branch is None: + print( + "cb-phase: warning — no head branch name in the PR snapshot; " + "skipping remote branch cleanup.", + file=sys.stderr, + ) + return + try: + result = subprocess.run( + ["git", "push", "origin", "--delete", branch], + capture_output=True, text=True, cwd=str(cwd), + ) + except OSError as exc: + print( + f"cb-phase: warning — remote branch delete failed for " + f"{branch!r}: {exc}", + file=sys.stderr, + ) + return + if result.returncode != 0: + print( + f"cb-phase: warning — could not delete remote branch {branch!r} " + f"(exit {result.returncode}): " + f"{_output_tail((result.stdout or '') + (result.stderr or ''))}", + file=sys.stderr, + ) + + +def _merge_pending_sha(store: StateStore, subtask_id: str, task_id: str) -> str | None: + """Return the ``head_sha`` recorded on the latest ``→ merge_pending`` row. + + The SHA the merge was *queued* at — the anchor for both the approval + grant and the execution-time re-check. Reads the store's SQLite file + directly (read-only), mirroring the watchdog's transition-log readers; + task-scoped, since subtask ids repeat across tasks. + """ + conn = sqlite3.connect(store.db_path, timeout=30.0) + try: + row = conn.execute( + "SELECT head_sha FROM transition_log " + "WHERE task_id = ? AND subtask_id = ? AND to_state = 'merge_pending' " + "ORDER BY id DESC LIMIT 1", + (task_id, subtask_id), + ).fetchone() + finally: + conn.close() + return row[0] if row is not None else None + + +def _approver_display(task: TaskRow, approver_spec: str) -> tuple[str | None, str]: + """Resolve ``(mention_id, handle)`` for the snapshotted approver spec. + + ``"owner"`` resolves to the task row's owner (structured mention via + ``owner_id``, display via ``owner_handle``); ``"human:"`` carries + only a display handle — no Band participant id is known for an arbitrary + human, so the message mentions them by text alone. + """ + if approver_spec.startswith("human:"): + return None, approver_spec[len("human:"):] + return task.owner_id, (task.owner_handle or task.owner_id or "owner") + + +def _send_approval_request( + project_dir: Path, + task: TaskRow, + subtask_id: str, + pr_number: int, + head_sha: str | None, + approver_spec: str, +) -> None: + """Post the merge-approval request to the task room, @mentioning the approver. + + The existing room-message mechanism (`cb approve` / `cb reject` use the + same plumbing in ``orchestration/kickoff.py``): a Band REST chat message + in the task's room. Sent with the Mergemaster's credentials — the merge + leg is the Mergemaster's seam — and a structured mention when the + approver has a known participant id (the owner). Raises on any failure; + the caller owns the send-once marker (marker-after-send). + """ + import asyncio + + from codeband.config import load_agent_config, load_config + + config = load_config(project_dir) + creds = load_agent_config(project_dir).get("mergemaster") + + mention_id, handle = _approver_display(task, approver_spec) + content = ( + f"@{handle} PR #{pr_number} (subtask {subtask_id}) is paused at the merge gate " + f"(head {head_sha or 'unknown'}). To merge, run this in your terminal:\n\n" + f" cb approve {pr_number}\n\n" + f"A chat reply does not advance the gate. Nothing further happens until the " + f"grant is recorded; the merge then completes automatically." + ) + + async def _send() -> None: + from thenvoi_rest import AsyncRestClient, ChatMessageRequest + from thenvoi_rest.types import ChatMessageRequestMentionsItem as Mention + + client = AsyncRestClient(api_key=creds.api_key, base_url=config.band.rest_url) + mentions = [Mention(id=mention_id)] if mention_id else [] + await client.agent_api_messages.create_agent_chat_message( + chat_id=task.room_id, + message=ChatMessageRequest(content=content, mentions=mentions), + ) + + asyncio.run(_send()) + + +def _audit_ungated_external_merge( + store: StateStore, + *, + task_id: str, + subtask_id: str, + pr_number: int, + merged_sha: str | None, + grant_sha: str | None, +) -> None: + """Best-effort append of the ``ungated_external_merge`` audit event. + + The ``append_audit_event`` primitive ships in the evidence-integrity PR + (the hash-chained ``audit_log``). It is called via ``getattr`` so this leg + is independent of merge order — the event activates automatically once that + PR lands, and a pre-integrity store simply records nothing extra (the + ``blocked`` transition + structured output already stand on their own). + Never raises into the merge path: the audit row is evidence, not a gate. + """ + append = getattr(store, "append_audit_event", None) + if append is None: + return + try: + append( + "ungated_external_merge", + task_id=task_id, + subtask_id=subtask_id, + payload={ + "pr_number": pr_number, + "merged_sha": merged_sha, + "grant_sha": grant_sha, + }, + ) + except Exception: + logger.warning("ungated_external_merge audit append failed", exc_info=True) + + +def _transition_or_fail( + subtask_id: str, + task_id: str, + new_state: str, + reason: str, + *, + store: StateStore, + head_sha: str | None = None, +) -> int | None: + """Apply a mergemaster transition; return an exit code only on rejection.""" + try: + transition( + subtask_id, task_id, new_state, + caller_role="mergemaster", reason=reason, store=store, + head_sha=head_sha, + ) + except NoOpTransitionError as exc: + print(str(exc)) + return None + except InvalidTransitionError as exc: + print(f"cb-phase: transition rejected — {exc}", file=sys.stderr) + return 1 + return None + + +def _max_rebase_rounds(project_dir: Path) -> int: + """Return the configured per-subtask rebase-round cap (live-read).""" + return load_config(project_dir).agents.max_rebase_rounds + + +def _needs_rebase_or_blocked( + subtask_id: str, + task_id: str, + reason: str, + *, + store: StateStore, + project_dir: Path, +) -> int | None: + """Send a subtask back for rebase — or escalate at the rebase-round cap. + + Every ``needs_rebase`` classification in this leg routes through here + (S2-1). An active rebase loop writes fresh transition rows every cycle, so + the watchdog's stall cap by construction never fires on it; the durable + ``rebase_rounds`` counter (incremented by the FSM on each entry to + ``needs_rebase``) is what bounds it. Below the cap, applies the + ``needs_rebase`` transition and returns ``None`` — the call site prints its + specific ``REJECTED [...]`` message and returns ``EXIT_NEEDS_REBASE``. At + the cap, escalates the subtask to ``blocked`` instead (mirroring the + review-round cap's mechanics in ``cli/handoff.py``), prints + ``BLOCKED [rebase_cap_reached]`` and returns ``EXIT_REBASE_CAP_REACHED``; + any rejected transition returns its failure code. The FSM enforces the + same cap inside :func:`~codeband.state.fsm.transition` (defense in depth); + this proactive check is what turns the rejection into the escalation. + """ + from codeband.state.fsm import transition as fsm_transition + + sub = store.get_subtask(subtask_id, task_id) + rounds = getattr(sub, "rebase_rounds", 0) if sub is not None else 0 + cap = _max_rebase_rounds(project_dir) + if rounds >= cap: + code = _transition_or_fail( + subtask_id, task_id, "blocked", + f"rebase-round cap {cap} reached — {reason}", + store=store, + ) + if code is not None: + return code + print( + f"BLOCKED [rebase_cap_reached]: {rounds} rebase rounds. " + "Escalated to human; stop and await.", + file=sys.stderr, + ) + return EXIT_REBASE_CAP_REACHED + try: + fsm_transition( + subtask_id, task_id, "needs_rebase", + caller_role="mergemaster", reason=reason, store=store, + max_rebase_rounds=cap, + ) + except NoOpTransitionError as exc: + print(str(exc)) + return None + except InvalidTransitionError as exc: + print(f"cb-phase: transition rejected — {exc}", file=sys.stderr) + return 1 + return None + + +def _cmd_merge(args: argparse.Namespace) -> int: + project_dir = resolve_project_dir(args.project_dir) + worktree = Path(args.worktree).resolve() + store = _resolve_store(project_dir) + + task_id, error_code = _resolve_task_id(project_dir, store, args.task) + if error_code is not None: + return error_code + + subtask = store.get_subtask(args.subtask_id, task_id) + current = subtask.state if subtask is not None else "planned" + if current == "merged": + sha = subtask.merge_approved_sha if subtask is not None else None + sha_tag = f"@{sha}" if sha else "" + print( + f"NO-OP [already_merged] {args.subtask_id}{sha_tag} " + f"(state: merged); nothing to do" + ) + return 0 + if current not in _ENTRY_STATES: + print( + f"cb-phase: subtask {args.subtask_id!r} is in state {current!r}, " + "which is not a valid entry state for cb-phase merge. " + "Expected review_passed, acceptance_passed, or merge_pending.", + file=sys.stderr, + ) + return 1 + + # PR number: the persisted binding is authoritative once set. An explicit + # --pr binds on first use (and is persisted — the durable binding the + # argument-less reconcile path reads back), is an idempotent no-op when it + # matches, and is REJECTED when it disagrees: rebinding a queued subtask + # to a different (possibly already-merged) PR would let the reconcile + # path record a phantom ``merged``. Neither given nor persisted → nothing + # to merge against, fail closed. + pr_number = args.pr if args.pr is not None else subtask.pr_number + if pr_number is None: + print( + f"REJECTED [no_pr_number]: subtask {args.subtask_id} has no " + "recorded PR. Pass --pr on the first cb-phase merge invocation.", + file=sys.stderr, + ) + return EXIT_NO_PR_NUMBER + if args.pr is not None and subtask.pr_number is not None and subtask.pr_number != args.pr: + print( + f"REJECTED [pr_rebind]: subtask {args.subtask_id} is already " + f"bound to PR #{subtask.pr_number}; refusing to rebind to " + f"#{args.pr}.", + file=sys.stderr, + ) + return EXIT_PR_REBIND + if args.pr is not None and subtask.pr_number is None: + store.set_pr_number(args.subtask_id, task_id, args.pr) + + # Repo identity comes from config (--repo ), never from whatever + # repo the worktree cwd happens to be in — same pattern as verify and + # the grant half. Without it, a same-numbered PR in another repo could + # be reconciled/merged. Underivable slug → an infra failure, exactly + # like a failed snapshot: fail closed, nothing written. + from codeband.github.prs import repo_slug + + try: + slug = repo_slug(load_config(project_dir).repo.url) + except ValueError as exc: + print( + f"REJECTED [pr_query_failed]: cannot derive the GitHub repo slug " + f"from config repo.url ({exc}). Fix repo.url in codeband.yaml, " + "then re-run.", + file=sys.stderr, + ) + return EXIT_PR_QUERY_FAILED + + # One PR snapshot drives every PR-derived decision this invocation. + pr = _pr_snapshot(pr_number, worktree, repo=slug) + if pr is None: + print( + f"REJECTED [pr_query_failed]: could not query PR #{pr_number} " + "via gh. Check gh auth/network, then re-run.", + file=sys.stderr, + ) + return EXIT_PR_QUERY_FAILED + pr_state = pr.get("state") + head_sha = pr.get("headRefOid") or None + + # (b) Reconcile first — the crash-recovery path. A merge that executed + # but crashed before recording lands here on re-invocation: the PR is + # already MERGED. But reconcile now REQUIRES a grant [S11-1.2]: the ONLY + # sanctioned reconcile is OUR OWN merge that raced/crashed between execute + # and record, which leaves a SHA-pinned grant matching the merged head. A + # PR merged with NO matching grant was merged OUT OF BAND (a human clicked + # merge, a stray process, another tool) — recording that as ``merged`` + # would launder an ungated merge into the ledger. So: grant present AND + # matching the merged head → ``merged`` as before; otherwise → ``blocked`` + # with the ``ungated_external_merge`` tag, an audit_log event, and a + # structured line naming the merged SHA and the missing/mismatched grant. + # The watchdog's existing blocked-subtask patrol carries the escalation — + # no new rung needed. + if current == "merge_pending": + if pr_state == "MERGED": + grant_sha = subtask.merge_approved_sha + if grant_sha is not None and head_sha is not None and grant_sha == head_sha: + code = _transition_or_fail( + args.subtask_id, task_id, "merged", + f"cb-phase merge: reconciled — PR #{pr_number} already " + f"merged at granted head {head_sha}", + store=store, head_sha=head_sha, + ) + if code is not None: + return code + print( + f"cb-phase: reconciled — PR #{pr_number} was already merged " + f"at the granted head {head_sha}; subtask {args.subtask_id} " + f"→ merged (task {task_id})." + ) + _delete_remote_branch(pr, worktree) + return 0 + + # Ungated external merge: PR is MERGED but no grant authorizes + # exactly this head. Record blocked, write the audit event, and + # report — never launder it into ``merged``. + _audit_ungated_external_merge( + store, task_id=task_id, subtask_id=args.subtask_id, + pr_number=pr_number, merged_sha=head_sha, grant_sha=grant_sha, + ) + code = _transition_or_fail( + args.subtask_id, task_id, "blocked", + f"ungated_external_merge: PR #{pr_number} is MERGED at " + f"{head_sha} but no grant authorizes it " + f"(grant={grant_sha or 'absent'})", + store=store, + ) + if code is not None: + return code + print( + f"BLOCKED [ungated_external_merge]: PR #{pr_number} was merged " + f"OUT OF BAND at {head_sha} — " + + ( + f"the recorded grant is for {grant_sha}, not this head" + if grant_sha is not None + else "no merge approval was ever granted" + ) + + ". Recorded blocked (NOT merged); escalation via watchdog. " + "Stop and await a human decision.", + file=sys.stderr, + ) + return EXIT_MERGE_FAILED + else: + # (c) The gated → merge_pending transition, at the PR head SHA, from + # whichever pre-merge state the subtask rests in (``review_passed`` for + # a verifier-less task, ``acceptance_passed`` once a Verifier is + # configured). Eligibility (2a) is enforced INSIDE the transition — + # this leg never duplicates the check. + try: + transition( + args.subtask_id, task_id, "merge_pending", + caller_role="mergemaster", + reason=f"cb-phase merge: queue PR #{pr_number} for merge", + store=store, head_sha=head_sha, + ) + except MergeNotEligibleError as exc: + reasons = exc.eligibility.reasons + detail = "; ".join(reasons) + # SHA-shaped ineligibility — every reason is a verdict that EXISTS + # but pins the wrong SHA (``stale_verdict``, including the + # mixed-SHA case where legs disagree) or pins nothing + # (``unpinned_verdict``). Rework at the current head cures all of + # them, so route the subtask to ``needs_rebase`` (cap applies via + # _needs_rebase_or_blocked) instead of resting it at + # ``review_passed`` with a bare reject nobody acts on. A missing + # verdict leg (``missing_verdict`` / ``unknown_*`` / + # ``no_head_sha``) is NOT SHA-shaped — the chain never completed, + # which is a routing/process failure: the bare reject stands. + sha_shaped = bool(reasons) and all( + r.startswith(("stale_verdict", "unpinned_verdict")) + for r in reasons + ) + if sha_shaped: + code = _needs_rebase_or_blocked( + args.subtask_id, task_id, + f"cb-phase merge: verdicts stale at head {head_sha} — " + f"{detail}", + store=store, project_dir=project_dir, + ) + if code is not None: + return code + print( + f"REJECTED [stale_verdicts]: subtask {args.subtask_id} " + f"cannot enter merge_pending at {head_sha!r} — {detail}. " + "Subtask → needs_rebase; rework and re-earn verdicts at " + "the current head.", + file=sys.stderr, + ) + return EXIT_NEEDS_REBASE + print( + f"REJECTED [not_eligible]: subtask {args.subtask_id} cannot " + f"enter merge_pending at {head_sha!r} — {detail}", + file=sys.stderr, + ) + return EXIT_NOT_ELIGIBLE + except NoOpTransitionError as exc: + print(str(exc)) + current = "merge_pending" + except InvalidTransitionError as exc: + print(f"cb-phase: transition rejected — {exc}", file=sys.stderr) + return 1 + else: + current = "merge_pending" + + # The SHA the merge was queued at — anchor for the grant and the + # execution-time re-check. + pending_sha = _merge_pending_sha(store, args.subtask_id, task_id) + + # (d) Execution-time SHA re-check — BEFORE any approval logic. A push + # while queued invalidates the queue entry: fail-closed, no execution, + # and crucially no grant evaluation and no approval-request send — a + # grant can never equal the stale pending_sha, and burning the request + # marker for a SHA that will never merge would strand the subtask + # permanently un-approvable. Guarded on a recorded queue SHA: NULL-pending + # legacy rows keep their previous behavior. + if pending_sha is not None and head_sha != pending_sha: + code = _needs_rebase_or_blocked( + args.subtask_id, task_id, + f"cb-phase merge: head moved while queued " + f"({pending_sha} → {head_sha})", + store=store, project_dir=project_dir, + ) + if code is not None: + return code + print( + f"REJECTED [sha_moved]: PR #{pr_number} head moved while queued — " + f"merge_pending was recorded at {pending_sha}, head is now " + f"{head_sha}. Subtask → needs_rebase; rework and re-earn verdicts.", + file=sys.stderr, + ) + return EXIT_NEEDS_REBASE + + # A PR that can never merge (closed without merging) is a residual + # failure: block before bothering the approver about it. + if pr_state != "OPEN": + code = _transition_or_fail( + args.subtask_id, task_id, "blocked", + f"cb-phase merge: PR #{pr_number} is {pr_state} — cannot merge", + store=store, + ) + if code is not None: + return code + print( + f"BLOCKED [merge_failed]: PR #{pr_number} is {pr_state} — cannot " + "merge. Escalation via watchdog; stop and await.", + file=sys.stderr, + ) + return EXIT_MERGE_FAILED + + # (e) Approval — required for every task in V1 ('none' is rejected at + # registration; a NULL snapshot defaults to 'owner', never to skipped). + # Runs strictly AFTER the SHA re-check above, so a request is only ever + # sent (and its send-once marker only ever burned) for a SHA that can + # still merge. + task = store.get_task(task_id) + approver_spec = (task.merge_approval if task is not None else None) or "owner" + subtask = store.get_subtask(args.subtask_id, task_id) + granted = ( + subtask.merge_approved_sha is not None + and pending_sha is not None + and subtask.merge_approved_sha == pending_sha + ) + if not granted: + if subtask.merge_approved_sha is not None: + print( + f"cb-phase: recorded approval is pinned to " + f"{subtask.merge_approved_sha}, not the queued " + f"{pending_sha} — re-approval required.", + file=sys.stderr, + ) + if subtask.merge_approval_requested_sha == pending_sha: + print( + f"cb-phase: awaiting approval for PR #{pr_number} " + f"(subtask {args.subtask_id}, approver {approver_spec}) — " + "request already sent. Re-run after cb approve." + ) + return 0 + try: + _send_approval_request( + project_dir, task, args.subtask_id, pr_number, + pending_sha, approver_spec, + ) + except Exception: + # Marker-after-send: the send-once marker stays unburned, so the + # next invocation retries the request. The subtask legitimately + # rests at merge_pending either way. + logger.exception("approval-request send failed") + print( + f"cb-phase: awaiting approval for PR #{pr_number} " + f"(subtask {args.subtask_id}, approver {approver_spec}) — " + "request send FAILED; will retry on next invocation.", + file=sys.stderr, + ) + return 0 + store.mark_merge_approval_requested( + args.subtask_id, task_id, pending_sha, + ) + print( + f"cb-phase: awaiting approval for PR #{pr_number} " + f"(subtask {args.subtask_id}) — request sent to {approver_spec}. " + f"Re-run after cb approve {pr_number}." + ) + return 0 + + # (f) Mergeability pre-check: a conflicted PR can never land — send it + # back for rebase without attempting the merge. + if pr.get("mergeable") == "CONFLICTING": + code = _needs_rebase_or_blocked( + args.subtask_id, task_id, + f"cb-phase merge: PR #{pr_number} is conflicted against its base", + store=store, project_dir=project_dir, + ) + if code is not None: + return code + print( + f"REJECTED [conflicted]: PR #{pr_number} has merge conflicts. " + "Subtask → needs_rebase; rebase and re-earn verdicts.", + file=sys.stderr, + ) + return EXIT_NEEDS_REBASE + + # (g) Execute, pinned to the approved commit. GitHub itself rejects the + # merge if the head moved between our snapshot and the execution. + merge_code, output = _gh_merge(pr_number, worktree, pending_sha, repo=slug) + if merge_code == 0: + code = _transition_or_fail( + args.subtask_id, task_id, "merged", + f"cb-phase merge: PR #{pr_number} merged", + store=store, head_sha=head_sha, + ) + if code is not None: + return code + print( + f"cb-phase: PR #{pr_number} merged; subtask {args.subtask_id} " + f"→ merged (task {task_id})." + ) + _delete_remote_branch(pr, worktree) + return 0 + + # (h) Failure classification — verify effects first. ``gh`` exiting + # non-zero does NOT mean the merge did not happen (a timeout after the + # merge API call landed produced exactly this misclassification), so the + # PR is re-snapshotted before anything is recorded: + # + # MERGED → record merged, exit 0 (only the report failed) + # head ≠ pending_sha → needs_rebase (covers --match-head-commit + # rejections) + # CONFLICTING → needs_rebase (structured field preferred; the + # _CONFLICT_RE text match is the fallback only) + # otherwise → blocked, reason recorded — the watchdog's + # blocked-subtask patrol escalates to the owner + # once. + # + # An unavailable re-snapshot classifies nothing: the subtask rests at + # ``merge_pending`` (re-invocation reconciles) rather than risking a + # phantom ``blocked`` over a PR that actually merged. + tail = _output_tail(output) + resnap = _pr_snapshot(pr_number, worktree, repo=slug) + if resnap is None: + print( + f"REJECTED [pr_query_failed]: gh pr merge #{pr_number} failed " + f"(exit {merge_code}): {tail} — and the post-failure PR snapshot " + "is unavailable, so the outcome cannot be classified. Subtask " + "rests at merge_pending; re-run to reconcile.", + file=sys.stderr, + ) + return EXIT_PR_QUERY_FAILED + + if resnap.get("state") == "MERGED": + code = _transition_or_fail( + args.subtask_id, task_id, "merged", + f"cb-phase merge: post-failure reconcile: gh exited " + f"{merge_code} but PR #{pr_number} is MERGED", + store=store, head_sha=resnap.get("headRefOid") or None, + ) + if code is not None: + return code + print( + f"cb-phase: PR #{pr_number} merged (gh exited {merge_code} but " + f"the merge landed); subtask {args.subtask_id} → merged " + f"(task {task_id})." + ) + _delete_remote_branch(resnap, worktree) + return 0 + + resnap_head = resnap.get("headRefOid") or None + if pending_sha is not None and resnap_head != pending_sha: + code = _needs_rebase_or_blocked( + args.subtask_id, task_id, + f"cb-phase merge: head moved during execution " + f"({pending_sha} → {resnap_head}); gh exited {merge_code}: {tail}", + store=store, project_dir=project_dir, + ) + if code is not None: + return code + print( + f"REJECTED [sha_moved]: PR #{pr_number} head moved during " + f"execution — merge was pinned to {pending_sha}, head is now " + f"{resnap_head}. Subtask → needs_rebase; rework and re-earn " + "verdicts.", + file=sys.stderr, + ) + return EXIT_NEEDS_REBASE + + if resnap.get("mergeable") == "CONFLICTING" or _CONFLICT_RE.search(output): + code = _needs_rebase_or_blocked( + args.subtask_id, task_id, + f"cb-phase merge: gh reported a conflict merging PR " + f"#{pr_number}: {tail}", + store=store, project_dir=project_dir, + ) + if code is not None: + return code + print( + f"REJECTED [conflicted]: gh could not merge PR #{pr_number} " + f"(conflict): {tail}. Subtask → needs_rebase.", + file=sys.stderr, + ) + return EXIT_NEEDS_REBASE + + code = _transition_or_fail( + args.subtask_id, task_id, "blocked", + f"cb-phase merge: gh pr merge #{pr_number} failed " + f"(exit {merge_code}): {tail}", + store=store, + ) + if code is not None: + return code + print( + f"BLOCKED [merge_failed] (exit {merge_code}): {tail}. " + "Reason recorded; escalation via watchdog. Stop and await.", + file=sys.stderr, + ) + return EXIT_MERGE_FAILED + + +def add_merge_subparser(sub: argparse._SubParsersAction) -> None: + """Register the ``merge`` subcommand on the ``cb-phase`` parser.""" + merge = sub.add_parser( + "merge", + help="Execute a gated, approved merge (the only sanctioned merge path).", + ) + merge.add_argument("subtask_id", help="Subtask identifier.") + merge.add_argument( + "--task", + required=False, + help="Task label (non-authoritative; active room resolved from " + ".codeband_room).", + ) + merge.add_argument( + "--pr", + type=int, + required=False, + help="Pull request number — required on the first invocation, " + "persisted for argument-less reconcile re-runs.", + ) + merge.add_argument( + "--worktree", + default=".", + help="Directory gh runs in for repo resolution (default: cwd).", + ) + merge.add_argument( + "--project-dir", + default=".", + help="Project directory containing codeband.yaml (default: cwd).", + ) + merge.set_defaults(func=_cmd_merge) + + +def record_approval_grant(project_dir: Path | str, pr_number: int) -> list[str]: + """Record a SHA-pinned merge-approval grant for ``pr_number``'s subtask(s). + + The store half of ``cb approve `` (the chat half is unchanged): + resolves the active task, finds the subtask(s) bound to the PR (bound by + ``cb-phase merge`` persisting ``--pr``), and writes the grant **at the + SHA the approval request named** (``merge_approval_requested_sha``) — + never at whatever the live head happens to be. A grant can only ever + exist for a SHA a request named, so the merge leg's granted==pending + check keeps its intended meaning. Concretely, per bound subtask: + + - request marker matches the live PR head → grant AT the requested SHA; + - request marker set but the head moved → **refuse** (raises, exit + nonzero, nothing recorded — the chat half never goes out): the human + approved a commit that is no longer what would merge; the merge leg's + re-queue will send a fresh request; + - no request marker (no request ever sent) → no grant, loud stderr note — + a grant is never recorded speculatively. + + Returns a human-readable line per grant recorded — empty when no subtask + is bound to the PR (the legacy chat-only flow, which records nothing and + changes nothing) or when no bound subtask has requested approval yet. + + ``project_dir`` is the raw ``--dir`` flag value: it goes through + :func:`~codeband.cli.handoff.resolve_project_dir` (explicit flag > + ``$CODEBAND_PROJECT_DIR`` > cwd), the same contract as every ``cb-phase`` + leg, so ``cb approve`` works from any cwd when the env var is set. + + Raises :class:`RuntimeError` when the active task cannot be resolved + (an "approval" recorded against nothing is indistinguishable from a + success to the human who ran it — fail loud instead), and when a bound + subtask exists but the PR head cannot be read — a grant that silently + pins nothing would strand the merge leg in awaiting-approval forever. + A PR with no bound subtask is NOT an error (the legacy chat-only flow), + but it prints a loud stderr note that no durable grant was recorded. + """ + project_dir = resolve_project_dir(project_dir) + store = _resolve_store(project_dir) + task_id, error_code = _resolve_task_id(project_dir, store, None) + if error_code is not None: + # _resolve_task_id already printed the specific cause to stderr. + raise NoActiveTaskError( + "cb approve: no active task — grant not recorded." + ) + + subtasks = [ + s for s in store.find_subtasks_by_pr(task_id, pr_number) + if s.state not in {"merged", "abandoned"} + ] + if not subtasks: + # Approve-before-binding: the verify/merge legs haven't bound this PR + # to a subtask yet, so there is nothing durable to pin a grant to. + # The chat half still goes out, but the human must know this recorded + # NOTHING — silently looking successful is how approvals got lost. + print( + f"cb approve: NO durable merge grant was recorded — no subtask " + f"is bound to PR #{pr_number} yet. Re-run `cb approve " + f"{pr_number}` after the merge leg requests approval.", + file=sys.stderr, + ) + return [] + + # Grants are scoped to the rows that REQUESTED approval (the merge leg's + # marker-after-send wrote merge_approval_requested_sha) — never to every + # row that merely references the PR. No request ever sent → nothing to + # grant against; a speculative grant at the live head is exactly the hole + # that let a moved branch merge with a stale-looking approval. + requested = [s for s in subtasks if s.merge_approval_requested_sha is not None] + if not requested: + print( + f"cb approve: NO durable merge grant was recorded — no approval " + f"request has been sent for PR #{pr_number} yet (no requested " + f"SHA on record). Re-run `cb approve {pr_number}` after the " + "merge leg requests approval.", + file=sys.stderr, + ) + return [] + + # Repo identity comes from config (--repo ), never from whatever + # repo the current cwd happens to be in. + from codeband.github.prs import repo_slug + + try: + slug = repo_slug(load_config(project_dir).repo.url) + except ValueError as exc: + raise RuntimeError( + f"cb approve: cannot derive the GitHub repo slug from config " + f"repo.url ({exc}) — approval grant not recorded." + ) from None + pr = _pr_snapshot(pr_number, project_dir, repo=slug) + head_sha = (pr or {}).get("headRefOid") or None + if head_sha is None: + raise RuntimeError( + f"cb approve: could not read PR #{pr_number}'s head SHA via gh — " + "approval grant not recorded. Check gh auth/network and re-run." + ) + + # The grant goes to the requested SHA, and only when the live head still + # IS that SHA. A moved head means the human would be approving a commit + # that is no longer what would merge — refuse outright (exit nonzero, no + # grant, no chat half); the merge leg's re-queue sends a fresh request. + matching = [s for s in requested if s.merge_approval_requested_sha == head_sha] + stale = [s for s in requested if s.merge_approval_requested_sha != head_sha] + if not matching: + stale_shas = ", ".join(sorted({s.merge_approval_requested_sha for s in stale})) + raise RuntimeError( + f"cb approve: PR #{pr_number} head is {head_sha} but the " + f"approval request was for {stale_shas} — the branch moved. " + "Wait for the re-queue and the fresh request." + ) + for sub in stale: + # Mixed multi-row PR: grant the current-head rows below, but never + # the rows whose request a push has already invalidated. + print( + f"cb approve: subtask {sub.subtask_id} NOT granted — its " + f"approval request was for {sub.merge_approval_requested_sha}, " + f"but PR #{pr_number} head is {head_sha}. Wait for its re-queue " + "and fresh request.", + file=sys.stderr, + ) + + task = store.get_task(task_id) + approved_by = (task.merge_approval if task is not None else None) or "owner" + + recorded = [] + for sub in matching: + # Idempotent: a second grant at the same SHA is a no-op — the + # grant is already durably recorded, nothing to do. + if sub.merge_approved_sha == sub.merge_approval_requested_sha: + print( + f"NO-OP [already_granted] " + f"{sub.subtask_id}@{sub.merge_approval_requested_sha}; " + "nothing to do" + ) + continue + store.record_merge_approval( + sub.subtask_id, task_id, + approved_by=approved_by, + approved_sha=sub.merge_approval_requested_sha, + ) + recorded.append( + f"Merge approval recorded for subtask {sub.subtask_id} " + f"at {sub.merge_approval_requested_sha} (approver: {approved_by})." + ) + return recorded diff --git a/src/codeband/config.py b/src/codeband/config.py index 6794bd8..3062fd3 100644 --- a/src/codeband/config.py +++ b/src/codeband/config.py @@ -2,12 +2,14 @@ from __future__ import annotations +import os +import tempfile from enum import Enum from pathlib import Path from typing import Literal import yaml -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator # Reject unknown fields by default. Catches YAML indentation bugs (e.g. # `agents:` nested under `repo:` instead of at top level) that would @@ -69,18 +71,31 @@ class MergemasterConfig(_StrictModel): auto_merge: AutoMergePolicy = AutoMergePolicy.LOW +# Role names the watchdog resolves thresholds for — the universe of valid +# `role_stale_thresholds` keys. Matches the AGENT_ROLE values the runner +# registers in its agent_id→role map. +_WATCHDOG_ROLE_KEYS = { + "coder", "reviewer", "planner", "plan_reviewer", + "conductor", "mergemaster", "watchdog", +} + + class WatchdogConfig(_StrictModel): """Configuration for the watchdog agent.""" - check_interval_seconds: int = 120 - stale_threshold_seconds: int = 300 - nudge_grace_seconds: int = 60 + # All interval/threshold knobs require >= 1: a zero here doesn't disable + # the feature, it bricks the swarm silently (check_interval_seconds: 0 + # hot-loops the Band API; a zero threshold marks every agent stale on + # every patrol). + check_interval_seconds: int = Field(default=120, ge=1) + stale_threshold_seconds: int = Field(default=300, ge=1) + nudge_grace_seconds: int = Field(default=60, ge=1) # After an agent responds to a nudge, suppress further nudges for this # long. Without it, a legitimately-idle agent (e.g. Planner waiting on # human approval) gets re-nudged every `stale_threshold_seconds` forever, # because the old logic wiped the per-agent state the moment the agent # replied. Escalation (nudged-but-no-response) is unaffected. - nudge_suppression_seconds: int = 1800 + nudge_suppression_seconds: int = Field(default=1800, ge=1) # Per-role threshold overrides. Coders and the Mergemaster do long-running # work and are instructed to stay silent in chat while working — a uniform # 5-minute threshold nudges them mid-task. Roles not listed here fall back @@ -88,13 +103,86 @@ class WatchdogConfig(_StrictModel): role_stale_thresholds: dict[str, int] = Field( default_factory=lambda: {"coder": 900, "mergemaster": 900}, ) + + @field_validator("role_stale_thresholds") + @classmethod + def _known_role_keys(cls, v: dict[str, int]) -> dict[str, int]: + """Reject unknown role keys — a typo'd key is otherwise silently + ignored at threshold lookup, defeating the override's purpose.""" + unknown = sorted(set(v) - _WATCHDOG_ROLE_KEYS) + if unknown: + raise ValueError( + f"Unknown role key(s) in role_stale_thresholds: {unknown}. " + f"Valid roles: {sorted(_WATCHDOG_ROLE_KEYS)}" + ) + return v # When the Conductor records that the user-facing task is complete or # waiting on human merge approval via a `swarm status …` memory envelope, # suppress all nudging for this long. Prevents the watchdog from poking # correctly-idle agents between actionable steps. Falls back to time-based # behavior if no envelope is present (e.g. Conductor crashed before writing # one). - swarm_idle_grace_seconds: int = 1800 + swarm_idle_grace_seconds: int = Field(default=1800, ge=1) + # Cycle/stall cap (RFC WS4). When a subtask makes no mechanical progress — + # no git-HEAD change on its branch and no new transition-log entry — for + # this many consecutive patrols, the watchdog marks it blocked and escalates + # to the Conductor + human. Catches stalls that chat-recency alone misses + # (e.g. a timed-out turn that produces no commit and no transition). + # ge=1: a zero would mark every subtask blocked on its first patrol. + max_phase_visits: int = Field(default=10, ge=1) + # Toggle for the mechanical (git-HEAD / PR-state / transition-log) progress + # signals. When False the watchdog falls back to chat-recency-only behavior. + git_progress_check: bool = True + # Deep full-history integrity sweep cadence (Stage-3 PR3). The incremental + # integrity rung runs every patrol but, by construction, only re-reads rows + # PAST its remembered tip — it cannot see an in-place edit of an interior, + # already-verified row. A separate, longer-cadence rung walks BOTH hash + # chains from row 1 (like `cb verify-log`) every this-many patrols to close + # that blind spot. Code-driven: it runs regardless of whether a verifier LLM + # seat is allocated, because integrity is a safety sweep, not an LLM + # behavior. ge=1: a zero would mark the modulo undefined / never run. + # Default 30 patrols ≈ hourly at the default 120s patrol interval — + # proportionate to ledger size at this scale. + full_integrity_interval_patrols: int = Field(default=30, ge=1) + # Transport-health heal rung: detect and heal turn-boundary 422 pins. When + # a mark-processed POST 422s at the turn boundary, the delivery stays stuck + # in ``processing`` and the agent's cursor is pinned — a chat nudge cannot + # reach it. The watchdog re-asserts ``processing → processed`` on the + # stuck delivery, which advances the cursor so the agent's next poll + # flows. ``transport_pin_threshold_seconds`` MUST be conservatively longer + # than any plausible real turn so mid-turn 422s are never touched — the + # default 1800s (30 min) is 2× the longest role threshold (coder / + # mergemaster at 900s). After ``transport_heal_max_attempts`` failed + # heals on the same delivery the pin escalates to the owner once and no + # further heals fire (no infinite heal storm on a server-side rejection). + # ``transport_heal_enabled`` is the kill switch. + transport_heal_enabled: bool = True + transport_pin_threshold_seconds: int = Field(default=1800, ge=1) + transport_heal_max_attempts: int = Field(default=3, ge=1) + # Approval→merge backstop rung: re-@mention the Mergemaster when a + # merge_pending subtask has a recorded human approval at the current HEAD + # but LLM dispatch has stalled, instead of letting the watchdog escalate + # an already-approved PR to blocked. + # ``merge_approval_backstop_seconds``: staleness window (seconds since the + # last grant or backstop nudge) before the first renudge fires. + # ``merge_approval_backstop_max_renudges``: number of backstop re-@mentions + # the rung may send per approved-SHA (0 disables the send leg entirely + # while still owning the patrol; 1 = the default, sends once then releases + # so a genuinely hung Mergemaster still surfaces as blocked). + merge_approval_backstop_seconds: int = Field(default=240, ge=1) + merge_approval_backstop_max_renudges: int = Field(default=1, ge=0) + # Acceptance-advance rung: re-@mention the Mergemaster when a subtask has + # passed acceptance (``acceptance_passed``) but merge dispatch has stalled, + # instead of letting the watchdog escalate a verified-and-accepted PR to + # blocked. Mirrors the approval→merge backstop but targets the state BEFORE + # ``merge_pending`` in the verifier-enabled path. + # ``acceptance_advance_backstop_seconds``: staleness window (seconds since + # acceptance_passed entry, or since the last renudge) before the rung fires. + # ``acceptance_advance_max_renudges``: re-@mentions per acceptance_passed + # entry (0 disables the send leg; 1 = default, sends once then releases so + # a genuinely hung Mergemaster still surfaces as blocked). + acceptance_advance_backstop_seconds: int = Field(default=240, ge=1) + acceptance_advance_max_renudges: int = Field(default=1, ge=0) # ─── Worker-pool config primitives ────────────────────────────────────────── @@ -123,7 +211,7 @@ class PoolEntry(BaseModel): # WorkerSupervisor; only SIGINT/SIGTERM ends a session. Kept for backward # compatibility so existing codeband.yaml files don't fail to parse. max_restarts: int = 5 - restart_delay_seconds: float = 5.0 + restart_delay_seconds: float = Field(default=5.0, ge=0.0) class FrameworkPool(_StrictModel): @@ -186,7 +274,7 @@ def _default_planners_pool() -> FrameworkPool: def _default_plan_reviewers_pool() -> PlanReviewersConfig: return PlanReviewersConfig( - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ) @@ -197,14 +285,67 @@ def _default_coders_pool() -> FrameworkPool: # their lighter workloads. return FrameworkPool( claude_sdk=PoolEntry(count=1, model="claude-opus-4-7"), - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ) def _default_reviewers_pool() -> ReviewersConfig: return ReviewersConfig( claude_sdk=PoolEntry(count=1, model="claude-sonnet-4-6"), - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), + ) + + +class VerifiersConfig(_StrictModel): + """Verifier pool — dual-mandate governance gate (PR #63). + + The Verifier carries a dual mandate: (1) contract conformance — the solution + actually satisfies the registered acceptance criteria; and (2) evidence + integrity — the durable evidence (PR body, collected counts, SHAs) is + truthful and matches reality. Both axes are governance duties, opposite-vendor + by design; a legitimate veto on either axis is authoritative. + + Mirrors ReviewersConfig in shape (no review_guidelines). The verdict leg is + wired (``cb-phase verify-acceptance``) and the Verifier runtime + dispatch + exist, so the seat is ACTIVE by default (count=1 per vendor): a configured + verifier makes ``verify_acceptance`` a required, SHA-pinned merge verdict + (see ``state/registration.py``). Setting both counts to 0 opts back out — + the verdict leaves the required snapshot and tasks merge straight from + ``review_passed``. Opposite-vendor pairing (verifier.vendor != coder.vendor) + is the adversarial signal; single-vendor configs degrade gracefully + (same-vendor checking; cb doctor warns, never fails). + """ + + claude_sdk: PoolEntry = PoolEntry() + codex: PoolEntry = PoolEntry() + + def total_count(self) -> int: + return self.claude_sdk.count + self.codex.count + + def active_frameworks(self) -> list[Framework]: + active = [] + if self.claude_sdk.count > 0: + active.append(Framework.CLAUDE_SDK) + if self.codex.count > 0: + active.append(Framework.CODEX) + return active + + def entry_for(self, framework: Framework) -> PoolEntry: + return self.claude_sdk if framework == Framework.CLAUDE_SDK else self.codex + + +def _default_verifiers_pool() -> VerifiersConfig: + # count=1 per vendor activates the seat by default now that the Verifier + # runtime + dispatch exist to produce the verdict. One Claude + one Codex + # verifier gives every default coder (Claude + Codex) an opposite-vendor + # acceptance checker — the adversarial signal — and lands the default swarm + # at exactly 10 Band seats (the free-tier cap). The iff-configured coupling + # in ``state/registration.py`` makes ``verify_acceptance`` a required, + # SHA-pinned merge verdict for this default; a user who sets both counts to + # 0 opts back out (merges straight from ``review_passed``, no acceptance). + return VerifiersConfig( + claude_sdk=PoolEntry(count=1, model="claude-opus-4-7"), + codex=PoolEntry(count=1, model="gpt-5.5"), ) @@ -220,9 +361,150 @@ class AgentsConfig(_StrictModel): plan_reviewers: PlanReviewersConfig = Field(default_factory=_default_plan_reviewers_pool) coders: FrameworkPool = Field(default_factory=_default_coders_pool) reviewers: ReviewersConfig = Field(default_factory=_default_reviewers_pool) + verifiers: VerifiersConfig = Field(default_factory=_default_verifiers_pool) watchdog: WatchdogConfig = Field(default_factory=WatchdogConfig) + # Optional verify command run by the ``cb-phase`` handoff gate (RFC WS3). + # When set, the command must exit 0 before a subtask may advance to + # ``review_pending``. ``None`` skips the verify gate. + handoff_verify_command: str | None = None + + # Verdict legs a subtask must clear before its PR may merge (Stage-2). + # Resolved and validated at task-registration time by + # ``state/registration.py`` — the single writer of "a task exists" — and + # snapshotted onto the tasks row, so a mid-task config edit cannot change + # what an in-flight task requires. ``None`` (key absent) resolves to + # ``["review"]`` plus ``"verify"`` only when ``handoff_verify_command`` is + # configured, plus ``"verify_acceptance"`` whenever a verifier is + # configured (the iff-configured coupling in ``state/registration.py``). An + # explicit ``[]`` is a loud error unless ``allow_ungated_merge`` is also set. + # Known verdicts: ``verify`` (requires ``handoff_verify_command``), + # ``review``, and ``verify_acceptance`` (requires a configured verifier). + # The merge-eligibility gate (``state/fsm.py``) reads the snapshot. + required_verdicts: list[str] | None = None + + # Escape hatch for ``required_verdicts: []`` — the name is deliberately + # ugly so "every PR merges with zero verdicts" can never be configured by + # accident or typo. Without it, an empty list fails registration. + allow_ungated_merge: bool = False + + # Who must approve a ``cb-phase merge`` before it executes (Stage-2). + # ``"owner"`` (default) routes the approval request to the task owner; + # ``"human:"`` routes it to the named human. Resolved and + # validated at task-registration time by ``state/registration.py`` — + # exactly like ``required_verdicts`` — and snapshotted onto the tasks row, + # so a mid-task config edit cannot change an in-flight task's approver. + # ``"none"`` is reserved and rejected (unapproved merges are not supported + # in V1); any other value fails registration loudly. + merge_approval: str = "owner" + + # Per-subtask review-round cap (RFC two-level model). Once a subtask has + # entered ``review_failed`` this many times, the FSM refuses to send it back + # to ``in_progress`` for another rework cycle — the only legal move is + # ``blocked`` (escalation). Bounds a *productive* review loop (real commits + # each round, HEAD advancing) that the watchdog's stall cap + # (``watchdog.max_phase_visits``) by design never fires on. Default 6 was + # validated in dogfood: the previous default of 3 false-blocked productive PRs + # on their third round; 6 reflects the observed 4-6-round plateau for + # non-trivial changes. Wired into ``fsm.transition`` via ``max_review_rounds`` + # (default ``fsm.MAX_REVIEW_ROUNDS``); the live caller lands with P5 activation. + # ge=1: a zero would block every subtask at its first review. + max_review_rounds: int = Field(default=6, ge=1) + + # Per-subtask verify-attempt cap (RFC two-level model). Once a subtask has + # had this many ``cb-phase verify`` attempts *rejected* (a failed gate: dirty + # tree / PR not open / verify command non-zero), the handoff CLI refuses a + # further attempt and escalates the subtask to ``blocked``. Bounds a verify + # loop where the coder commits real code each attempt — git HEAD advances, so + # the watchdog's stall cap (``watchdog.max_phase_visits``) by design never + # fires, and the review-round cap (``max_review_rounds``) never sees it (the + # subtask never reaches ``review_failed``). Read by ``cli/handoff.py`` (the + # already-live enforcement seam); default 20 matches ``fsm`` / + # ``cli.handoff.MAX_VERIFY_ATTEMPTS``. ge=1: a zero would block every + # subtask on its first verify attempt. + max_verify_attempts: int = Field(default=20, ge=1) + + # Per-subtask rebase-round cap (S2-1). Once a subtask has *entered* + # ``needs_rebase`` this many times (merge-gate send-backs: a moved head + # while queued, a conflicted PR), the merge leg escalates the next + # send-back to ``blocked`` (``BLOCKED [rebase_cap_reached]``) instead of + # another rework cycle. Bounds the one loop neither sibling cap can see: + # each rebase round writes fresh transition rows, so the watchdog's stall + # cap (``watchdog.max_phase_visits``) by construction never fires, and the + # loop never enters ``review_failed``, so ``max_review_rounds`` never + # counts it. Live-read by ``cli/merge.py`` like the sibling caps; default + # matches ``fsm.MAX_REBASE_ROUNDS``. ge=1: a zero would block every + # subtask on its first send-back. + max_rebase_rounds: int = Field(default=3, ge=1) + + # Exit codes from the verify command that classify as an infrastructure + # failure rather than a test failure. When the command exits with one of + # these codes the verify attempt is NOT counted against the coder's budget. + # ``None`` resolves to the module-level default in ``cli/handoff.py`` + # (``_DEFAULT_INFRA_EXIT_CODES``): {124, 126, 127, 137, 143}. Can also be + # overridden per-repo via ``verify_infra_exit_codes`` in the worktree's + # ``.codeband.yaml``. + verify_infra_exit_codes: list[int] | None = None + + # How quickly an idle agent re-polls its pending message queue — the + # SDK's Phase-2 idle resync, the delivery backstop for missed websocket + # pushes. Passed to every role uniformly (coders included — same intake + # stack) as ``SessionConfig(idle_resync_seconds=...)`` at Agent.create + # (``runner._create_band_agent``). Lower values recover faster from + # missed pushes but generate more REST traffic: each resync fires one + # /next poll per subscribed room. ge=1: the SDK rejects values <= 0 + # (they would turn the resync into a REST hot loop). + idle_resync_seconds: int = Field(default=30, ge=1) + + # Whole-turn budget for every Codex role (finding 22 mitigation 4a; also + # the root of the original shakedown finding 4). band-sdk's + # ``CodexAdapterConfig.turn_timeout_s`` defaults to 180s and is NOT + # activity-extended — the remaining budget shrinks from turn start no + # matter how much real work streams by — so every coding turn longer + # than 3 minutes was abandoned mid-flight: the adapter sends + # turn/interrupt and stops listening while the Codex CLI keeps working, + # the primary desync behind dormant Codex threads. Wired into + # ``CodexAdapterConfig(turn_timeout_s=...)`` at every Codex + # role-constructor seam (all six runners). ge=60: anything shorter + # cannot fit even a trivial tool-using turn. + # + # Do NOT set this to 0 expecting "unlimited": band-sdk 0.2.11 has no + # no-timeout special case for this knob. The event loop computes + # ``max(0.0, turn_timeout_s - elapsed)`` (thenvoi/adapters/codex.py:683) + # and feeds it straight to ``asyncio.wait_for`` via + # ``recv_event(timeout_s=...)`` — only ``timeout_s=None`` means "wait + # forever" (rpc_base.py:191-193), and the adapter never passes None — so + # 0 means an IMMEDIATE TimeoutError on the first event wait: instant + # turn abandon. 0-as-unlimited semantics arrive with the upstream + # band-sdk fix; until that lands the ge=60 floor deliberately makes 0 + # unrepresentable. + codex_turn_timeout_seconds: int = Field(default=3600, ge=60) + + # Per-message retry budget for the SDK session + # (``SessionConfig.max_message_retries``, SDK default 1) — same plumbing + # as ``idle_resync_seconds`` at ``runner._create_band_agent``. With the + # SDK default, ONE transient turn failure permanently retires an + # @mention: the client-side retry tracker marks it failed with no + # server-side resolution, and the room's idle-resync backstop then + # re-fetches the same poisoned head and stops cold (upstream band-sdk + # defect, reported separately). Honest scope: raising this REDUCES the + # frequency of that silent-permafail/head-of-line failure; it does not + # eliminate it. ge=1: the SDK needs at least one attempt. + max_message_retries: int = Field(default=3, ge=1) + + # Message-delivery transport for swarm agents. ``sdk`` (default) is today's + # behavior: the SDK ExecutionContext's WebSocket + ``/next`` cursor, which + # can wedge on a swallowed mark-processed 422. ``jam`` is the opt-in + # wedge-immune path: inbound is pulled from the local jam daemon over its + # Unix-socket Control contract (durable per-peer queue, non-fatal acks, no + # head-of-line cursor). Read via ``runner._resolve_delivery_mode``, where the + # ``CODEBAND_DELIVERY`` env var overrides this field. The ``jam`` code is + # fully dormant (never imported) unless this resolves to ``jam``. See + # ``codeband/transport/``. Recovery machinery (#102/#103/watchdog heal) stays + # active on the ``sdk`` path. + delivery: Literal["sdk", "jam"] = "sdk" + def total_agent_count(self) -> int: """Band.ai seats used (excluding Watchdog — reuses Conductor creds).""" return ( @@ -231,6 +513,7 @@ def total_agent_count(self) -> int: + self.plan_reviewers.total_count() + self.coders.total_count() + self.reviewers.total_count() + + self.verifiers.total_count() ) @@ -272,9 +555,16 @@ class CodebandConfig(_StrictModel): @classmethod def from_yaml(cls, path: Path) -> CodebandConfig: - """Load configuration from a YAML file.""" + """Load configuration from a YAML file. + + ``yaml.safe_load`` yields ``None`` for a zero-byte / comments-only + file; normalize to ``{}`` (as ``AgentConfigFile.from_yaml`` already + does) so an empty ``codeband.yaml`` fails with the actionable + "repo: Field required" instead of the opaque "Input should be a + valid dictionary". + """ with open(path, encoding="utf-8") as f: - data = yaml.safe_load(f) + data = yaml.safe_load(f) or {} return cls.model_validate(data) def to_yaml(self, path: Path) -> None: @@ -297,10 +587,22 @@ def from_yaml(cls, path: Path) -> AgentConfigFile: return cls.model_validate(data) def to_yaml(self, path: Path) -> None: - """Write agent credentials to YAML.""" + """Write agent credentials to YAML, private (0600) and atomic.""" data = self.model_dump(mode="json") - with open(path, "w", encoding="utf-8") as f: - yaml.dump(data, f, default_flow_style=False, sort_keys=False) + if path.exists(): + os.chmod(path, 0o600) + tmp_fd, tmp_name = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + os.chmod(tmp_name, 0o600) + os.replace(tmp_name, path) + except Exception: + try: + os.unlink(tmp_name) + except OSError: + pass + raise def get(self, key: str) -> AgentCredentials: """Get credentials for an agent key, raising if not found.""" @@ -313,6 +615,29 @@ def get(self, key: str) -> AgentCredentials: return self.agents[key] +def resolve_workspace_path(config: CodebandConfig, project_dir: Path) -> Path: + """Resolve ``workspace.path`` to an absolute path — the ONE shared rule. + + A relative ``workspace.path`` resolves against ``$WORKSPACE`` when that + env var is set (the Docker images set it to ``/workspace``, the shared + volume every container mounts), otherwise against ``project_dir``. An + absolute path is returned as-is. The runner, ``cb-phase``/``cb approve`` + (via ``cli/handoff.py:_resolve_store``), task registration + (``state/registration.py:resolve_state_dir``) and ``cb doctor`` all route + through this helper: two implementations of this rule is how containers + ended up with the runner reading ``/workspace/state/`` while ``cb-phase`` + looked in ``/app/config/.codeband/state/``. + """ + import os + + ws_path = Path(config.workspace.path) + if ws_path.is_absolute(): + return ws_path + workspace_env = os.environ.get("WORKSPACE") + base = Path(workspace_env) if workspace_env else project_dir + return base / ws_path + + def load_config(project_dir: Path | None = None) -> CodebandConfig: """Load codeband.yaml from project directory (defaults to cwd).""" project_dir = project_dir or Path.cwd() @@ -337,7 +662,7 @@ def load_agent_config(project_dir: Path | None = None) -> AgentConfigFile: return AgentConfigFile.from_yaml(config_path) -_SCALABLE_POOLS = {"planners", "plan_reviewers", "coders", "reviewers"} +_SCALABLE_POOLS = {"planners", "plan_reviewers", "coders", "reviewers", "verifiers"} def scale_pool( @@ -345,9 +670,9 @@ def scale_pool( ) -> CodebandConfig: """Set the capacity of a (pool, framework) entry in an existing config. - `pool` must be one of "planners" / "plan_reviewers" / "coders" / "reviewers". - Preserves model/restart settings on the pool entry. Saves the updated - config back to disk and returns it. + `pool` must be one of "planners" / "plan_reviewers" / "coders" / + "reviewers" / "verifiers". Preserves model/restart settings on the pool + entry. Saves the updated config back to disk and returns it. """ if count < 0: raise ValueError("count must be >= 0") diff --git a/src/codeband/doctor.py b/src/codeband/doctor.py index 0f01840..09058d5 100644 --- a/src/codeband/doctor.py +++ b/src/codeband/doctor.py @@ -224,13 +224,22 @@ def check_claude_auth(_ctx: Context) -> CheckResult: "Codeband will start with OAuth and keep the API key as a usage-limit fallback", ) if api and has_sub and not oauth: + prefer = os.environ.get("CODEBAND_CLAUDE_PREFER_API_KEY", "").strip().lower() + if prefer in ("1", "true", "yes", "on"): + return CheckResult( + Status.OK, + "Claude auth: ANTHROPIC_API_KEY (CODEBAND_CLAUDE_PREFER_API_KEY override active — " + "subscription OAuth will not take precedence)", + ) return CheckResult( Status.WARN, "ANTHROPIC_API_KEY set alongside host subscription OAuth — " "Codeband will start with the subscription and keep the API key as a fallback", remediation=( "This is valid. ANTHROPIC_API_KEY is used only if the Claude " - "Pro/Max subscription path reports a usage-limit error." + "Pro/Max subscription path reports a usage-limit error.\n" + "Set CODEBAND_CLAUDE_PREFER_API_KEY=1 to force API-key precedence " + "(e.g. when parallel coders would exhaust subscription rate limits)." ), ) if oauth: @@ -247,7 +256,7 @@ def _needs_codex(ctx: Context) -> bool: if ctx.config is None: return False agents = ctx.config.agents - for pool_name in ("planners", "plan_reviewers", "coders", "reviewers"): + for pool_name in ("planners", "plan_reviewers", "coders", "reviewers", "verifiers"): pool = getattr(agents, pool_name) if pool.entry_for(Framework.CODEX).count > 0: return True @@ -265,9 +274,29 @@ def check_codex_auth(ctx: Context) -> CheckResult: creates `~/.codex/` unconditionally as a bind-mount target, so a bare directory is not evidence that `codex login` was ever run. """ - if os.environ.get("OPENAI_API_KEY"): - return CheckResult(Status.OK, "OPENAI_API_KEY set") auth_path = Path.home() / ".codex" / "auth.json" + api = os.environ.get("OPENAI_API_KEY") + if api and auth_path.exists(): + prefer = os.environ.get("CODEBAND_CODEX_PREFER_API_KEY", "").strip().lower() + if prefer in ("1", "true", "yes", "on"): + return CheckResult( + Status.OK, + "Codex auth: OPENAI_API_KEY (CODEBAND_CODEX_PREFER_API_KEY override active — " + "ChatGPT subscription auth will not take precedence)", + ) + return CheckResult( + Status.WARN, + "OPENAI_API_KEY set alongside Codex CLI login — " + "Codeband will start with the subscription and keep the API key as a fallback", + remediation=( + "This is valid. OPENAI_API_KEY is used only if the Codex " + "ChatGPT subscription path reports a usage-limit error.\n" + "Set CODEBAND_CODEX_PREFER_API_KEY=1 to force API-key precedence " + "(e.g. when parallel coders would exhaust subscription rate limits)." + ), + ) + if api: + return CheckResult(Status.OK, "OPENAI_API_KEY set") if auth_path.exists(): return CheckResult( Status.OK, f"Codex CLI login detected ({auth_path})", @@ -317,6 +346,21 @@ def check_codeband_yaml(ctx: Context) -> CheckResult: ) +def check_handoff_verify_command(ctx: Context) -> CheckResult: + if ctx.config is None: + return CheckResult(Status.SKIP, "codeband.yaml not loaded") + if ctx.config.agents.handoff_verify_command: + return CheckResult(Status.OK, "handoff verify command configured") + return CheckResult( + Status.INFO, + "handoff verify is available but not active", + remediation=( + "Set `agents.handoff_verify_command` in codeband.yaml to require " + "a repo-specific verify command before review." + ), + ) + + def check_agent_config_yaml(ctx: Context) -> CheckResult: if ctx.config is None: return CheckResult(Status.SKIP, "codeband.yaml not loaded") @@ -407,6 +451,42 @@ def _check_pair(label: str, author_pool, critic_pool) -> None: return CheckResult(Status.OK, "Cross-model pairing possible for all pools") +def check_verifier_pairing(ctx: Context) -> CheckResult: + """Warn when the verifier pool can't pair opposite-vendor to any active coder. + + Fires only when at least one verifier is configured (count > 0). The + product default configures one verifier per vendor, so this is normally an + active cross-vendor capacity check. Explicit verifier opt-out (both counts + set to 0) skips it. + """ + if ctx.config is None: + return CheckResult(Status.SKIP, "codeband.yaml not loaded") + + agents = ctx.config.agents + if agents.verifiers.total_count() == 0: + return CheckResult(Status.SKIP, "verifier seat not active (count=0)") + + coder_fws = set(agents.coders.active_frameworks()) + verifier_fws = set(agents.verifiers.active_frameworks()) + + if not coder_fws: + return CheckResult(Status.SKIP, "no coders configured") + + if len(verifier_fws) == 1 and verifier_fws <= coder_fws: + only = next(iter(verifier_fws)) + return CheckResult( + Status.WARN, + f"Verifier pairing degraded: only {only.value} verifiers → " + "same-vendor checking (reduced adversarial value)", + remediation=( + "For adversarial evidence verification, enable opposite-framework " + "verifiers — e.g. add `verifiers.codex: {{count: 1}}` when all " + "coders are claude_sdk." + ), + ) + return CheckResult(Status.OK, "Verifier opposite-vendor pairing possible") + + def check_agent_count_vs_tier(ctx: Context) -> CheckResult: """Warn when total agents > 10 (Band.ai free-tier cap).""" if ctx.config is None: @@ -465,6 +545,95 @@ def _conductor_rest_client(ctx: Context): ) +def _human_rest_client(ctx: Context): + """Return a human-scope REST client, or a CheckResult to short-circuit.""" + if ctx.config is None: + return CheckResult(Status.SKIP, "codeband.yaml not loaded") + api_key = os.environ.get("BAND_API_KEY") + if not api_key: + return CheckResult( + Status.WARN, + "Could not verify platform agents — BAND_API_KEY is not set", + remediation="Set BAND_API_KEY and rerun `cb doctor`, or run `cb setup-agents`.", + ) + try: + from thenvoi_rest import AsyncRestClient + except ImportError as exc: + return CheckResult(Status.FAIL, f"thenvoi_rest not importable: {exc}") + return AsyncRestClient(api_key=api_key, base_url=ctx.config.band.rest_url) + + +async def check_agent_platform_existence(ctx: Context) -> CheckResult: + """Confirm configured agent IDs still exist on Band.ai. + + ``check_agent_config_yaml`` verifies local shape/count. This networked check + verifies that the persisted agent IDs still resolve platform-side, catching + deleted agents before the swarm starts with dead credentials. + """ + if ctx.config is None or ctx.agent_config is None: + return CheckResult(Status.SKIP, "codeband.yaml or agent_config.yaml not loaded") + if ctx.agent_config_error: + return CheckResult(Status.SKIP, "agent_config.yaml failed to parse") + + client = _human_rest_client(ctx) + if isinstance(client, CheckResult): + return client + + try: + platform_response = await asyncio.wait_for( + client.human_api_agents.list_my_agents(), timeout=5, + ) + except asyncio.TimeoutError: + return CheckResult( + Status.WARN, + f"Could not verify platform agents — Band.ai REST at {ctx.config.band.rest_url} " + "timed out after 5s", + remediation="Check network or band.rest_url in codeband.yaml, then rerun `cb doctor`.", + ) + except Exception as exc: + return CheckResult( + Status.WARN, + "Could not verify platform agents — " + f"{type(exc).__name__}: {exc}", + remediation=( + "Check BAND_API_KEY, network, and band.rest_url in codeband.yaml, " + "then rerun `cb doctor`." + ), + ) + + from codeband.orchestration.setup import _expected_agents + + expected = _expected_agents(ctx.config) + platform_by_id = { + agent.id: agent + for agent in (platform_response.data or []) + if getattr(agent, "id", None) + } + missing: list[str] = [] + for key, creds in ctx.agent_config.agents.items(): + if creds.agent_id in platform_by_id: + continue + display_name = expected.get(key, (None, ""))[0] + label = ( + f"{key} ({display_name}, id {creds.agent_id})" + if display_name + else f"{key} (id {creds.agent_id})" + ) + missing.append(label) + + if missing: + return CheckResult( + Status.FAIL, + f"Band.ai missing/deleted {len(missing)} configured agent(s): " + + ", ".join(sorted(missing)), + remediation="Run: cb setup-agents", + ) + return CheckResult( + Status.OK, + f"Band.ai platform agents OK — {len(ctx.agent_config.agents)} configured IDs exist", + ) + + async def check_band_rest(ctx: Context) -> CheckResult: client = _conductor_rest_client(ctx) if isinstance(client, CheckResult): @@ -502,15 +671,20 @@ async def check_active_room_membership(ctx: Context) -> CheckResult: A fresh task room contains only the Conductor; everyone else appears as the workflow recruits them. This check makes that visible while debugging. """ - room_file = ctx.project_dir / ".codeband_room" - if not room_file.exists(): - return CheckResult(Status.SKIP, "No active task room (.codeband_room not found)") - try: - room_id = room_file.read_text(encoding="utf-8").strip() - except OSError as exc: - return CheckResult(Status.WARN, f"Could not read .codeband_room: {exc}") + # Same dual-location read as cb-phase: canonical {workspace}/state/ + # pointer first, legacy / fallback — a fresh post-relocation + # registration writes only the canonical location, and this check must + # not SKIP on it. Without a loaded config the workspace (and canonical + # pointer) cannot be resolved, so only the legacy location is readable. + from codeband.state.registration import read_room_pointer, resolve_state_dir + + if ctx.config is not None: + state_dir = resolve_state_dir(ctx.config, ctx.project_dir) + else: + state_dir = ctx.project_dir + room_id = read_room_pointer(ctx.project_dir, state_dir, warn_legacy=False) if not room_id: - return CheckResult(Status.SKIP, ".codeband_room is empty") + return CheckResult(Status.SKIP, "No active task room (.codeband_room not found)") client = _conductor_rest_client(ctx) if isinstance(client, CheckResult): @@ -573,6 +747,76 @@ async def check_memory_mode(ctx: Context) -> CheckResult: ) +def check_jam_delivery_sdk_coupling(ctx: Context) -> CheckResult: + """Tripwire for the jam-delivery transport's coupling to band-sdk internals. + + The opt-in ``CODEBAND_DELIVERY=jam`` path (``codeband/transport/jam_runtime.py``) + deliberately bypasses the SDK's public ``Agent.create(...).run()`` facade — + that facade IS the wedging ``/next`` path — and reassembles the pieces beneath + it. So it depends on internal (and one private) band-sdk surfaces that carry no + stability promise. This check imports each one and reports clearly if a band-sdk + upgrade moved or renamed it, surfacing the coupling at upgrade time instead of + at first jam run. The default ``sdk`` delivery path depends on NONE of this. + """ + import importlib + + # (module, attribute) pairs the jam runtime reassembles. See jam_runtime.py. + required = [ + ("thenvoi.platform.link", "ThenvoiLink"), + ("thenvoi.preprocessing.default", "DefaultPreprocessor"), + ("thenvoi.runtime.execution", "ExecutionContext"), + ("thenvoi.runtime.retry_tracker", "MessageRetryTracker"), + ("thenvoi.client.streaming", "MessageCreatedPayload"), + ("thenvoi.client.streaming", "MessageMetadata"), + ("thenvoi.platform.event", "MessageEvent"), + ] + missing: list[str] = [] + for mod_name, attr in required: + try: + mod = importlib.import_module(mod_name) + except Exception as exc: # noqa: BLE001 - any import failure = moved/renamed + missing.append(f"{mod_name} ({type(exc).__name__})") + continue + if not hasattr(mod, attr): + missing.append(f"{mod_name}.{attr}") + + # The private method the per-room worker calls + the private adapter attr the + # handshake sets — the most fragile couplings (no underscore-stability promise). + try: + from thenvoi.runtime.execution import ExecutionContext as _EC + + if not hasattr(_EC, "_ensure_fresh_context"): + missing.append("thenvoi.runtime.execution.ExecutionContext._ensure_fresh_context") + except Exception: # noqa: BLE001 - already recorded by the import loop above + pass + + if not missing: + return CheckResult( + Status.OK, + "jam delivery SDK internals present (transport coupling intact)", + ) + + # Severity tracks exposure: FAIL if jam delivery is actually selected (the path + # in use is broken); WARN otherwise (the opt-in path won't work, but the active + # sdk path is fine — don't trip the exit code for sdk users). + jam_selected = os.environ.get("CODEBAND_DELIVERY", "").strip().lower() == "jam" or ( + ctx.config is not None + and getattr(ctx.config.agents, "delivery", "sdk") == "jam" + ) + return CheckResult( + Status.FAIL if jam_selected else Status.WARN, + "jam delivery transport is incompatible with the installed band-sdk — " + f"missing/moved internal symbol(s): {', '.join(missing)}", + remediation=( + "The opt-in CODEBAND_DELIVERY=jam path reassembles band-sdk internals " + "(see codeband/transport/jam_runtime.py); a band-sdk upgrade moved one. " + "Pin band-sdk to the supported range (>=0.2.8,<0.3) or update " + "jam_runtime.py to the new SDK shapes. The default CODEBAND_DELIVERY=sdk " + "delivery path is unaffected." + ), + ) + + # ─── registry ─────────────────────────────────────────────────────────────── _CHECKS: list[Check] = [ @@ -586,10 +830,14 @@ async def check_memory_mode(ctx: Context) -> CheckResult: Check("gh CLI", "Tools", check_gh), Check("gh authenticated", "Tools", check_gh_auth), Check("codeband.yaml", "Config", check_codeband_yaml), + Check("handoff verify command", "Config", check_handoff_verify_command), Check("agent_config.yaml", "Config", check_agent_config_yaml), + Check("Band.ai platform agents", "Config", check_agent_platform_existence), Check("Workspace writable", "Config", check_workspace_writable), Check("Cross-model pairing", "Config", check_cross_model_pairing), + Check("Verifier pairing", "Config", check_verifier_pairing), Check("Agent count vs Band.ai tier", "Config", check_agent_count_vs_tier), + Check("jam delivery SDK coupling", "Environment", check_jam_delivery_sdk_coupling), Check("Band.ai REST reachable", "Connectivity", check_band_rest), Check("Memory backend", "Connectivity", check_memory_mode), Check("Active room membership", "Connectivity", check_active_room_membership), diff --git a/src/codeband/identity.py b/src/codeband/identity.py new file mode 100644 index 0000000..3163a09 --- /dev/null +++ b/src/codeband/identity.py @@ -0,0 +1,170 @@ +"""Durable agent identity resolution — distributed env vs local worktree map. + +item-0 stamps the Band ``agent_id`` of the coder that worked a subtask +(``assigned_worker``) and the reviewer that rendered its verdict +(``assigned_reviewer``) onto the subtask row, so the watchdog can @mention the +right agent (``agents/watchdog.py`` uses ``assigned_worker`` directly as a chat +mention id) and rehydration/forensics can attribute work. Identity must be +resolved two different ways because the run modes differ: + +* **Distributed mode** (``run_agent``): one OS process IS one Band agent. The + runner exports ``CODEBAND_AGENT_ID`` (alongside ``CODEBAND_ROLE``) on the + spawn seam; the ``cb-phase`` subprocess inherits it. :func:`resolve_identity` + reads it directly — env-first. + +* **Local mode** (``run_local``): every role runs as an asyncio task in ONE + process sharing ONE environment, so there is no single agent to name and + ``CODEBAND_AGENT_ID`` is deliberately NOT exported (and is actively cleared at + the seam, so a stale/inherited value cannot hijack resolution). The only + per-seat signal is the cwd the adapter gives each agent's CLI subprocess: + coders get ``worktrees/``, reviewers/verifiers get + ``scratch/`` (see ``workspace/init.py``). The runner writes a + :data:`LOCATION_MAP_FILENAME` mapping each seat's operating dir to its + ``agent_id``; :func:`resolve_identity` matches the cwd against it. + +Identity is **advisory** — it is a forensic / recovery / mention aid, never a +gate. Every failure mode resolves to ``None`` (do not stamp); nothing here may +raise into an FSM transition. +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# The local-mode dir→identity map, written by the runner into the workspace +# ``state/`` dir at ``run_local`` startup and read by the ``cb-phase`` CLI. +LOCATION_MAP_FILENAME = "agent_locations.json" + +# Env var carrying the single distributed-agent identity (set by the runner's +# spawn seam only in distributed mode). Its mere presence is the "this process +# is one distributed agent" signal, so the seam clears it in local mode. +AGENT_ID_ENV = "CODEBAND_AGENT_ID" +ROLE_ENV = "CODEBAND_ROLE" + + +@dataclass(frozen=True) +class ResolvedIdentity: + """The identity behind a ``cb-phase`` invocation. + + ``agent_id`` is the Band agent_id (the chat-mention key). ``role`` is the + role name (``coder`` / ``reviewer`` / …) when known — from + ``$CODEBAND_ROLE`` in distributed mode or the location-map entry in local + mode; ``None`` only when the env path resolved an id without a role. + """ + + agent_id: str + role: str | None + + +def write_location_map(state_dir: Path, entries: list[dict[str, Any]]) -> None: + """Atomically write the local-mode seat→identity map. + + Each entry is ``{"dir", "worker_id", "agent_id", "role"}``; ``dir`` is + stored as a resolved absolute path string so the resolver can match a + spawned subprocess's cwd against it without re-resolving relative paths. + Overwritten on every ``run_local`` startup, so the map always reflects the + current run's agent_ids (stale-across-runs is handled by overwrite). + + Best-effort: a write failure logs and returns — a missing map degrades + local-mode stamping to ``None`` (no stamp), never breaks the swarm. + """ + state_dir = Path(state_dir) + payload = { + "version": 1, + "entries": [ + { + "dir": str(Path(e["dir"]).resolve()), + "worker_id": e["worker_id"], + "agent_id": e["agent_id"], + "role": e["role"], + } + for e in entries + ], + } + try: + state_dir.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=state_dir, suffix=".tmp") + try: + with open(fd, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + Path(tmp).replace(state_dir / LOCATION_MAP_FILENAME) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + except OSError: + logger.warning( + "Failed to write agent location map at %s — local-mode identity " + "stamping will be unavailable this run", state_dir, exc_info=True, + ) + + +def _is_within(child: Path, parent: Path) -> bool: + """True when ``child`` is ``parent`` or a descendant of it. + + Uses :meth:`Path.is_relative_to` semantics (path-component boundaries), so + ``/a/wt-1`` is NOT considered within ``/a/wt-10`` — a plain string-prefix + check would false-match those sibling worktrees. + """ + try: + child.relative_to(parent) + return True + except ValueError: + return False + + +def _resolve_from_map(cwd: Path, state_dir: Path) -> ResolvedIdentity | None: + """Resolve identity from the location map by cwd, fail-safe to ``None``. + + Returns the unique seat whose ``dir`` is ``cwd`` or an ancestor of it. + Zero matches (operator-run, moved worktree, unknown dir) → ``None``. + More than one match (pathologically nested seat dirs) → ``None`` (refuse to + guess a wrong identity). A missing / unreadable / malformed map → ``None``. + """ + map_path = Path(state_dir) / LOCATION_MAP_FILENAME + try: + payload = json.loads(map_path.read_text(encoding="utf-8")) + entries = payload["entries"] + except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError, OSError): + return None + + matches: list[ResolvedIdentity] = [] + for entry in entries: + try: + seat_dir = Path(entry["dir"]) + agent_id = entry["agent_id"] + except (KeyError, TypeError): + continue + if _is_within(cwd, seat_dir): + matches.append(ResolvedIdentity(agent_id=agent_id, role=entry.get("role"))) + + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + logger.warning( + "Ambiguous agent location map: cwd %s is within %d seat dirs — " + "refusing to guess identity", cwd, len(matches), + ) + return None + + +def resolve_identity(*, cwd: Path | str, state_dir: Path | str) -> ResolvedIdentity | None: + """Resolve the agent identity behind a ``cb-phase`` invocation. + + Env-first: a non-empty ``$CODEBAND_AGENT_ID`` reliably means "this process + is a single distributed agent" (the runner clears it in local mode), so it + wins outright. Otherwise fall through to the local location map keyed on the + invoking cwd. Returns ``None`` when nothing trustworthy resolves — the + caller must then leave the identity field unstamped. + """ + env_id = os.environ.get(AGENT_ID_ENV) + if env_id: + return ResolvedIdentity(agent_id=env_id, role=os.environ.get(ROLE_ENV)) + return _resolve_from_map(Path(cwd).resolve(), Path(state_dir)) diff --git a/src/codeband/knowledge/__init__.py b/src/codeband/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codeband/knowledge/coding-standards.md b/src/codeband/knowledge/coding-standards.md new file mode 100644 index 0000000..81986eb --- /dev/null +++ b/src/codeband/knowledge/coding-standards.md @@ -0,0 +1,122 @@ +# Coding Standards + +These are the craft standards for code you write in any Codeband swarm. They are +deliberately **language-agnostic** — Codeband runs against arbitrary repositories. +When this guide and the target repo disagree, **the target repo wins**: match the +conventions, idioms, and tooling that already exist in the code you are editing. +This guide governs only where the repo is silent. + +## The one rule that overrides everything + +**Read the surrounding code before you write any.** The single most common failure +of an autonomous coder is writing code that is locally correct but foreign to the +codebase — a different error-handling style, a different naming scheme, a hand-rolled +helper that already exists three files over. Before adding code: + +- Read at least one neighbouring file in the same module or package. +- Find how this codebase already does the thing you are about to do (logging, config, + HTTP calls, DB access, error types) and do it that way. +- Prefer extending an existing abstraction over introducing a new one. + +## Code quality principles + +- **Smallest change that fully solves the task.** Do not refactor unrelated code, + rename things you weren't asked to rename, or "tidy while you're in there." Scope + creep is the enemy of a clean review and a clean merge. +- **Clarity over cleverness.** Code is read far more than it is written. A longer, + obvious implementation beats a terse, subtle one. +- **No dead code.** Don't leave commented-out blocks, unused variables, unreachable + branches, or speculative "might need this later" helpers. Delete it; git remembers. +- **No debugging leftovers.** No stray prints, console logs, `TODO: remove`, or + temporary instrumentation in the code you submit. +- **Single responsibility.** A function should do one thing. If you can't describe it + without "and", consider splitting it. + +## Naming and structure + +- Names describe intent, not implementation. `retry_count`, not `n`. `is_eligible`, + not `flag`. +- Match the casing/convention of the file you're in (snake_case, camelCase, etc.) — + do not introduce a second style. +- Keep functions short enough to see whole. Deep nesting is a smell; prefer early + returns / guard clauses over arrowhead `if` pyramids. +- Put new code where a reader would look for it. Don't create a new top-level module + for something that belongs in an existing one. + +## Logging, not stdout + +Use the codebase's logging facility, never raw stdout/stderr writes, for diagnostic +output that ships. + +- **Don't** emit `print(...)` / `console.log(...)` / `fmt.Println(...)` as a logging + mechanism in production code paths. +- **Do** use the project's logger at an appropriate level (`debug` for developer + detail, `info` for normal lifecycle, `warning`/`error` for problems). +- Never log secrets, credentials, tokens, full request bodies with PII, or anything + you wouldn't want in a shared log aggregator. See `security.md`. + +## Error handling + +Handle errors at boundaries; don't swallow them. + +- **Fail loudly at system boundaries** — network calls, file I/O, parsing untrusted + input, subprocess calls. These *will* fail in production; handle the failure + explicitly with a clear message and the original cause attached. +- **Don't catch-all-and-ignore.** A bare `except: pass` (or `catch {}`) hides the bug + you'll be paged for. If you must catch broadly, log the cause and re-raise or return + a typed error. +- **Don't catch what you can't handle.** Catching an exception only to re-raise an + identical one adds noise. Either add context or let it propagate. +- **Preserve the cause.** When wrapping an error, chain the original (`raise X from e`, + `fmt.Errorf("...: %w", err)`, `throw new X({cause: e})`) so the stack trace survives. +- **Error messages are for the person debugging at 3am.** Include what was being + attempted and the relevant identifiers, not just "operation failed". + +## Common traps to avoid + +- **Mutable default arguments / shared mutable state** captured across calls. +- **Off-by-one and boundary errors** in ranges, slices, and pagination. +- **Silent type coercion** — comparing across types, truthiness of `0`/`""`/`None`. +- **Resource leaks** — files, sockets, DB connections, locks not released on the error + path. Use the language's scoped-cleanup construct (`with`, `defer`, `try/finally`, + RAII). +- **Time and timezones** — naive timestamps, assuming local time, DST. +- **Floating point for money** — use integers/decimals for currency. +- **Concurrency** — shared state without synchronization, assuming ordering, races on + check-then-act. See `testing.md` for what to test here. +- **Trusting external input** — anything from the network, a file, an env var, or a + user is untrusted until validated. See `security.md`. + +## What good code looks like + +- A new reader can follow it without you explaining it. +- It fits the file it lives in — same patterns, same error style, same naming. +- The happy path is obvious; the error paths are explicit, not implied. +- It has tests that would fail if the behaviour regressed (see `testing.md`). +- The diff is minimal: every changed line is there for a reason tied to the task. + +## Production hardening + +For anything that runs in production, before you call it done: + +- **Inputs validated** at the boundary, with clear rejection of bad input. +- **External calls** have timeouts and a defined behaviour on failure (retry with + backoff, fail fast, or degrade) — never an unbounded hang. +- **Idempotency** considered for anything that can be retried (webhooks, queue + consumers, payment-adjacent flows). +- **Observability** — the code emits enough logging/metrics that an operator can tell + what happened when it misbehaves. +- **No new secrets in code or config committed to the repo.** + +## Self-review checklist (run before you open the PR) + +Before reporting completion, read your own diff top to bottom and confirm: + +- [ ] Every changed line is necessary for the task — no scope creep, no drive-by edits. +- [ ] It matches the surrounding code's style, naming, and error handling. +- [ ] No debug prints, commented-out code, or dead branches. +- [ ] Errors at every system boundary are handled explicitly. +- [ ] No secret, token, or credential is hardcoded or logged. +- [ ] There are tests that would fail if this behaviour broke (see `testing.md`). +- [ ] You ran the verification command from the plan and it passed. +- [ ] You could explain every line if a reviewer asked. diff --git a/src/codeband/knowledge/security.md b/src/codeband/knowledge/security.md new file mode 100644 index 0000000..786ea59 --- /dev/null +++ b/src/codeband/knowledge/security.md @@ -0,0 +1,75 @@ +# Security Practices + +Baseline security expectations for any code a Codeband agent writes or reviews. +Language-agnostic — apply the principle using your stack's safe primitives. The Code +Reviewer treats violations here as blocking, but only when there is a concrete, +demonstrable path to exploitation — not a theoretical one. + +## Trust nothing from outside the process + +Anything that crosses a boundary into your code is untrusted until validated: network +requests, user input, file contents, environment variables, message-queue payloads, +and responses from external services. Validate shape and bounds at the boundary; +reject what doesn't conform rather than coercing it. + +## Injection: never build a command/query by string concatenation + +- **SQL:** use parameterized queries / prepared statements / the ORM's safe binding. + Never interpolate user input into a query string. +- **Shell/OS commands:** avoid shelling out with interpolated input. If you must, + pass arguments as a list to the exec API (no shell), never a concatenated string + through a shell. +- **NoSQL / LDAP / template engines:** the same rule — use the parameterized/escaped + API, not string building. +- **HTML/JS output:** escape/encode on output to prevent XSS; use the framework's + auto-escaping rather than hand-built markup with raw input. + +## No hardcoded secrets + +- No API keys, passwords, tokens, private keys, or connection strings in source code, + tests, fixtures, or committed config. +- Read secrets from environment variables or the project's secret manager. +- Don't log secrets, tokens, full auth headers, or PII. Redact before logging. +- If you encounter an existing hardcoded secret, flag it — don't copy the pattern. + +## Authentication and authorization + +- **Authentication** (who you are) and **authorization** (what you may do) are + separate checks — doing one doesn't give you the other. +- Check authorization on **every** protected operation, server-side, on the actual + resource being accessed — not just at the UI or route layer. Don't trust an ID from + the client to be one the caller is allowed to touch (broken object-level + authorization is the most common real-world hole). +- Fail closed: if you can't determine permission, deny. + +## Path and request safety + +- **Path traversal:** never build filesystem paths from untrusted input without + normalizing and confining to an allowed base directory. Reject `..` segments. +- **SSRF:** don't fetch URLs supplied by users without validating the destination + against an allowlist; internal metadata endpoints and private ranges are the + classic targets. +- **Open redirects:** validate redirect targets against an allowlist. + +## Sensitive data handling + +- Transmit secrets and PII over TLS only. +- Hash passwords with a slow, salted algorithm (bcrypt/scrypt/argon2) — never plain, + never fast unsalted hashes. +- Store only what you need; minimize the blast radius of a breach. +- Be careful what ends up in error messages returned to clients — don't leak stack + traces, internal paths, or query details to the outside. + +## Dependency security + +- Prefer well-maintained, widely-used libraries over obscure ones for security- + sensitive work (crypto, auth, parsing). +- Don't roll your own crypto. Use vetted library primitives. +- When adding a dependency, prefer the official/most-common package and pin it the way + the repo pins its others. + +## Reviewer note: demonstrate the exploit + +When flagging a security finding, show the concrete path: which untrusted input +reaches which sink, and what an attacker achieves. "This could theoretically be +unsafe" without a demonstrable path is a suggestion, not a blocker. diff --git a/src/codeband/knowledge/testing.md b/src/codeband/knowledge/testing.md new file mode 100644 index 0000000..94265f9 --- /dev/null +++ b/src/codeband/knowledge/testing.md @@ -0,0 +1,129 @@ +# Testing Guide + +Tests are how the swarm proves a change works without a human checking by hand. The +cross-model reviewer reads your tests as evidence — weak tests are treated as no tests. +This guide is **language-agnostic**; use your stack's test runner and idioms, but the +standard for *what makes a test worth writing* is the same everywhere. + +## The standard: a test must be able to fail + +A test that cannot fail proves nothing. Before you keep a test, ask: *"If the behaviour +I care about were broken, would this test go red?"* If not, it is decoration. The most +common worthless test asserts something the code can't violate (e.g. that a constructor +returns a non-null object) — delete or strengthen it. + +## Test structure + +- **One behaviour per test.** A test that asserts five unrelated things tells you little + when it fails. Split them. +- **Arrange / act / assert.** Set up the world, perform the one action, assert the one + outcome. Keep setup obvious; a reader should see what's being tested in seconds. +- **Descriptive names.** `test_login_rejects_expired_token`, not `test_login_2`. +- **Deterministic.** No reliance on wall-clock, network, random seeds, or test ordering. + Inject clocks/randomness; stub the network. A flaky test is worse than no test. + +## What to test + +### 1. Happy path + +Prove the feature does what the plan's acceptance criteria say, end to end, with +realistic inputs. This is necessary but **not sufficient** — a passing happy path is +where testing starts, not ends. + +### 2. Then try to break it + +After the happy path passes, deliberately attack your own code. This is the mindset that +separates real tests from rubber stamps. For the change you made, ask: + +### 3. Input validation + +- Empty input, missing fields, `null`/`None`, wrong type. +- Boundary values: 0, 1, -1, max, max+1, empty collection, single-element collection. +- Oversized input (huge strings/lists) where it matters. +- Malformed/garbage input from any untrusted boundary. + +### 4. Edge cases — probe these systematically + +- Off-by-one at the start and end of ranges, slices, and pages. +- Duplicate entries, already-exists, not-found. +- Unicode, whitespace-only, and very long strings in text fields. +- The empty case for every collection the code iterates. + +### 5. Concurrency and races (if the code touches shared state) + +- Two operations interleaving on the same resource. +- Check-then-act gaps (the value changed between the check and the use). +- Idempotency: does running the same operation twice corrupt state? + +### 6. Resource lifecycle + +- Files/connections/locks are released even on the error path. +- Cleanup runs when the operation fails partway. + +### 7. Error paths + +- The dependency raises/returns an error — does your code handle it, and does the test + assert the *handling* (right error surfaced, right cleanup, right log), not just that + it threw? +- Timeouts and unavailable external services. + +### 8. State transitions + +- Invalid transitions are rejected. +- The system ends in the state you claim after a sequence of operations. + +### 9. End-to-end integration (at least one) + +At least one test should exercise the new behaviour through the real seams it ships +with — not every collaborator mocked into meaninglessness. A suite of fully-mocked unit +tests can pass while the wired-together system is broken. + +## Test quality standards + +- **Assert on behaviour, not implementation.** Test the observable outcome, not internal + call counts, so a refactor that preserves behaviour doesn't break the test. +- **No vacuous assertions.** `assert result is not None` after a call that can't return + `None` tests nothing. Assert the actual value/shape/effect. +- **Don't assert on log strings or error message wording** unless that wording is the + contract — those are brittle. +- **Mock at boundaries, not internals.** Stub the network/clock/filesystem; don't mock + the function under test's own helpers, or you test the mock. +- **A test's failure message should point at the cause.** Prefer specific assertions + over one giant equality check on a blob. + +## What NOT to test + +- Third-party libraries and the language standard library — assume they work. +- Trivial getters/setters and pure pass-throughs with no logic. +- Generated code. +- Exact wording of human-facing copy (unless it's a contract). + +Don't pad coverage with tests that can't fail. Coverage percentage is not the goal; +*the ability to catch a regression* is the goal. + +## When a test reveals an implementation bug + +If writing the test surfaces a real bug in the code, fix the code — that's the test +doing its job. But if the bug is outside the scope of your task (pre-existing, in code +you weren't asked to touch), don't silently widen scope: note it and raise it rather +than quietly patching unrelated production code. See `coding-standards.md` on scope. + +## Regression coverage + +When you fix a bug, add a test that fails before your fix and passes after. That test is +the proof the bug is gone and the guard that keeps it gone. + +## Reporting test results + +When you report completion, state exactly what you ran and the outcome — the verbatim +command and the pass/fail counts (e.g. `pytest tests/test_auth.py -v — 7 passed`). If a +test fails for a reason outside your change (pre-existing on the base branch, an +environmental flake you have evidence for), say so explicitly with the evidence; never +report a green status you didn't actually observe. + +## Hanging tests + +If a test hangs, it usually means a missing timeout, an unawaited async operation, an +open resource, or a real deadlock in the code — investigate the cause rather than just +raising the test timeout. A test that only passes with a 5-minute timeout is hiding a +bug. diff --git a/src/codeband/memory/local_store.py b/src/codeband/memory/local_store.py index 65b141d..73124fe 100644 --- a/src/codeband/memory/local_store.py +++ b/src/codeband/memory/local_store.py @@ -2,9 +2,18 @@ The store is a single append-only JSONL file. Reads do a full scan and filter in memory; volume is tiny (typically <100 live envelopes per task), so this -is fine. Writes use an advisory `fcntl.flock` to stay safe when multiple -agents in one process (or sibling processes sharing the workspace) append -simultaneously. +is fine. To keep the file honoring that assumption over long sessions, +`archive()`'s rewrite compacts history: archived records beyond the newest +:data:`_ARCHIVED_KEEP_LAST` (50) are dropped (S8-F4). Live records are never +compacted and `list()` semantics for them are unchanged. + +Writes serialize on an advisory `fcntl.flock` held on a stable SIDECAR file +(`memories.jsonl.lock`), not on the data file itself (S6-F10): `archive()` +replaces the data file (a new inode), so a writer that locked the OLD +inode's handle could proceed against a file no longer at the path and its +append would be silently lost — the archive-vs-append lost-write race on +the protocol-state channel. The sidecar inode is never replaced, and every +writer re-opens the data file only after acquiring the lock. Returned records duck-type the Band.ai SDK's Pydantic Memory objects well enough for existing readers at `kickoff.py:_format_task_status` and the @@ -14,6 +23,7 @@ from __future__ import annotations +import asyncio import contextlib import fcntl import json @@ -26,6 +36,12 @@ logger = logging.getLogger(__name__) +# Compaction bound (S8-F4): how many archived records `archive()`'s rewrite +# keeps (newest first, by file order). Pairs with the module-header "<100 live +# envelopes" sizing assumption — without compaction, months of archived +# protocol-state envelopes accumulate and every full-scan read pays for them. +_ARCHIVED_KEEP_LAST = 50 + def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -145,19 +161,34 @@ async def list( return MemoryListResponse(data=matches) async def archive(self, memory_id: str) -> MemoryRecord | None: - """Mark `memory_id` archived. Returns the updated record, or None if unknown.""" - updated: MemoryRecord | None = None - records = list(self._iter_records()) - for rec in records: - if rec.id == memory_id and rec.status != "archived": - rec.status = "archived" - rec.archived_at = _now_iso() - rec.updated_at = rec.archived_at - updated = rec - break - if updated is not None: - self._rewrite(records) - return updated + """Mark `memory_id` archived. Returns the updated record, or None if unknown. + + The read-modify-rewrite runs entirely under the sidecar lock (S6-F10), + so a concurrent append cannot land between the read and the rewrite + and be dropped. The rewrite also compacts: archived records beyond the + newest :data:`_ARCHIVED_KEEP_LAST` are dropped (S8-F4); live records + are untouched. + + The locked critical section (flock + full-file read + rewrite) runs in + a worker thread via asyncio.to_thread so blocking I/O does not stall + the event loop (T-09). + """ + def _do_archive() -> MemoryRecord | None: + updated: MemoryRecord | None = None + with self._locked(): + records = list(self._iter_records()) + for rec in records: + if rec.id == memory_id and rec.status != "archived": + rec.status = "archived" + rec.archived_at = _now_iso() + rec.updated_at = rec.archived_at + updated = rec + break + if updated is not None: + self._rewrite_locked(self._compact(records)) + return updated + + return await asyncio.to_thread(_do_archive) # --- internals ---------------------------------------------------------- @@ -183,24 +214,57 @@ def _iter_records(self) -> Iterator[MemoryRecord]: except (json.JSONDecodeError, TypeError) as exc: logger.warning("Skipping malformed memory record: %s", exc) + @staticmethod + def _compact(records: list[MemoryRecord]) -> list[MemoryRecord]: + """Drop archived records beyond the newest ``_ARCHIVED_KEEP_LAST``. + + File order is chronological (append-only), so "newest" is the tail. + Live (non-archived) records are always kept, in order — ``list()`` + semantics for them are unchanged. + """ + archived = [r for r in records if r.status == "archived"] + overflow = len(archived) - _ARCHIVED_KEEP_LAST + if overflow <= 0: + return records + dropped = {id(r) for r in archived[:overflow]} + return [r for r in records if id(r) not in dropped] + def _append(self, record: MemoryRecord) -> None: - with self._locked("a") as fh: - fh.write(record.to_json() + "\n") + # Open the data file only AFTER acquiring the sidecar lock: a + # concurrent archive() may have replaced the file's inode, and a + # handle opened before the lock could point at the orphaned one. + try: + with self._locked(): + with self.path.open("a", encoding="utf-8") as fh: + fh.write(record.to_json() + "\n") + except OSError as exc: + logger.error("local_store _append failed, record dropped: %s", exc) - def _rewrite(self, records: list[MemoryRecord]) -> None: - # Rewrite atomically via temp file to avoid partial writes during archive. + def _rewrite_locked(self, records: list[MemoryRecord]) -> None: + """Atomically replace the data file. Caller must hold the sidecar lock.""" tmp = self.path.with_suffix(self.path.suffix + ".tmp") - with self._locked("a"): # hold the lock on the real file while writing tmp + try: with tmp.open("w", encoding="utf-8") as fh: for rec in records: fh.write(rec.to_json() + "\n") tmp.replace(self.path) + except OSError as exc: + logger.error("local_store _rewrite_locked failed, archive degraded: %s", exc) @contextlib.contextmanager - def _locked(self, mode: str) -> Iterator[Any]: - with self.path.open(mode, encoding="utf-8") as fh: + def _locked(self) -> Iterator[None]: + """Hold the exclusive advisory lock on the stable sidecar file. + + The sidecar (``memories.jsonl.lock``) is never replaced, so its inode + is stable — unlike the data file, which ``archive()`` swaps via + tmp+rename. Locking the data file directly is exactly the lost-write + race this replaces (S6-F10): a writer could acquire the lock on an + inode that ``archive()`` had already orphaned and append into the void. + """ + lock_path = self.path.with_suffix(self.path.suffix + ".lock") + with lock_path.open("a", encoding="utf-8") as fh: fcntl.flock(fh.fileno(), fcntl.LOCK_EX) try: - yield fh + yield finally: fcntl.flock(fh.fileno(), fcntl.LOCK_UN) diff --git a/src/codeband/monitoring/activity_log.py b/src/codeband/monitoring/activity_log.py index 4f2cc2d..dc60454 100644 --- a/src/codeband/monitoring/activity_log.py +++ b/src/codeband/monitoring/activity_log.py @@ -1,19 +1,42 @@ -"""Persistent JSONL activity log — append-only structured event history.""" +"""Persistent JSONL activity log — append-only structured event history. + +**Attribution scope (Stage-3).** :func:`record_cli_invocation` logs a +``cli_invocation`` event at the start of every ``cb`` / ``cb-phase`` command +and a ``cli_completion`` event with the exit code when it returns. The event +carries the full ``argv``, ``cwd``, ``pid``, timestamp and the env markers +(``CODEBAND_AGENT_SESSION`` / ``CODEBAND_ROLE`` / worker id). This is the +attribution record: the row-5 forensics question ("which process ran this?") +becomes answerable for the entire **sanctioned CLI surface**. + +The residual is by design: actions taken OUTSIDE the CLI — a raw ``sqlite3`` +write to the state DB, a direct shell ``git``/``gh`` — leave no +``cli_invocation`` row. That is the adversary line we do not defend (detection +over prevention, honesty over theater). Logging is best-effort: a failure to +resolve the activity-log path (no ``codeband.yaml`` in scope, e.g. ``cb init``) +or to write it never breaks the command. +""" from __future__ import annotations import dataclasses +import fcntl import json +import logging +import os from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import Final +logger = logging.getLogger(__name__) + class EventType: """Well-known activity ``event_type`` values.""" LLM_USAGE: Final[str] = "LLM_USAGE" + CLI_INVOCATION: Final[str] = "cli_invocation" + CLI_COMPLETION: Final[str] = "cli_completion" @dataclasses.dataclass @@ -35,7 +58,14 @@ def __init__(self, log_path: Path): self._path.parent.mkdir(parents=True, exist_ok=True) def log(self, event_type: str, agent: str, summary: str, **details) -> None: - """Append an event to the activity log.""" + """Append an event to the activity log. + + The append holds an exclusive ``fcntl.flock`` for the write — the + same discipline ``LocalMemoryStore`` uses (S6-F8). Every agent task + in the process (plus the watchdog) shares this one file; without the + lock, concurrent appends can interleave into a torn line that then + poisons every future read. + """ event = { "timestamp": datetime.now(UTC).isoformat(), "event_type": event_type, @@ -44,7 +74,113 @@ def log(self, event_type: str, agent: str, summary: str, **details) -> None: "details": details if details else None, } with open(self._path, "a", encoding="utf-8") as f: - f.write(json.dumps(event) + "\n") + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + try: + f.write(json.dumps(event) + "\n") + finally: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + +def _project_dir_from_argv(argv: list[str]) -> str: + """Best-effort extract of the ``--dir`` value from a raw argv. + + The CLI logger runs before click parses, so it scans argv directly for the + common ``--dir

`` / ``--dir=

`` option every project-aware command + shares. Falls back to ``"."`` (which :func:`resolve_project_dir` then + resolves via ``$CODEBAND_PROJECT_DIR`` then cwd). + """ + for i, tok in enumerate(argv): + if tok == "--dir" and i + 1 < len(argv): + return argv[i + 1] + if tok.startswith("--dir="): + return tok.split("=", 1)[1] + return "." + + +def _resolve_activity_logger(argv: list[str]) -> "ActivityLogger | None": + """Resolve the ``ActivityLogger`` for the project this invocation targets. + + Routes through the same project-dir + workspace resolution as every + ``cb-phase`` leg (explicit ``--dir`` > ``$CODEBAND_PROJECT_DIR`` > cwd, then + ``resolve_workspace_path``). Returns ``None`` — best-effort — when no + ``codeband.yaml`` is in scope or anything else fails, so attribution + logging never breaks a command (notably ``cb init``, which runs before a + config exists). + """ + try: + from codeband.cli.handoff import resolve_project_dir + from codeband.config import load_config, resolve_workspace_path + + project = resolve_project_dir(_project_dir_from_argv(argv)) + config = load_config(project) + workspace = resolve_workspace_path(config, project) + return ActivityLogger(workspace / "state" / "activity.jsonl") + except Exception: + return None + + +def _actor_markers() -> dict: + """Capture the env attribution markers + process identity for an audit event.""" + return { + "cwd": os.getcwd(), + "pid": os.getpid(), + "agent_session": os.environ.get("CODEBAND_AGENT_SESSION"), + "role": os.environ.get("CODEBAND_ROLE"), + "worker_id": os.environ.get("CODEBAND_WORKER_ID"), + } + + +def _actor_label(markers: dict) -> str: + """A short ``agent`` label for the event row from the captured markers.""" + if markers.get("role"): + return markers["role"] + return "agent" if markers.get("agent_session") else "human" + + +def record_cli_invocation(prog: str, argv: list[str]) -> Callable[[int], None]: + """Log a ``cli_invocation`` event; return a completion callback. + + Call once at the top of the ``cb`` / ``cb-phase`` entrypoint with the + program name and the raw argv (sans prog). Appends the invocation event + (full argv, cwd, pid, timestamp, env markers) and returns a callback that, + given the process exit code, appends the matching ``cli_completion`` event. + Both writes use :meth:`ActivityLogger.log`'s ``fcntl.flock`` discipline and + are wrapped so a logging failure never propagates into the command. + """ + markers = _actor_markers() + full_argv = [prog, *argv] + logger_obj = _resolve_activity_logger(argv) + label = _actor_label(markers) + + if logger_obj is not None: + try: + logger_obj.log( + EventType.CLI_INVOCATION, + label, + " ".join(full_argv), + argv=full_argv, + **markers, + ) + except Exception: + logger.debug("cli_invocation logging failed", exc_info=True) + + def _complete(exit_code: int) -> None: + if logger_obj is None: + return + try: + logger_obj.log( + EventType.CLI_COMPLETION, + label, + f"{prog} exited {exit_code}", + argv=full_argv, + exit_code=exit_code, + pid=markers["pid"], + role=markers["role"], + ) + except Exception: + logger.debug("cli_completion logging failed", exc_info=True) + + return _complete class ActivityReader: @@ -92,14 +228,20 @@ def read( for line in text.splitlines(): if not line: continue - data = json.loads(line) - if agent and data["agent"] != agent: - continue - if event_type and data["event_type"] != event_type: - continue - if since: - ts = datetime.fromisoformat(data["timestamp"]) - if ts < since: + # One torn/malformed line (a crash mid-append, a corrupted byte) + # must not kill `cb log` forever — skip it with a warning, the + # same policy as LocalMemoryStore._iter_records (S6-F8). + try: + data = json.loads(line) + if agent and data["agent"] != agent: + continue + if event_type and data["event_type"] != event_type: continue - events.append(ActivityEvent(**data)) + if since: + ts = datetime.fromisoformat(data["timestamp"]) + if ts < since: + continue + events.append(ActivityEvent(**data)) + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + logger.warning("Skipping malformed activity log line: %s", exc) return events diff --git a/src/codeband/monitoring/attempt_window.py b/src/codeband/monitoring/attempt_window.py new file mode 100644 index 0000000..571457e --- /dev/null +++ b/src/codeband/monitoring/attempt_window.py @@ -0,0 +1,232 @@ +"""Attempt-window observability marker (``MSG_ATTEMPT_WINDOW``). + +This is a *pure-observer* instrument over the Band SDK's message-ack path. For +every message that reaches the success-ack, it emits a durable structured event +recording how long the server-side **processing attempt** was held open — from +``mark_processing`` (attempt opened) to ``mark_processed`` (attempt acked) — plus +whether the ``mark_processed`` REST call returned 422. + +It is the diagnostic gauge for the mark-processed-422 cursor-pin: the pin fires +when a long agentic turn outlives the server's attempt-validity window, the +``/processed`` call 422s, the SDK swallows it, and the ``/next`` cursor wedges. + +Design (see ``PLAN.md`` for the reviewed contract): + +* The ack calls belong to the SDK. We wrap them but NEVER alter their return + values, exceptions, side effects, or the SDK's existing swallow-the-422 + behavior. Each wrapper calls the SDK original first, with identical args, and + returns/raises its result untouched. Recording and emission run in their own + ``try/except`` so they can never perturb the ack path. +* Timing is captured at the :class:`ThenvoiLink` layer; the 422 (and the + processing-success confirmation) are observed one layer below, at the REST + client, via **observe-and-re-raise** — the identical exception still + propagates up to the SDK's ``except`` and is swallowed exactly as before. +* REST-layer observations are confined to the ``ThenvoiLink`` call path by two + :class:`~contextvars.ContextVar` "slots" set only inside the wrapped + ``ThenvoiLink`` methods. Codeband's watchdog calls the same REST methods + directly (and *intentionally* provokes 422s while healing pins); those calls + run with no slot set, so the REST wrappers are transparent pass-throughs for + them and the watchdog's 422 can never contaminate a marker. +* The open registry is keyed by ``(agent_id, room_id, message_id)`` because the + local runtime hosts many agents in one process, each with its own link. +* ``t_open`` is recorded only when the ``mark_processing`` REST call actually + succeeds; a swallowed processing failure yields ``elapsed_seconds=None`` rather + than a misleading window. + +The instrument is best-effort: if the SDK cannot be imported it logs a warning +and does nothing — it must never block startup. +""" + +from __future__ import annotations + +import logging +import time +from collections import OrderedDict +from contextvars import ContextVar +from functools import wraps +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from codeband.monitoring.activity_log import ActivityLogger + +logger = logging.getLogger(__name__) + + +def _monotonic() -> float: + """Indirection over :func:`time.monotonic` so tests can drive the clock + without monkeypatching the global ``time`` module (which asyncio also uses).""" + return time.monotonic() + + +#: Event type for the durable marker (matches the #97 UPPER_SNAKE family). +EVENT_TYPE = "MSG_ATTEMPT_WINDOW" + +#: Cap on the open-attempt registry. Keys that never get a matching +#: ``mark_processed`` (handler-failure path, reaper path) are reclaimed here — +#: this is the leak bound. msg_ids are effectively unique, so stale keys are +#: harmless until evicted. +_MAX_OPEN = 2048 + +# --- module state --------------------------------------------------------- + +# (agent_id, room_id, message_id) -> t_open (monotonic seconds). Insert only on +# a confirmed open; pop on emit. LRU-capped at _MAX_OPEN. +_open_registry: "OrderedDict[tuple[Optional[str], str, str], float]" = OrderedDict() + +# Per-call observation slots, set only inside the wrapped ThenvoiLink methods. +# A ContextVar value set in a coroutine is visible to the awaited callee in the +# same task and does not leak to sibling tasks. +_open_slot: ContextVar[Optional[dict]] = ContextVar("_cb_attempt_open_slot", default=None) +_ack_slot: ContextVar[Optional[dict]] = ContextVar("_cb_attempt_ack_slot", default=None) + +_activity: Optional["ActivityLogger"] = None + +# SDK originals, captured once at first install so the wrappers can delegate. +_orig_link_processing: Any = None +_orig_link_processed: Any = None +_orig_rest_processing: Any = None +_orig_rest_processed: Any = None + + +def _record_open(agent_id: Optional[str], room_id: str, message_id: str) -> None: + """Record a confirmed attempt-open timestamp, LRU-capping the registry.""" + key = (agent_id, room_id, message_id) + _open_registry[key] = _monotonic() + _open_registry.move_to_end(key) + while len(_open_registry) > _MAX_OPEN: + _open_registry.popitem(last=False) + + +def _emit(link: Any, room_id: str, message_id: str, t_ack: float, http_422: bool) -> None: + """Pop the matching open and append the durable ``MSG_ATTEMPT_WINDOW`` event. + + Absence of a matching open (reaper path, or a swallowed processing failure) + yields ``elapsed_seconds=None`` — a faithful "no confirmed window" signal. + """ + agent_id = getattr(link, "agent_id", None) + t_open = _open_registry.pop((agent_id, room_id, message_id), None) + elapsed = round(t_ack - t_open, 3) if t_open is not None else None + if _activity is None: + return + _activity.log( + EVENT_TYPE, + agent_id or "agent", + f"msg {message_id} attempt window {elapsed}s 422={http_422}", + msg_id=message_id, + room_id=room_id, + agent_id=agent_id, + elapsed_seconds=elapsed, + ack_422=http_422, + ) + + +def install_attempt_window_instrument(activity: "ActivityLogger") -> None: + """Install the four class-level ack-path wraps (idempotent, best-effort). + + Safe to call from both the local and distributed runner paths: the patches + are class-level and guarded by a ``_codeband_attempt_window`` sentinel, so a + second call only refreshes the activity sink. + """ + global _activity + global _orig_link_processing, _orig_link_processed + global _orig_rest_processing, _orig_rest_processed + + _activity = activity + + try: + from thenvoi.platform.link import ThenvoiLink + from thenvoi_rest.agent_api_messages.client import AsyncAgentApiMessagesClient + except ImportError as exc: # pragma: no cover - exercised only on a broken SDK + logger.warning( + "attempt-window instrument disabled: cannot import the Band SDK ack " + "path (%s). Marker emission is skipped; ack behavior is unchanged.", + exc, + ) + return + + if getattr(ThenvoiLink.mark_processed, "_codeband_attempt_window", False): + # Already installed this process; activity sink refreshed above. + return + + _orig_link_processing = ThenvoiLink.mark_processing + _orig_link_processed = ThenvoiLink.mark_processed + _orig_rest_processing = AsyncAgentApiMessagesClient.mark_agent_message_processing + _orig_rest_processed = AsyncAgentApiMessagesClient.mark_agent_message_processed + + @wraps(_orig_link_processing) + async def _wrapped_link_processing(self, room_id, message_id, *args, **kwargs): + slot = {"opened": False} + token = _open_slot.set(slot) + try: + result = await _orig_link_processing(self, room_id, message_id, *args, **kwargs) + finally: + _open_slot.reset(token) + # Reached only on a NORMAL return of the SDK original. If the original + # propagated (e.g. cancellation), we never record an open — there is no + # completed attempt to time. + if slot["opened"]: + try: + _record_open(getattr(self, "agent_id", None), room_id, message_id) + except Exception: # noqa: BLE001 - observation must never perturb the ack path + logger.debug("attempt-window: open record failed", exc_info=True) + return result + + @wraps(_orig_rest_processing) + async def _wrapped_rest_processing(self, *args, **kwargs): + # Raises propagate untouched (SDK swallows them upstream). On success, + # mark the open confirmed — but only inside a wrapped ThenvoiLink call. + result = await _orig_rest_processing(self, *args, **kwargs) + slot = _open_slot.get() + if slot is not None: + try: + slot["opened"] = True + except Exception: # noqa: BLE001 + logger.debug("attempt-window: open-confirm failed", exc_info=True) + return result + + @wraps(_orig_rest_processed) + async def _wrapped_rest_processed(self, *args, **kwargs): + try: + return await _orig_rest_processed(self, *args, **kwargs) + except Exception as exc: # noqa: BLE001 - re-raised identically below + slot = _ack_slot.get() + if slot is not None and getattr(exc, "status_code", None) == 422: + try: + slot["http_422"] = True + except Exception: # noqa: BLE001 + logger.debug("attempt-window: 422 observe failed", exc_info=True) + raise + + @wraps(_orig_link_processed) + async def _wrapped_link_processed(self, room_id, message_id, *args, **kwargs): + t_ack = _monotonic() + slot = {"http_422": False} + token = _ack_slot.set(slot) + try: + result = await _orig_link_processed(self, room_id, message_id, *args, **kwargs) + finally: + _ack_slot.reset(token) + # Emit ONLY on a normal return of the SDK original — i.e. a completed + # (and possibly 422-swallowed) ack. If the original propagated (e.g. + # cancellation), we re-raise the identical exception WITHOUT emitting a + # marker or popping the open entry, so a later retry still has its window. + try: + _emit(self, room_id, message_id, t_ack, slot["http_422"]) + except Exception: # noqa: BLE001 - emission must never perturb the ack path + logger.debug("attempt-window: emit failed", exc_info=True) + return result + + for fn in ( + _wrapped_link_processing, + _wrapped_rest_processing, + _wrapped_rest_processed, + _wrapped_link_processed, + ): + fn._codeband_attempt_window = True # type: ignore[attr-defined] + + ThenvoiLink.mark_processing = _wrapped_link_processing + ThenvoiLink.mark_processed = _wrapped_link_processed + AsyncAgentApiMessagesClient.mark_agent_message_processing = _wrapped_rest_processing + AsyncAgentApiMessagesClient.mark_agent_message_processed = _wrapped_rest_processed + + logger.debug("attempt-window instrument installed") diff --git a/src/codeband/orchestration/agent_main.py b/src/codeband/orchestration/agent_main.py index d64cfab..95d7c4b 100644 --- a/src/codeband/orchestration/agent_main.py +++ b/src/codeband/orchestration/agent_main.py @@ -41,6 +41,11 @@ def main() -> None: from codeband.config import CodebandConfig config = CodebandConfig.from_yaml(config_path) + # Distributed-mode rehydration (RFC WS5) is wired inside ``run_agent``: it + # resolves the workspace path, opens the durable StateStore, and prepends + # per-role recovery context to the agent's system prompt before + # ``agent.run()``. Kept there (not here) because ``main()`` only has the raw + # config + project_dir, while ``run_agent`` already resolves the workspace. from codeband.orchestration.runner import run_agent asyncio.run(run_agent(config, project_dir, agent_key)) diff --git a/src/codeband/orchestration/kickoff.py b/src/codeband/orchestration/kickoff.py index 6cda2aa..ba3045d 100644 --- a/src/codeband/orchestration/kickoff.py +++ b/src/codeband/orchestration/kickoff.py @@ -7,7 +7,7 @@ import re from pathlib import Path -from codeband.config import CodebandConfig, load_agent_config +from codeband.config import CodebandConfig, load_agent_config, resolve_workspace_path logger = logging.getLogger(__name__) @@ -61,6 +61,51 @@ async def send_task(config: CodebandConfig, project_dir: Path, description: str) room_id = room.data.id logger.info("Created task room: %s", room_id) + # The initiator is whoever holds BAND_API_KEY (the human_client above). + # Resolve their Band participant id so the watchdog can @mention them when + # a subtask of this task lands blocked. Owner resolution is REQUIRED: a + # failure aborts kickoff loudly *before any message is posted* — an + # ownerless task can never be escalated to a human, so it must not start. + try: + profile = await human_client.human_api_profile.get_my_profile() + owner_id = getattr(profile.data, "id", None) + except Exception as exc: + raise RuntimeError( + "Could not resolve the task initiator's Band profile (needed as " + "the task owner). Check BAND_API_KEY and Band.ai connectivity, " + "then retry." + ) from exc + if not owner_id or not isinstance(owner_id, str): + raise RuntimeError( + "Band profile lookup returned no participant id — cannot register " + "an ownerless task. Check BAND_API_KEY, then retry." + ) + raw_handle = getattr(profile.data, "name", None) + owner_handle = raw_handle if isinstance(raw_handle, str) else None + + # Register the task — tasks row + .codeband_room pointer, atomically and + # row-first — BEFORE the task message below. The pointer must exist before + # any agent is activated, so an early cb-phase call cannot race it. Any + # registration failure aborts kickoff loudly; nothing is swallowed. + from codeband.state import StateStore + from codeband.state.registration import register_task + + workspace_path = resolve_workspace_path(config, project_dir) + store = StateStore(workspace_path / "state" / "orchestration.db") + registration = register_task( + room_id=room_id, + description=description, + owner_id=owner_id, + agents=config.agents, + owner_handle=owner_handle, + project_dir=project_dir, + store=store, + ) + if registration.superseded_task_id: + logger.info( + "Superseded previous task %s", registration.superseded_task_id, + ) + # Human adds only the Conductor — the human's first message @mentions the # Conductor, so the Conductor must be a participant for that message to # land. Every other agent is invited lazily by the inviting agent @@ -71,6 +116,31 @@ async def send_task(config: CodebandConfig, project_dir: Path, description: str) participant=ParticipantRequest(participant_id=conductor_id), ) + # If a session agent is present (CODEBAND_SESSION_AGENT_KEY set by the + # skill), enroll it as a participant now — it must be in the room before + # send_room_message can post under its identity (agent_api_messages requires + # room membership). Failure is fatal: a non-enrolled session agent would + # cause silent 4xx on every subsequent cb approve/reject post. + session_agent_key = os.environ.get("CODEBAND_SESSION_AGENT_KEY") or None + if session_agent_key: + try: + session_client = AsyncRestClient( + api_key=session_agent_key, base_url=config.band.rest_url, + ) + session_identity = await session_client.agent_api_identity.get_agent_me() + session_agent_id = session_identity.data.id + await human_client.human_api_participants.add_my_chat_participant( + room_id, + participant=ParticipantRequest(participant_id=session_agent_id), + ) + logger.info("Enrolled session agent %s as room participant", session_agent_id) + except Exception as exc: + raise RuntimeError( + f"Failed to enroll session agent as room participant: {exc}. " + "Ensure CODEBAND_SESSION_AGENT_KEY is valid. A non-enrolled " + "session agent causes silent 4xx on every post." + ) from exc + context_msg = ( f"{description}\n\n" f"@{conductor_name} — here's a new task for the team. " @@ -86,9 +156,8 @@ async def send_task(config: CodebandConfig, project_dir: Path, description: str) ) logger.info("Task sent to room %s", room_id) - # Persist room ID so approve/reject/cleanup can target this specific room - room_file = project_dir / ".codeband_room" - room_file.write_text(room_id, encoding="utf-8") + # No pointer write here: register_task above already persisted + # .codeband_room (row-first, before the task message). # Print summary print(f"\nTask room: {room_id}") @@ -104,25 +173,27 @@ async def send_room_message( ) -> None: """Send a message to the existing Codeband task room (for approve/reject). - Reads the room ID from .codeband_room (written by send_task) and sends - the message @mentioning the Conductor. Does NOT create a new room. + Reads the room ID from the active-room pointer (canonical + ``{workspace}/state/.codeband_room``, legacy project-dir fallback — see + ``state/registration.py``) and sends the message @mentioning the + Conductor. Does NOT create a new room. """ from thenvoi_rest import AsyncRestClient, ChatMessageRequest from thenvoi_rest.types import ChatMessageRequestMentionsItem as Mention - room_file = project_dir / ".codeband_room" - try: - task_room_id = room_file.read_text(encoding="utf-8").strip() - except FileNotFoundError: + from codeband.state.registration import read_room_pointer, resolve_state_dir + + task_room_id = read_room_pointer( + project_dir, resolve_state_dir(config, project_dir), + ) + if not task_room_id: task_cmd = "/task" if command_style == "slash" else "cb task" issue_cmd = "/issue" if command_style == "slash" else "cb issue" raise RuntimeError( - "No active Codeband task room found (.codeband_room missing). " + "No active Codeband task room found (state/.codeband_room missing). " f"Start a task first with '{task_cmd}' or '{issue_cmd}'." ) - api_key = _require_api_key() - human_client = AsyncRestClient(api_key=api_key, base_url=config.band.rest_url) agent_config = load_agent_config(project_dir) # Resolve conductor name (need one API call for the display name) @@ -136,10 +207,21 @@ async def send_room_message( content = f"@{conductor_name} — {message}" mentions = [Mention(id=conductor_id, name=conductor_name)] - await human_client.human_api_messages.send_my_chat_message( - task_room_id, - message=ChatMessageRequest(content=content, mentions=mentions), - ) + + session_key = os.environ.get("CODEBAND_SESSION_AGENT_KEY") or None + if session_key: + session_client = AsyncRestClient(api_key=session_key, base_url=config.band.rest_url) + await session_client.agent_api_messages.create_agent_chat_message( + task_room_id, + message=ChatMessageRequest(content=content, mentions=mentions), + ) + else: + api_key = _require_api_key() + human_client = AsyncRestClient(api_key=api_key, base_url=config.band.rest_url) + await human_client.human_api_messages.send_my_chat_message( + task_room_id, + message=ChatMessageRequest(content=content, mentions=mentions), + ) logger.info("Message sent to room %s", task_room_id) @@ -390,16 +472,20 @@ async def _cleanup_rooms( ) -> None: """Remove agents from the previous task room only. - Reads .codeband_room to find the specific room to clean up. Other rooms - (including concurrent Codeband tasks) are left untouched. Each agent - leaves on its own credentials, so a 404 (already-not-a-member, common - under lazy invites where most agents were never added) is harmless. + Reads the active-room pointer (both locations — the canonical + ``{workspace}/state/.codeband_room`` and the legacy project-dir file) to + find the specific room to clean up. Other rooms (including concurrent + Codeband tasks) are left untouched. Each agent leaves on its own + credentials, so a 404 (already-not-a-member, common under lazy invites + where most agents were never added) is harmless. """ - room_file = project_dir / ".codeband_room" - try: - prev_room_id = room_file.read_text(encoding="utf-8").strip() - except FileNotFoundError: - prev_room_id = None + from codeband.state.registration import read_room_pointer, resolve_state_dir + + # warn_legacy=False: send_task re-registers immediately after this, + # which migrates a legacy pointer anyway — no need to nag here. + prev_room_id = read_room_pointer( + project_dir, resolve_state_dir(config, project_dir), warn_legacy=False, + ) if not prev_room_id: logger.debug("No previous task room found, skipping cleanup") @@ -409,23 +495,54 @@ async def _cleanup_rooms( await _remove_agents_from_room(prev_room_id, agent_config, config) -async def reset_active_room(config: CodebandConfig, project_dir: Path) -> str | None: +def _clear_state_room_records(state_dir: Path) -> None: + """Clear durable task-room state when a workspace wipe requests it.""" + db_path = state_dir / "orchestration.db" + if not db_path.exists(): + return + + from codeband.state import StateStore + + StateStore(db_path).clear_task_records() + + +async def reset_active_room( + config: CodebandConfig, + project_dir: Path, + *, + clear_state_rooms: bool = False, +) -> str | None: """Remove all agents from the active task room and delete the pointer file. Returns the room id that was cleaned up, or None if there was nothing to reset. Safe to call repeatedly — missing file or dead-on-Band room both - reduce to a no-op. + reduce to a no-op. Clears BOTH pointer locations (canonical + ``{workspace}/state/.codeband_room`` and the legacy project-dir file) so + a reset can never leave a stale legacy pointer behind to resurrect a + dead room. """ - room_file = project_dir / ".codeband_room" - try: - room_id = room_file.read_text(encoding="utf-8").strip() - except FileNotFoundError: - return None + from codeband.state.registration import ( + legacy_pointer_path, + read_room_pointer, + resolve_state_dir, + state_pointer_path, + ) + + state_dir = resolve_state_dir(config, project_dir) + pointer_files = (state_pointer_path(state_dir), legacy_pointer_path(project_dir)) + + room_id = read_room_pointer(project_dir, state_dir, warn_legacy=False) if not room_id: - room_file.unlink(missing_ok=True) + for f in pointer_files: + f.unlink(missing_ok=True) + if clear_state_rooms: + _clear_state_room_records(state_dir) return None agent_config = load_agent_config(project_dir) await _remove_agents_from_room(room_id, agent_config, config) - room_file.unlink(missing_ok=True) + for f in pointer_files: + f.unlink(missing_ok=True) + if clear_state_rooms: + _clear_state_room_records(state_dir) return room_id diff --git a/src/codeband/orchestration/runner.py b/src/codeband/orchestration/runner.py index 4155e4c..da7a5f7 100644 --- a/src/codeband/orchestration/runner.py +++ b/src/codeband/orchestration/runner.py @@ -4,7 +4,11 @@ import asyncio import logging +import os +import random import signal +from dataclasses import dataclass +from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, Callable @@ -41,13 +45,80 @@ _RECONNECT_MAX_DELAY_SECONDS = 60.0 -async def _safe_stop_agent(agent: object) -> None: - """Best-effort teardown of a Band.ai Agent between reconnect cycles. +@dataclass +class _LocalSweepSettings: + """Per-process inputs for the patched startup room sweep (local mode). + + ``run_local`` fills these in before agents start; the patched + ``_subscribe_to_existing_rooms`` reads them at sweep time so every + reconnect cycle sees the current values. + """ + + state_db_path: Path | None = None + fresh: bool = False + + +_local_sweep_settings = _LocalSweepSettings() + + +def _agent_connection_open(agent: object) -> bool: + """True when the agent's websocket link is verifiably still open. + + Walks the SDK teardown chain ``Agent._runtime`` (PlatformRuntime) → + ``_link`` (ThenvoiLink) → ``_ws`` (WebSocketClient). A successful + ``link.disconnect()`` sets ``_ws = None`` and ``_is_connected = False`` + (thenvoi/platform/link.py), so a non-None ``_ws`` or a truthy + ``is_connected`` after stop means the connection leaked. Unknown shapes + report closed — verification must never produce false alarms on fakes. + """ + runtime = getattr(agent, "_runtime", None) + link = getattr(runtime, "_link", None) + if link is None: + return False + return getattr(link, "_ws", None) is not None or bool( + getattr(link, "is_connected", False) + ) + + +def _read_active_room_ids(state_db_path: Path | None) -> set[str] | None: + """Room ids of ``active`` tasks in the StateStore, or ``None`` on failure. + + ``None`` is the loud-but-connected degradation [decision (b′)]: when the + state dir is unresolved or the store is unreadable, the sweep subscribes + to ALL participant rooms rather than risk skipping a live task's room. + An empty set is the normal fresh-run answer (store readable, no active + tasks) and is NOT a failure. + """ + if state_db_path is None: + logger.error( + "Subscribe-existing: state dir unresolved — subscribing to ALL " + "participant rooms (fail toward connectivity)", + ) + return None + try: + from codeband.state import StateStore + + return set(StateStore(state_db_path).list_active_task_room_ids()) + except Exception as exc: # noqa: BLE001 - any store failure degrades the same way + logger.error( + "Subscribe-existing: StateStore read failed at %s (%s: %s) — " + "subscribing to ALL participant rooms (fail toward connectivity)", + state_db_path, type(exc).__name__, exc, + ) + return None + + +async def _safe_stop_agent(agent: object, name: str = "unknown-agent") -> None: + """Loud best-effort teardown of a Band.ai Agent between reconnect cycles. Why: PHXChannelsClient owns its own auto-reconnect task. Without an explicit stop, that task survives ``agent.run()`` returning and races the next cycle, producing ``PHXTopicError: already subscribed`` and ``cannot call recv while another coroutine is already running recv``. + + Failures log at ERROR (a silently-swallowed stop is the classic + CLOSE_WAIT/socket-leak source) and closure is verified afterwards, but + nothing raises — teardown is loud, never fatal. """ stop = getattr(agent, "stop", None) if stop is None: @@ -56,8 +127,16 @@ async def _safe_stop_agent(agent: object) -> None: await stop(timeout=2.0) except asyncio.CancelledError: raise - except Exception: - logger.debug("agent.stop() raised during teardown", exc_info=True) + except Exception as exc: + logger.error( + "Teardown of agent %s failed: %s: %s", + name, type(exc).__name__, exc, exc_info=True, + ) + if _agent_connection_open(agent): + logger.error( + "Agent %s leaked its websocket connection: still open after stop()", + name, + ) def _patch_band_local_runtime() -> None: @@ -76,17 +155,40 @@ def _patch_band_local_runtime() -> None: registers process signal handlers, and RoomPresence auto-joins existing room topics during startup. Those are reasonable defaults for one agent per process, but unsafe/noisy when plain ``cb`` runs the full fleet in - one event loop. In local mode Codeband owns signals, and agents subscribe - to new rooms via ``agent_rooms`` instead of replaying old room state at - startup. Set ``CODEBAND_LOCAL_SUBSCRIBE_EXISTING=1`` to restore startup - backlog subscription for debugging. + one event loop. In local mode Codeband owns signals, and the startup + room sweep is replaced with a store-scoped serial sweep: rooms tied to + an ``active`` task in the StateStore are rejoined (mid-task recovery — + the SDK then drains their backlog through rehydrated context), stale + rooms are skipped (caps the backlog-storm blast radius), and a store + failure subscribes ALL participant rooms — fail toward connectivity, + never toward deafness. ``cb run --fresh`` skips the sweep entirely. + + Scope: this patch exists for the IN-PROCESS fleet — competing lifecycle + owners and shared signal handlers — and is local-mode only. Distributed + mode (``run_agent``) intentionally runs the SDK-native reconnect and + subscribe-existing behavior unpatched. """ + if os.environ.get("CODEBAND_LOCAL_SUBSCRIBE_EXISTING") is not None: + logger.warning( + "CODEBAND_LOCAL_SUBSCRIBE_EXISTING is deprecated and ignored — " + "subscribe-existing is now the default; use `cb run --fresh` " + "to opt out.", + ) try: from thenvoi.client.streaming import client as streaming_client from thenvoi.runtime.presence import RoomPresence - except Exception: - logger.debug("Could not import Band local runtime hooks", exc_info=True) - return + except ImportError as exc: + # An SDK we cannot patch must fail loud at startup: running the fleet + # with PHX auto-reconnect enabled silently corrupts the reconnect + # lifecycle (duplicate subscriptions, recv races). The usual cause is + # a band-sdk version conflict — 1.0.0 renamed thenvoi.* to band.*. + raise RuntimeError( + "Cannot patch the Band SDK local runtime: importing its hooks " + f"failed ({exc}). This usually means an incompatible band-sdk " + "version is installed — Codeband requires band-sdk>=0.2.8,<0.3 " + "(1.0.0 renamed the thenvoi.* module namespace). " + "Reinstall with: pip install 'band-sdk[codex,claude-sdk]>=0.2.8,<0.3'" + ) from exc websocket_cls = getattr(streaming_client, "WebSocketClient", None) if websocket_cls is not None: @@ -129,17 +231,35 @@ async def _codeband_run_forever(self) -> None: return async def _codeband_subscribe_to_existing_rooms(self) -> None: - import os - - if os.environ.get("CODEBAND_LOCAL_SUBSCRIBE_EXISTING") != "1": + if _local_sweep_settings.fresh: logger.info( - "Skipping existing-room websocket subscriptions in local mode" + "--fresh: skipping existing-room websocket subscriptions" ) return + # Store-scoped filter, applied in OUR sweep — not via the SDK's + # RoomPresence(room_filter=...), which also gates live room_added + # joins (thenvoi/runtime/presence.py:203) and would block the new + # task's room. None means "subscribe everything" (decision b′: + # fail toward connectivity, never toward deafness). + allowed = _read_active_room_ids(_local_sweep_settings.state_db_path) + logger.debug("Subscribing to existing rooms serially") try: rooms_to_join = await self._list_existing_rooms() + if allowed is not None: + kept = [ + (room_id, payload) + for room_id, payload in rooms_to_join + if room_id in allowed + ] + skipped = len(rooms_to_join) - len(kept) + if skipped: + logger.info( + "Skipping %d existing room(s) not tied to an active " + "task", skipped, + ) + rooms_to_join = kept if not rooms_to_join: return @@ -172,21 +292,185 @@ async def _codeband_subscribe_to_existing_rooms(self) -> None: RoomPresence._subscribe_to_existing_rooms = _codeband_subscribe_to_existing_rooms +def _reset_dead_codex_client(adapter: object) -> None: + """Reset a Codex adapter whose subprocess died BETWEEN turns (finding 22). + + The SDK only notices a dead ``codex app-server`` mid-turn: the + ``transport/closed`` sentinel that ``_fail_pending`` enqueues + (thenvoi/integrations/codex/stdio_client.py:119-133, + rpc_base.py:227-245) is consumed exclusively inside + ``_process_turn_events`` (thenvoi/adapters/codex.py:895-916), and + ``_ensure_client_ready`` (codex.py:1011-1024) checks only + ``_client is None`` / ``_initialized``. A between-turns death therefore + either raises at the next ``turn/start`` or — worse — hangs forever, + because ``BaseJsonRpcClient.request()`` has no timeout + (rpc_base.py:199-225) and the reader loop that would resolve the future + is gone, wedging the adapter-wide ``_rpc_lock``. + + Detection is shape-tolerant (unknown shapes do nothing): a recorded + subprocess ``returncode`` or a queued ``transport/closed`` sentinel + means dead. The reset mirrors what the SDK's own mid-turn handler does + (codex.py:898-910): drop ``_client`` / ``_initialized`` / + ``_room_threads`` so the next ``_ensure_client_ready`` rebuilds a fresh + subprocess (thread continuity is re-established via the bootstrap + ``thread/resume`` path where room history carries a thread id). + """ + client = getattr(adapter, "_client", None) + if client is None: + return + + dead = getattr(getattr(client, "_proc", None), "returncode", None) is not None + if not dead: + # asyncio.Queue has no peek; inspect its internal deque read-only. + queue = getattr(getattr(client, "_events", None), "_queue", None) + if queue is not None: + try: + dead = any( + getattr(event, "method", None) == "transport/closed" + for event in list(queue) + ) + except TypeError: + dead = False + if not dead: + return + + logger.error( + "Codex subprocess found dead between turns — resetting the adapter " + "client state so the next turn rebuilds it", + ) + close = getattr(client, "close", None) + if callable(close): + try: + result = close() + if asyncio.iscoroutine(result): + asyncio.get_running_loop().create_task(result) + except Exception: # noqa: BLE001 - the old client is already dead + logger.debug("Closing the dead Codex client failed", exc_info=True) + adapter._client = None + adapter._initialized = False + room_threads = getattr(adapter, "_room_threads", None) + if isinstance(room_threads, dict): + room_threads.clear() + + +def _wrap_codex_on_message(original: Callable) -> Callable: + """Wrap ``CodexAdapter.on_message`` with liveness + visible-error handling. + + Two seams, both finding-22 mitigations: + + * before the turn: :func:`_reset_dead_codex_client` — a dead subprocess + is rebuilt instead of raising (or hanging) at ``turn/start``; + * around the turn: ``turn/start`` runs BEFORE any chat output exists + (thenvoi/adapters/codex.py:568 sits outside the try block at :629), + so a failure there was previously invisible — the room saw nothing + while the message was retired. A pre-output exception now posts a + visible chat error, then re-raises so the SDK's retry accounting + (``max_message_retries``) still applies. + """ + + async def _codeband_on_message(self, msg, tools, *args, **kwargs): + _reset_dead_codex_client(self) + try: + return await original(self, msg, tools, *args, **kwargs) + except asyncio.CancelledError: + raise + except Exception as exc: + try: + await tools.send_message( + content=( + "⚠️ Codex turn could not start: " + f"{type(exc).__name__}: {exc}. The triggering message " + "was NOT processed — re-send it after the Codex " + "session recovers." + ), + ) + except Exception: # noqa: BLE001 - the error report must not mask the error + logger.debug( + "Could not post the Codex turn-failure notice", exc_info=True, + ) + raise + + _codeband_on_message._codeband_codex_resilience = True # type: ignore[attr-defined] + return _codeband_on_message + + +def _patch_codex_adapter_resilience() -> None: + """Wrap the Codex adapter's message entry point (finding 22 mitigations). + + ``_patch_band_local_runtime``-style, but applied in BOTH local and + distributed modes: the dormancy defect lives in the adapter itself, not + in the lifecycle ownership that keeps the PHX patch local-only. Codex + extras absent (Claude-only install) → nothing to patch. Idempotent. + """ + try: + from thenvoi.adapters import CodexAdapter + except ImportError: + logger.debug("Codex adapter not importable — resilience patch skipped") + return + if getattr(CodexAdapter.on_message, "_codeband_codex_resilience", False): + return + CodexAdapter.on_message = _wrap_codex_on_message(CodexAdapter.on_message) + logger.debug("Codex adapter resilience patch installed") + + +def _log_activity_safe( + activity: object, event_type: str, name: str, summary: str, +) -> None: + """Best-effort activity append inside a supervision loop (S6-F9). + + The reconnect-forever loop is the thing being reported on — its own + bookkeeping must never kill it. Without this, the crash handler's + AGENT_CRASH log line raising ``OSError`` (full disk, unwritable state + dir) would take down the very loop it reports on. + """ + try: + activity.log(event_type, name, summary) + except OSError: + logger.warning( + "Activity-log write (%s for %s) failed — continuing", + event_type, name, exc_info=True, + ) + + async def _run_agent_forever( - make_agent: Callable[[], object], name: str, activity: object, + make_agent: Callable[..., object], + name: str, + activity: object, + *, + agent_key: str | None = None, + workspace_path: Path | str | None = None, ) -> None: """Run an unsupervised agent under an infinite reconnect loop. - Each cycle builds a fresh Agent via ``make_agent()`` and tears it down - in ``finally`` so the underlying PHXChannelsClient's reconnect/heartbeat - tasks cannot leak into the next cycle. Both crashes and clean exits - trigger another cycle after exponential backoff. The loop ends only - when the enclosing task is cancelled by the shutdown path. + Each cycle builds a fresh Agent via ``make_agent(recovery_context)`` and + tears it down in ``finally`` so the underlying PHXChannelsClient's + reconnect/heartbeat tasks cannot leak into the next cycle. Both crashes and + clean exits trigger another cycle after exponential backoff. The loop ends + only when the enclosing task is cancelled by the shutdown path. + + On every (re)connect, when ``agent_key`` and ``workspace_path`` are set, we + rebuild per-role recovery context from the durable StateStore (RFC WS5) and + pass it into the factory so it can be prepended to the system prompt. + Rehydration is fully guarded — any failure falls back to ``None`` and never + breaks the reconnect loop. """ attempt = 0 while True: attempt += 1 - agent = make_agent() + recovery_context = None + if agent_key is not None and workspace_path is not None: + try: + from codeband.state.rehydration import recover_for_reconnect + + recovery_context = await recover_for_reconnect(agent_key, workspace_path) + except Exception: + logger.warning( + "Rehydration wiring failed for %s — continuing without it", + name, exc_info=True, + ) + recovery_context = None + agent = make_agent(recovery_context) + reconnect_pending = attempt > 1 try: try: await agent.run() @@ -197,26 +481,31 @@ async def _run_agent_forever( "%s crashed (attempt %d): %s: %s", name, attempt, type(exc).__name__, exc, ) - activity.log( - "AGENT_CRASH", name, + _log_activity_safe( + activity, "AGENT_CRASH", name, f"{type(exc).__name__}: {exc}", ) else: + if reconnect_pending: + _log_activity_safe( + activity, "AGENT_RECONNECTED", name, + f"Reconnect attempt #{attempt} — first successful turn", + ) logger.warning( "%s run() returned cleanly — reconnecting (attempt %d)", name, attempt, ) - activity.log( - "AGENT_RESTART", name, + _log_activity_safe( + activity, "AGENT_RESTART", name, f"Clean exit — reconnect #{attempt}", ) finally: - await _safe_stop_agent(agent) + await _safe_stop_agent(agent, name) delay = min( _RECONNECT_BASE_DELAY_SECONDS * (2 ** min(attempt - 1, 5)), _RECONNECT_MAX_DELAY_SECONDS, ) - await asyncio.sleep(delay) + await asyncio.sleep(random.random() * delay) # ─── memory backend patches ──────────────────────────────────────────────── @@ -232,48 +521,6 @@ def _get_tools_class(): ) -def _patch_band_subject_id_bug() -> None: - """Work around band-sdk bug: strip subject_id=None before API call.""" - cls = _get_tools_class() - if cls is None or getattr(cls.store_memory, "_codeband_patched", False): - return - - async def _patched_store_memory( - self, - content, - system, - type, - segment, - thought, - scope="subject", - subject_id=None, - metadata=None, - ): - from thenvoi.client.rest import MemoryCreateRequest - - kwargs = dict( - content=content, - system=system, - type=type, - segment=segment, - thought=thought, - scope=scope, - metadata=metadata, - ) - if subject_id is not None: - kwargs["subject_id"] = subject_id - - response = await self.rest.agent_api_memories.create_agent_memory( - memory=MemoryCreateRequest(**kwargs) - ) - if not response.data: - raise RuntimeError("Failed to store memory - no response data") - return response.data - - _patched_store_memory._codeband_patched = True - cls.store_memory = _patched_store_memory - - def _patch_agent_tools_to_local_store(store) -> None: """Redirect AgentTools memory methods at `store` (a LocalMemoryStore).""" cls = _get_tools_class() @@ -335,6 +582,37 @@ def _build_watchdog_memory_store(memory_mode: str, workspace_path: Path) -> Any: return LocalMemoryStore(workspace_path / "state" / "memories.jsonl") +def _watchdog_repo_slug(config: CodebandConfig) -> str | None: + """Resolve the ``owner/repo`` slug for the watchdog's gh probes. + + From config ``repo.url`` — cwd-independent (S9-1). ``None`` for + non-GitHub URLs: the watchdog's PR probe then degrades to the historical + cwd-based resolution rather than blocking startup. + """ + try: + from codeband.github.prs import repo_slug + + return repo_slug(config.repo.url) + except ValueError: + return None + + +def _build_watchdog_state_store(workspace_path: Path) -> Any: + """Construct the durable ``StateStore`` for the watchdog (RFC WS4). + + Points at the same SQLite file the shadow-mode store uses. Best-effort — + returns ``None`` on any failure so the watchdog degrades to chat-recency + behavior rather than blocking startup. + """ + try: + from codeband.state import StateStore + + return StateStore(workspace_path / "state" / "orchestration.db") + except Exception: # noqa: BLE001 - mechanical-progress path is optional + logger.warning("Watchdog StateStore unavailable", exc_info=True) + return None + + async def _install_memory_backend( config: CodebandConfig, workspace_path: Path, rest_client, ): @@ -364,37 +642,124 @@ async def _install_memory_backend( print(status_line) print(f"Memory: local JSONL store at {store_path}") else: - _patch_band_subject_id_bug() + # band-sdk >=0.2.11 strips subject_id=None natively before the API + # call (thenvoi/runtime/tools.py), so the old _patch_band_subject_id_bug + # workaround is no longer needed. print(status_line) print("Memory: Band.ai remote API") + # Shadow mode (RFC WS1 / Phase 1): initialise the durable state store + # alongside the memory backend so its schema exists for later phases. + # Record-only — its result is never used to drive orchestration, and a + # failure here must not change observable behaviour of `cb run`, so it is + # fully guarded. The swarm behaves identically whether or not this succeeds. + try: + from codeband.state import StateStore + + StateStore(Path(workspace_path) / "state" / "orchestration.db") + except Exception: # noqa: BLE001 - shadow mode must never break the swarm + logger.warning("StateStore init skipped (shadow mode)", exc_info=True) + return mode # ─── workspace path helpers ───────────────────────────────────────────────── -def _resolve_workspace_config(config: CodebandConfig, project_dir: Path) -> CodebandConfig: - """Resolve workspace path relative to project_dir, returning updated config.""" +def _export_project_dir_env( + project_dir: Path, *, role: str | None = None, agent_id: str | None = None +) -> None: + """Export ``CODEBAND_PROJECT_DIR`` for every session this process spawns. + + ``role`` (Stage-3 attribution): when given, also exports + ``CODEBAND_ROLE=`` on this process so every spawned session inherits + it. This is the same seam that #46 used for ``CODEBAND_AGENT_SESSION``. It + is only meaningful in **distributed mode** (``run_agent``), where the + process IS a single role — local ``run_local`` runs every role in one + process, so there is no single role to export and ``CODEBAND_ROLE`` stays + unset (the operator-like, ungated path; cb-phase role gating treats unset + as allowed). Like the session marker, this is an accident guard / forensic + marker, not authentication. + + The coder/reviewer/mergemaster CLI sessions (Claude Code / Codex + subprocesses spawned by the adapters, which already receive their ``cwd`` + from the runner) inherit this process's environment — exporting here is + the one seam that injects the resolved project dir into every spawned + session, local and supervised alike. ``cb-phase`` and ``cb approve`` + resolve their project dir from this variable when no explicit + ``--project-dir`` is given (see ``cli/handoff.py:resolve_project_dir``), + so prompts pass no new flags and agents stop depending on their cwd + happening to be the project dir. Docker sets the same variable to + ``/app/config`` in the compose env block. + + ``CODEBAND_AGENT_SESSION`` rides the same seam: every spawned agent + session inherits it, and ``cb approve`` refuses to record a grant when it + is set. This is an ACCIDENT GUARD, not authentication — it stops an agent + from reflexively shelling out to the human-approval primitive (finding + 18); a motivated process can trivially unset it. Real identity binding is + a design-session concern. The interactive shell's ``/approve`` runs in + this same process (bare ``cb`` hosts the orchestrator in-process), so the + guard exempts ``command_style="slash"`` — that path is only reachable + from the human at the REPL prompt. + + ``agent_id`` (item-0 identity plumbing) rides the same seam in DISTRIBUTED + mode, where the process IS one Band agent: it is exported as + ``CODEBAND_AGENT_ID`` so the spawned ``cb-phase`` subprocess can stamp the + durable ``assigned_worker`` / ``assigned_reviewer`` fields with the real + agent_id (``codeband/identity.py``). When ``agent_id`` is NOT given (local + ``run_local``, where one process hosts every role and there is no single + identity) the var is actively CLEARED — a non-empty ``CODEBAND_AGENT_ID`` + must reliably mean "single distributed agent", so a stale/inherited value + cannot hijack local-mode resolution (which falls through to the + worktree/scratch location map instead). Like the role marker, this is a + forensic / mention aid, not authentication. + """ import os + os.environ["CODEBAND_PROJECT_DIR"] = str(Path(project_dir).resolve()) + os.environ["CODEBAND_AGENT_SESSION"] = "1" + if role is not None: + os.environ["CODEBAND_ROLE"] = role + if agent_id is not None: + os.environ["CODEBAND_AGENT_ID"] = agent_id + else: + os.environ.pop("CODEBAND_AGENT_ID", None) + + +def _resolve_workspace_config(config: CodebandConfig, project_dir: Path) -> CodebandConfig: + """Resolve workspace path relative to project_dir, returning updated config. + + Delegates to ``config.resolve_workspace_path`` — the one shared + ``$WORKSPACE``-aware rule, also used by ``cb-phase``/``cb approve``, + task registration and ``cb doctor`` — so every consumer agrees on where + the workspace (and its ``state/`` dir) lives. + """ + from codeband.config import resolve_workspace_path + ws_path = Path(config.workspace.path) - if not ws_path.is_absolute(): - workspace_env = os.environ.get("WORKSPACE") - base = Path(workspace_env) if workspace_env else project_dir - resolved = str(base / ws_path) - elif not ws_path.exists(): - resolved = str(ws_path) + resolved = str(resolve_workspace_path(config, project_dir)) + if ws_path.is_absolute() and not ws_path.exists(): logger.info("Creating workspace directory at %s", resolved) - else: - resolved = str(ws_path) return config.model_copy( update={"workspace": config.workspace.model_copy(update={"path": resolved})} ) def _create_band_agent(adapter, creds: AgentCredentials, config: CodebandConfig): - """Create a Band.ai Agent with standard connection args.""" + """Create a Band.ai Agent with standard connection args. + + The session config tunes the SDK's Phase-2 idle resync — how quickly an + idle agent re-polls its pending queue. It is the delivery backstop for + missed websocket pushes and applies to every role uniformly (all roles + funnel through this factory, local and distributed alike). + + ``max_message_retries`` (finding 22 mitigation 4b) rides the same seam: + the SDK default of 1 means a single transient turn failure permanently + retires an @mention client-side AND pins the room's resync backstop on + the poisoned head-of-queue. Raising it reduces how often that upstream + defect fires; it does not eliminate it (see ``config.AgentsConfig``). + """ from thenvoi import Agent + from thenvoi.runtime.types import SessionConfig return Agent.create( adapter=adapter, @@ -402,9 +767,71 @@ def _create_band_agent(adapter, creds: AgentCredentials, config: CodebandConfig) api_key=creds.api_key, ws_url=config.band.ws_url, rest_url=config.band.rest_url, + session_config=SessionConfig( + idle_resync_seconds=config.agents.idle_resync_seconds, + max_message_retries=config.agents.max_message_retries, + ), ) +def _resolve_delivery_mode(config: CodebandConfig) -> str: + """Resolve the message-delivery transport: ``sdk`` (default) or ``jam``. + + ``CODEBAND_DELIVERY`` env var overrides ``config.agents.delivery``. Any + unknown/empty value resolves to ``sdk`` — fail toward the proven path. This + is the ONE place the flag is interpreted; the ``jam`` modules are imported + only when this returns ``jam`` (see ``_create_delivery_agent``). + """ + import os + + raw = os.environ.get("CODEBAND_DELIVERY") or getattr(config.agents, "delivery", "sdk") + return "jam" if str(raw).strip().lower() == "jam" else "sdk" + + +def _create_delivery_agent(adapter, creds: AgentCredentials, config: CodebandConfig): + """Build the agent for the configured delivery transport. + + ``sdk`` → the unchanged :func:`_create_band_agent` (byte-identical default). + ``jam`` → a :class:`~codeband.transport.jam_runtime.JamAgent` exposing the + same ``.run()`` / ``.stop()`` contract. The adapter (with any recovery + context already baked in) is identical across both paths, so the brain is + unchanged; only inbound delivery + ack differ. Imported lazily so the ``sdk`` + path never touches the transport package. + """ + if _resolve_delivery_mode(config) == "jam": + from codeband.transport.jam_runtime import JamAgent + + return JamAgent(adapter, creds, config) + return _create_band_agent(adapter, creds, config) + + +async def _jam_delivery_preflight(config: CodebandConfig) -> None: + """Fail fast (jam mode only) if jamd's control socket is unreachable. + + Per-agent adoption happens idempotently in each ``JamAgent.start()``; this is + only a one-shot reachability check so an operator who opted into ``jam`` gets + a clear error before the swarm spins up, instead of every agent backing off. + No-op on the default ``sdk`` path (callers guard on ``_resolve_delivery_mode``). + + Async: ``run_local`` / ``run_agent`` are already inside a running event loop, + so this awaits the probe directly (a nested ``asyncio.run`` would raise). + """ + from codeband.transport.jam_control import JamControlClient, socket_path + + client = JamControlClient() + try: + reachable = await client.ping() + finally: + await client.close() + + if not reachable: + raise SystemExit( + "CODEBAND_DELIVERY=jam but the jam daemon control socket is not " + f"reachable at {socket_path()}. Start it with `jam daemon run` (or " + "unset CODEBAND_DELIVERY to use the default SDK delivery)." + ) + + def _create_rest_client(api_key: str, rest_url: str): """Create a Band.ai REST client (used for the memory probe and watchdog).""" from thenvoi.client.rest import AsyncRestClient @@ -515,12 +942,31 @@ def _iter_pool(pool: FrameworkPool, role: WorkerRole): # ─── run_local: pool-driven in-process runtime ───────────────────────────── +def _make_watchdog_done_callback(activity: Any) -> Callable[[asyncio.Task], None]: + """Return a done-callback that surfaces unexpected watchdog task deaths loudly.""" + + def _on_done(t: asyncio.Task) -> None: + if t.cancelled(): + return + exc = t.exception() + if exc is not None: + logger.error( + "Watchdog task died unexpectedly — %s: %s", + type(exc).__name__, exc, + exc_info=exc, + ) + activity.log("WATCHDOG_CRASH", "watchdog", f"{type(exc).__name__}: {exc}") + + return _on_done + + async def run_local( config: CodebandConfig, project_dir: Path, *, shutdown_event: asyncio.Event | None = None, ready_event: asyncio.Event | None = None, + fresh: bool = False, ) -> None: """Run all Codeband agents in a single async process. @@ -533,12 +979,40 @@ async def run_local( If ``ready_event`` is supplied, it is set once the agents banner has been printed and all tasks have been spawned. Used by the shell to sequence the "Ready; use /help…" hint after the orchestrator banner. + + ``fresh=True`` (``cb run --fresh``) skips rejoining existing rooms and + their backlog at startup; the default rejoins rooms tied to active + tasks in the StateStore (mid-task recovery). """ agent_config = await _ensure_agents_registered(config, project_dir) resolved_config = _resolve_workspace_config(config, project_dir) layout = initialize_workspace(resolved_config) + # Inputs for the patched startup sweep, read at sweep time. The store + # path uses the same post-#36/#40 resolution every state consumer shares + # (resolve_workspace_path via _resolve_workspace_config above). + _local_sweep_settings.state_db_path = ( + Path(resolved_config.workspace.path) / "state" / "orchestration.db" + ) + _local_sweep_settings.fresh = fresh + if _resolve_delivery_mode(resolved_config) == "jam": + # Opt-in jam delivery: fail fast if jamd is unreachable before spinning + # up the swarm. No effect on the default sdk path. + await _jam_delivery_preflight(resolved_config) _patch_band_local_runtime() + _patch_codex_adapter_resilience() + # Every agent session spawned below inherits the resolved project dir so + # cb-phase / cb approve resolve config + state from any cwd. No agent_id is + # passed (one process hosts every role), which also CLEARS any stale + # CODEBAND_AGENT_ID so local-mode identity resolution falls through to the + # location map written next. + _export_project_dir_env(project_dir) + + # item-0 identity plumbing: write the local-mode seat→agent_id map. Local + # mode has no per-process env identity, so each spawned cb-phase resolves + # its agent_id from the worktree/scratch dir it runs in. Covers every seat + # the layout knows so the map is general (not just the two stamp sites). + _write_local_location_map(layout, agent_config) # Resolve memory backend once per process, using the Conductor's creds. conductor_creds = agent_config.get("conductor") @@ -551,6 +1025,12 @@ async def run_local( from codeband.monitoring.activity_log import ActivityLogger activity = ActivityLogger(layout.state_dir / "activity.jsonl") + # Pure-observer instrument over the SDK ack path: emits MSG_ATTEMPT_WINDOW + # (the mark_processing→mark_processed held-open window + ack-422 flag) for + # diagnosing the mark-processed-422 cursor-pin. Idempotent, best-effort. + from codeband.monitoring.attempt_window import install_attempt_window_instrument + install_attempt_window_instrument(activity) + # Attach SDK usage tracking — tag log records with agent names # based on which asyncio task emitted them. from codeband.monitoring.usage import AgentTaskFilter, SDKUsageHandler @@ -568,30 +1048,44 @@ async def run_local( # brand-new Agent (and a brand-new PHXChannelsClient) on every reconnect # cycle. Reusing an instance leaks the SDK's internal reconnect task into # the next cycle and produces "Topic ... already subscribed" cascades. - def _band_agent_factory(adapter, creds): + # + # ``make_adapter`` is a partial over a ``_create_*`` factory with everything + # bound except ``recovery_context``; ``_run_agent_forever`` supplies fresh + # per-role recovery context (RFC WS5) on every reconnect, so the adapter — + # and the system prompt it carries — is rebuilt each cycle. + def _band_agent_factory(make_adapter, creds): config = resolved_config - return lambda: _create_band_agent(adapter, creds, config) - unsupervised: list[tuple[Callable[[], object], str]] = [] + def factory(recovery_context: str | None = None): + adapter = make_adapter(recovery_context=recovery_context) + return _create_delivery_agent(adapter, creds, config) + + return factory + + unsupervised: list[tuple[Callable[..., object], str]] = [] # --- Conductor (singleton) --- - cond_adapter = _create_conductor( - resolved_config, worker_roster=worker_roster, - ) - unsupervised.append( - (_band_agent_factory(cond_adapter, conductor_creds), "conductor"), - ) + unsupervised.append(( + _band_agent_factory( + partial(_create_conductor, resolved_config, worker_roster=worker_roster), + conductor_creds, + ), + "conductor", + )) logger.info("Created Conductor agent") # --- Mergemaster (singleton) --- mm_creds = agent_config.get("mergemaster") - mm_adapter = _create_mergemaster( - resolved_config, - str(layout.mergemaster_worktree) if layout.mergemaster_worktree else None, - ) - unsupervised.append( - (_band_agent_factory(mm_adapter, mm_creds), "mergemaster"), + mm_workspace = ( + str(layout.mergemaster_worktree) if layout.mergemaster_worktree else None ) + unsupervised.append(( + _band_agent_factory( + partial(_create_mergemaster, resolved_config, mm_workspace), + mm_creds, + ), + "mergemaster", + )) logger.info("Created Mergemaster agent") # --- Planner pool --- @@ -599,13 +1093,14 @@ def _band_agent_factory(adapter, creds): key = str(wid) creds = agent_config.get(key) wt_path = layout.planner_worktrees.get(key) - adapter = _create_planner( + make_adapter = partial( + _create_planner, resolved_config, workspace=str(wt_path) if wt_path else None, framework=wid.framework, worker_roster=worker_roster, ) - unsupervised.append((_band_agent_factory(adapter, creds), key)) + unsupervised.append((_band_agent_factory(make_adapter, creds), key)) logger.info("Created %s", key) # --- Plan Reviewer pool --- @@ -615,12 +1110,13 @@ def _band_agent_factory(adapter, creds): key = str(wid) creds = agent_config.get(key) wt_path = layout.plan_reviewer_worktrees.get(key) - adapter = _create_plan_reviewer( + make_adapter = partial( + _create_plan_reviewer, resolved_config, workspace=str(wt_path) if wt_path else None, framework=wid.framework, ) - unsupervised.append((_band_agent_factory(adapter, creds), key)) + unsupervised.append((_band_agent_factory(make_adapter, creds), key)) logger.info("Created %s", key) # --- Reviewer pool (code reviewers) --- @@ -628,12 +1124,29 @@ def _band_agent_factory(adapter, creds): key = str(wid) creds = agent_config.get(key) scratch_path = layout.reviewer_scratch.get(key) - adapter = _create_code_reviewer( + make_adapter = partial( + _create_code_reviewer, resolved_config, workspace=str(scratch_path) if scratch_path else None, framework=wid.framework, ) - unsupervised.append((_band_agent_factory(adapter, creds), key)) + unsupervised.append((_band_agent_factory(make_adapter, creds), key)) + logger.info("Created %s", key) + + # --- Verifier pool (evidence-integrity acceptance gate) --- + # Mirrors the reviewer pool exactly: isolated scratch dir, no supervisor. + # INERT (no slots) when verifiers are count=0; the iterator yields nothing. + for wid, _entry in _iter_pool(resolved_config.agents.verifiers, WorkerRole.VERIFIER): + key = str(wid) + creds = agent_config.get(key) + scratch_path = layout.verifier_scratch.get(key) + make_adapter = partial( + _create_verifier, + resolved_config, + workspace=str(scratch_path) if scratch_path else None, + framework=wid.framework, + ) + unsupervised.append((_band_agent_factory(make_adapter, creds), key)) logger.info("Created %s", key) # --- Watchdog (deterministic daemon, not a Band.ai Agent) --- @@ -643,6 +1156,18 @@ def _band_agent_factory(adapter, creds): role_map, wd_human_rest = await _build_watchdog_extras( agent_config, resolved_config, ) + # Per-agent REST clients for the transport-heal rung: keyed by agent_id, + # each authenticated as THAT agent so the rung's + # `list_agent_messages(status="processing")` + `mark_agent_message_processed` + # calls act on its own delivery row. The Conductor's client only sees/heals + # the Conductor's deliveries, so without this map the heal would be a + # no-op for every other agent. + agent_rest_clients: dict[str, Any] = { + creds.agent_id: _create_rest_client( + creds.api_key, resolved_config.band.rest_url, + ) + for creds in agent_config.agents.values() + } watchdog = WatchdogDaemon( config=resolved_config.agents.watchdog, rest_client=wd_rest, @@ -654,6 +1179,15 @@ def _band_agent_factory(adapter, creds): local_memory_store=_build_watchdog_memory_store( memory_mode, Path(resolved_config.workspace.path), ), + state_store=_build_watchdog_state_store( + Path(resolved_config.workspace.path), + ), + # Repo context for the mechanical-progress probes (S9-1): the + # workspace's bare clone + the config-derived slug, so git/gh probes + # are cwd-independent. + bare_repo=layout.bare_repo, + repo_slug=_watchdog_repo_slug(resolved_config), + agent_rest_clients=agent_rest_clients, ) logger.info("Created Watchdog daemon") @@ -694,11 +1228,17 @@ def _band_agent_factory(adapter, creds): # task → human-readable name for shutdown-time diagnostics. task_names: dict[asyncio.Task, str] = {} + workspace_path = Path(resolved_config.workspace.path) unsupervised_tasks = [] for i, (make_agent, name) in enumerate(unsupervised): if i > 0: await asyncio.sleep(_STARTUP_DELAY) - task = asyncio.create_task(_run_agent_forever(make_agent, name, activity)) + task = asyncio.create_task( + _run_agent_forever( + make_agent, name, activity, + agent_key=name, workspace_path=workspace_path, + ) + ) agent_task_filter.register(task, name) task_names[task] = name unsupervised_tasks.append(task) @@ -713,9 +1253,40 @@ def _band_agent_factory(adapter, creds): watchdog_task = asyncio.create_task(watchdog.run()) task_names[watchdog_task] = "watchdog" + watchdog_task.add_done_callback(_make_watchdog_done_callback(activity)) shutdown_task = asyncio.create_task(shutdown_event.wait()) + # Session-agent heartbeat — only when CODEBAND_SESSION_AGENT_KEY is set. + # Refreshes the local liveness marker on a ~5-min timer, tied to this + # process's lifecycle so the marker stays fresh as long as the orchestrator + # is alive and goes stale when it dies. + heartbeat_task: asyncio.Task | None = None + session_agent_key = os.environ.get("CODEBAND_SESSION_AGENT_KEY") or None + if session_agent_key: + try: + from codeband.orchestration.session_agent import start_heartbeat_loop + from thenvoi_rest import AsyncRestClient as _ARC + _sa_client = _ARC( + api_key=session_agent_key, base_url=resolved_config.band.rest_url, + ) + _sa_identity = await _sa_client.agent_api_identity.get_agent_me() + _sa_id = _sa_identity.data.id + _sa_name = _sa_identity.data.name + _sa_repo = _watchdog_repo_slug(resolved_config) or "repo" + heartbeat_task = asyncio.create_task( + start_heartbeat_loop(_sa_id, _sa_name, _sa_repo) + ) + task_names[heartbeat_task] = "session-heartbeat" + logger.info("Session heartbeat started for agent %s", _sa_id) + except Exception: + logger.warning( + "Could not resolve session agent identity — heartbeat skipped", + exc_info=True, + ) + all_tasks = unsupervised_tasks + supervisor_tasks + [watchdog_task] + if heartbeat_task is not None: + all_tasks.append(heartbeat_task) worker_keys = [name for _, name in unsupervised] + [s._worker_id for s in supervisors] # noqa: SLF001 print(f"Agents ({agent_count}): {', '.join(worker_keys)}, watchdog") if ready_event is not None: @@ -781,6 +1352,10 @@ async def run_agent(config: CodebandConfig, project_dir: Path, agent_key: str) - - `{role}-{framework}-{index}` — pool workers (e.g., `coder-claude_sdk-0`) - `watchdog` — in-process daemon (reuses Conductor creds) """ + # Adapter-defect mitigation (finding 22), NOT a lifecycle patch: unlike + # _patch_band_local_runtime, this applies in distributed mode too. + _patch_codex_adapter_resilience() + agent_config = await _ensure_agents_registered(config, project_dir) if agent_key == "watchdog": @@ -791,7 +1366,19 @@ async def run_agent(config: CodebandConfig, project_dir: Path, agent_key: str) - role = _role_from_key(agent_key) resolved_config = _resolve_workspace_config(config, project_dir) + if _resolve_delivery_mode(resolved_config) == "jam": + # Opt-in jam delivery (distributed mode): fail fast if jamd is down. + await _jam_delivery_preflight(resolved_config) layout = initialize_agent_workspace(resolved_config, agent_key, role) + # Same seam as run_local: the agent session spawned below inherits the + # resolved project dir so cb-phase / cb approve work from any cwd. In + # Docker the compose env block already pins this to /app/config — the + # re-export resolves to the identical path (project_dir IS that dir). + # Distributed mode IS a single role per process, so we also export + # CODEBAND_ROLE here (Stage-3 attribution / cb-phase role gating) and + # CODEBAND_AGENT_ID (item-0): the cb-phase subprocess stamps the durable + # assigned_worker / assigned_reviewer with this agent_id. + _export_project_dir_env(project_dir, role=role, agent_id=creds.agent_id) # Resolve memory backend per process. probe_client = _create_rest_client(creds.api_key, resolved_config.band.rest_url) @@ -802,6 +1389,11 @@ async def run_agent(config: CodebandConfig, project_dir: Path, agent_key: str) - from codeband.monitoring.activity_log import ActivityLogger activity = ActivityLogger(layout.state_dir / "activity.jsonl") + # Pure-observer SDK ack-path instrument (MSG_ATTEMPT_WINDOW). See the local + # `run` path above; idempotent, so installing from both is safe. + from codeband.monitoring.attempt_window import install_attempt_window_instrument + install_attempt_window_instrument(activity) + from codeband.monitoring.usage import SDKUsageHandler sdk_usage_handler = SDKUsageHandler(activity, agent_name=agent_key) @@ -835,21 +1427,39 @@ async def _run_until_shutdown(coro) -> None: raise SystemExit(1) async def _run_band_agent(adapter) -> None: - agent = _create_band_agent(adapter, creds, resolved_config) + agent = _create_delivery_agent(adapter, creds, resolved_config) try: await _run_until_shutdown(agent.run()) finally: if hasattr(agent, "close"): await agent.close() + # Distributed-mode rehydration (RFC WS5): rebuild per-role recovery context + # from the durable StateStore before building the adapter. Guarded — any + # failure falls back to None, identical to today's blank reconnect. The + # coder path (handled below) rehydrates from git via WorkerSupervisor. + recovery_context: str | None = None + if role in ( + "conductor", "mergemaster", "planner", "plan_reviewer", "reviewer", "verifier", + ): + from codeband.state.rehydration import recover_for_reconnect + + recovery_context = await recover_for_reconnect( + agent_key, Path(resolved_config.workspace.path), + ) + if role == "conductor": roster = _build_worker_roster(resolved_config) - adapter = _create_conductor(resolved_config, worker_roster=roster) + adapter = _create_conductor( + resolved_config, worker_roster=roster, recovery_context=recovery_context, + ) await _run_band_agent(adapter) elif role == "mergemaster": workspace = str(layout.worktree) if layout.worktree else None - adapter = _create_mergemaster(resolved_config, workspace) + adapter = _create_mergemaster( + resolved_config, workspace, recovery_context=recovery_context, + ) await _run_band_agent(adapter) elif role == "planner": @@ -859,6 +1469,7 @@ async def _run_band_agent(adapter) -> None: adapter = _create_planner( resolved_config, workspace=workspace, framework=framework, worker_roster=roster, + recovery_context=recovery_context, ) await _run_band_agent(adapter) @@ -867,6 +1478,7 @@ async def _run_band_agent(adapter) -> None: workspace = str(layout.worktree) if layout.worktree else None adapter = _create_plan_reviewer( resolved_config, workspace=workspace, framework=framework, + recovery_context=recovery_context, ) await _run_band_agent(adapter) @@ -875,6 +1487,16 @@ async def _run_band_agent(adapter) -> None: workspace = str(layout.reviewer_workspace) if layout.reviewer_workspace else None adapter = _create_code_reviewer( resolved_config, workspace=workspace, framework=framework, + recovery_context=recovery_context, + ) + await _run_band_agent(adapter) + + elif role == "verifier": + framework = _framework_from_key(agent_key) + workspace = str(layout.verifier_workspace) if layout.verifier_workspace else None + adapter = _create_verifier( + resolved_config, workspace=workspace, framework=framework, + recovery_context=recovery_context, ) await _run_band_agent(adapter) @@ -918,6 +1540,12 @@ async def _run_band_agent(adapter) -> None: local_memory_store=_build_watchdog_memory_store( memory_mode, Path(resolved_config.workspace.path), ), + state_store=_build_watchdog_state_store( + Path(resolved_config.workspace.path), + ), + # Same repo context as run_local (S9-1): cwd-independent probes. + bare_repo=layout.bare_repo, + repo_slug=_watchdog_repo_slug(resolved_config), ) try: await _run_until_shutdown(watchdog.run()) @@ -943,11 +1571,54 @@ def _role_from_key(key: str) -> str: parts = key.rsplit("-", 2) if len(parts) == 3: role = parts[0] - if role in {"planner", "plan_reviewer", "coder", "reviewer"}: + if role in {"planner", "plan_reviewer", "coder", "reviewer", "verifier"}: return role raise ValueError(f"Cannot derive role from agent key: {key}") +def _write_local_location_map(layout, agent_config: dict) -> None: + """Write the local-mode seat→agent_id map from the workspace layout. + + item-0 identity plumbing: local mode (``run_local``) hosts every role in one + process, so ``cb-phase`` cannot read a per-process ``CODEBAND_AGENT_ID``. + Instead each agent's CLI subprocess runs from its own worktree/scratch dir, + and :func:`codeband.identity.resolve_identity` maps that cwd back to the + seat's agent_id via this file. Every seat the layout exposes is included + (planner / plan_reviewer / coder worktrees + reviewer / verifier scratch + + mergemaster worktree) so the map is general, not scoped to the two stamp + sites. A seat missing from ``agent_config`` (count mismatch) is skipped. + """ + from codeband.identity import write_location_map + + seat_dirs: dict[str, Path] = {} + seat_dirs.update(layout.planner_worktrees) + seat_dirs.update(layout.plan_reviewer_worktrees) + seat_dirs.update(layout.coder_worktrees) + seat_dirs.update(layout.reviewer_scratch) + seat_dirs.update(layout.verifier_scratch) + if layout.mergemaster_worktree is not None: + seat_dirs["mergemaster"] = layout.mergemaster_worktree + + entries: list[dict] = [] + for worker_id, seat_dir in seat_dirs.items(): + creds = agent_config.get(worker_id) + if creds is None: + continue + try: + role = _role_from_key(worker_id) + except ValueError: + continue + entries.append( + { + "dir": str(seat_dir), + "worker_id": worker_id, + "agent_id": creds.agent_id, + "role": role, + } + ) + write_location_map(layout.state_dir, entries) + + def _framework_from_key(key: str) -> Framework: """Map a pool-worker key to its Framework.""" parts = key.rsplit("-", 2) @@ -978,6 +1649,7 @@ def _build_worker_roster(config: CodebandConfig) -> str: WorkerRole.REVIEWER: "Reviewer", WorkerRole.PLANNER: "Planner", WorkerRole.PLAN_REVIEWER: "Plan-Reviewer", + WorkerRole.VERIFIER: "Verifier", } def _display_name(role: WorkerRole, fw: Framework, index: int) -> str: @@ -998,6 +1670,7 @@ def _rows(role: WorkerRole, role_label: str, pool: FrameworkPool) -> None: _rows(WorkerRole.REVIEWER, "Code Reviewer", config.agents.reviewers) _rows(WorkerRole.PLANNER, "Planner", config.agents.planners) _rows(WorkerRole.PLAN_REVIEWER, "Plan Reviewer", config.agents.plan_reviewers) + _rows(WorkerRole.VERIFIER, "Verifier", config.agents.verifiers) return "\n".join(lines) @@ -1009,6 +1682,7 @@ def _create_planner( *, framework: Framework = Framework.CLAUDE_SDK, worker_roster: str | None = None, + recovery_context: str | None = None, ) -> "FrameworkAdapter": """Create a Planner adapter for the given framework.""" entry = config.agents.planners.entry_for(framework) @@ -1016,12 +1690,14 @@ def _create_planner( kwargs = dict( workspace=workspace, worker_roster=worker_roster, + recovery_context=recovery_context, ) if entry.model: kwargs["model"] = entry.model if framework == Framework.CODEX: from codeband.agents.planner import CodexPlannerRunner + kwargs["turn_timeout_seconds"] = config.agents.codex_turn_timeout_seconds return CodexPlannerRunner(**kwargs).adapter from codeband.agents.planner import ClaudePlannerRunner @@ -1066,6 +1742,7 @@ def _create_conductor( config: CodebandConfig, *, worker_roster: str | None = None, + recovery_context: str | None = None, ) -> "FrameworkAdapter": """Create the conductor adapter — singleton coordinator, framework-selectable. @@ -1078,10 +1755,12 @@ def _create_conductor( worker_roster=worker_roster, auto_merge=config.agents.mergemaster.auto_merge.value, repo_pin=_build_repo_pin(config), + recovery_context=recovery_context, ) if config.agents.conductor.framework == Framework.CODEX: from codeband.agents.conductor import CodexConductorRunner + kwargs["turn_timeout_seconds"] = config.agents.codex_turn_timeout_seconds return CodexConductorRunner(**kwargs).adapter from codeband.agents.conductor import ClaudeConductorRunner @@ -1093,6 +1772,7 @@ def _create_code_reviewer( workspace: str | None = None, *, framework: Framework = Framework.CLAUDE_SDK, + recovery_context: str | None = None, ) -> "FrameworkAdapter": """Create a code-reviewer adapter for the given framework.""" reviewers = config.agents.reviewers @@ -1102,21 +1782,56 @@ def _create_code_reviewer( model=entry.model or "claude-sonnet-4-6", review_guidelines=reviewers.review_guidelines, workspace=workspace, + recovery_context=recovery_context, ) if framework == Framework.CODEX: from codeband.agents.code_reviewer import CodexCodeReviewerRunner + kwargs["turn_timeout_seconds"] = config.agents.codex_turn_timeout_seconds return CodexCodeReviewerRunner(**kwargs).adapter from codeband.agents.code_reviewer import ClaudeCodeReviewerRunner return ClaudeCodeReviewerRunner(**kwargs).adapter +def _create_verifier( + config: CodebandConfig, + workspace: str | None = None, + *, + framework: Framework = Framework.CLAUDE_SDK, + recovery_context: str | None = None, +) -> "FrameworkAdapter": + """Create a verifier adapter for the given framework. + + A clean mirror of :func:`_create_code_reviewer` — the Verifier is a + reviewer-shaped seat (isolated scratch dir + gh network) whose verdict is + the SHA-pinned ``verify_acceptance`` acceptance gate. ``VerifiersConfig`` + carries no ``review_guidelines``, so the prompt is ``verifier.md`` verbatim. + """ + verifiers = config.agents.verifiers + entry = verifiers.entry_for(framework) + + kwargs = dict( + model=entry.model or "claude-sonnet-4-6", + workspace=workspace, + recovery_context=recovery_context, + ) + + if framework == Framework.CODEX: + from codeband.agents.verifier import CodexVerifierRunner + kwargs["turn_timeout_seconds"] = config.agents.codex_turn_timeout_seconds + return CodexVerifierRunner(**kwargs).adapter + + from codeband.agents.verifier import ClaudeVerifierRunner + return ClaudeVerifierRunner(**kwargs).adapter + + def _create_plan_reviewer( config: CodebandConfig, workspace: str | None = None, *, framework: Framework = Framework.CLAUDE_SDK, + recovery_context: str | None = None, ) -> "FrameworkAdapter": """Create a plan-reviewer adapter for the given framework.""" plan_reviewers = config.agents.plan_reviewers @@ -1126,10 +1841,12 @@ def _create_plan_reviewer( model=entry.model or "claude-sonnet-4-6", review_guidelines=plan_reviewers.review_guidelines, workspace=workspace, + recovery_context=recovery_context, ) if framework == Framework.CODEX: from codeband.agents.plan_reviewer import CodexPlanReviewerRunner + kwargs["turn_timeout_seconds"] = config.agents.codex_turn_timeout_seconds return CodexPlanReviewerRunner(**kwargs).adapter from codeband.agents.plan_reviewer import ClaudePlanReviewerRunner @@ -1182,6 +1899,7 @@ def _create_coder( ) if entry.model: kwargs["model"] = entry.model + kwargs["turn_timeout_seconds"] = config.agents.codex_turn_timeout_seconds runner = CodexPlayerRunner(**kwargs) return runner.adapter @@ -1191,6 +1909,8 @@ def _create_coder( def _create_mergemaster( config: CodebandConfig, workspace: str | None, + *, + recovery_context: str | None = None, ) -> "FrameworkAdapter": """Create the mergemaster adapter — singleton coordinator, framework-selectable.""" kwargs = dict( @@ -1198,10 +1918,12 @@ def _create_mergemaster( workspace=workspace, test_command=config.agents.mergemaster.test_command, review_guidelines=config.agents.mergemaster.review_guidelines, + recovery_context=recovery_context, ) if config.agents.mergemaster.framework == Framework.CODEX: from codeband.agents.mergemaster import CodexMergemasterRunner + kwargs["turn_timeout_seconds"] = config.agents.codex_turn_timeout_seconds return CodexMergemasterRunner(**kwargs).adapter from codeband.agents.mergemaster import ClaudeMergemasterRunner @@ -1226,6 +1948,6 @@ async def create(*, recovery_context: str | None = None): recovery_context=recovery_context, worker_roster=worker_roster, ) - return _create_band_agent(adapter, creds, config) + return _create_delivery_agent(adapter, creds, config) return create diff --git a/src/codeband/orchestration/session_agent.py b/src/codeband/orchestration/session_agent.py new file mode 100644 index 0000000..26a012f --- /dev/null +++ b/src/codeband/orchestration/session_agent.py @@ -0,0 +1,287 @@ +"""Per-session Band agent lifecycle: register, heartbeat, sweep.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +logger = logging.getLogger(__name__) + +_HEARTBEAT_INTERVAL_SECONDS = 300 # 5 min — orchestrator refreshes this often +_STALE_THRESHOLD_SECONDS = 900 # 15 min = 3 missed beats → stale +_SESSION_AGENT_PREFIX = "codeband-session-" + + +def _sessions_dir() -> Path: + return Path.home() / ".codeband" / "sessions" + + +def _marker_path(agent_id: str) -> Path: + return _sessions_dir() / f"{agent_id}.json" + + +def repo_slug_from_project(project_dir: Path | None = None) -> str: + """Extract a short repo slug from git origin, falling back to 'repo'.""" + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, text=True, check=True, + cwd=str(project_dir) if project_dir else None, + ) + url = result.stdout.strip() + m = re.search(r"/([^/]+?)(?:\.git)?$", url) + return m.group(1) if m else "repo" + except Exception: + return "repo" + + +def write_heartbeat( + agent_id: str, + agent_name: str, + pid: int, + repo: str, + *, + sessions_dir: Path | None = None, +) -> Path: + """Write or refresh the local liveness marker for ``agent_id``. + + Returns the path written. + """ + base = sessions_dir if sessions_dir is not None else _sessions_dir() + base.mkdir(parents=True, exist_ok=True) + path = base / f"{agent_id}.json" + data = { + "agent_id": agent_id, + "agent_name": agent_name, + "pid": pid, + "last_heartbeat": datetime.now(timezone.utc).isoformat(), + "repo": repo, + } + path.write_text(json.dumps(data), encoding="utf-8") + return path + + +def read_marker(agent_id: str, *, sessions_dir: Path | None = None) -> dict | None: + """Return the parsed marker dict for ``agent_id``, or None if absent/corrupt.""" + base = sessions_dir if sessions_dir is not None else _sessions_dir() + path = base / f"{agent_id}.json" + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _pid_alive(pid: int) -> bool: + """True if the process is alive (signal 0 probe).""" + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError): + return False + + +def is_stale(marker: dict | None) -> bool: + """True if the marker indicates the session is no longer live. + + Single-machine assumption: no marker means the agent was never started + or the marker was lost, which we treat as stale. Revisit when cross-machine + concurrency is added (no-marker would need a network source of truth). + """ + if marker is None: + return True + # Dead pid is an additional stale signal even when the timestamp is fresh + pid = marker.get("pid") + if isinstance(pid, int) and not _pid_alive(pid): + return True + # Primary signal: heartbeat age + ts = marker.get("last_heartbeat") + if not ts: + return True + try: + last = datetime.fromisoformat(ts) + age = (datetime.now(timezone.utc) - last).total_seconds() + return age > _STALE_THRESHOLD_SECONDS + except ValueError: + return True + + +async def register_session_agent( + owner_id: str, + repo: str, + *, + rest_url: str, + band_api_key: str, + sessions_dir: Path | None = None, +) -> tuple[str, str]: + """Mint a ``codeband-session-*`` agent on Band.ai and write the initial marker. + + Returns ``(agent_id, api_key)``. Atomic: if the marker write fails the + just-created agent is deleted before raising so no orphan is left on the + platform. + """ + import secrets + + from thenvoi_rest import AsyncRestClient + from thenvoi_rest.types import AgentRegisterRequest + + hex_suffix = secrets.token_hex(4) + name = f"{_SESSION_AGENT_PREFIX}{repo}-{hex_suffix}" + description = f"Session agent for codeband operator (owner: {owner_id})" + + client = AsyncRestClient(api_key=band_api_key, base_url=rest_url) + response = await client.human_api_agents.register_my_agent( + agent=AgentRegisterRequest(name=name, description=description) + ) + agent = response.data.agent + credentials = response.data.credentials + agent_id = agent.id + api_key = credentials.api_key + logger.warning("Registered session agent %s (%s)", name, agent_id) + + try: + write_heartbeat( + agent_id, name, pid=os.getpid(), repo=repo, + sessions_dir=sessions_dir, + ) + except Exception as exc: + try: + await client.human_api_agents.delete_my_agent(agent_id, force=True) + logger.info("Rolled back session agent %s after marker write failure", agent_id) + except Exception: + logger.warning("Rollback delete failed for orphaned agent %s", agent_id) + raise RuntimeError( + f"Failed to write session marker (agent rolled back): {exc}" + ) from exc + + return agent_id, api_key + + +async def sweep_stale_session_agents( + *, + band_api_key: str, + rest_url: str, + current_agent_id: str | None = None, + sessions_dir: Path | None = None, +) -> list[str]: + """Delete stale ``codeband-session-*`` agents owned by this operator. + + Stale = no local marker, heartbeat older than ``_STALE_THRESHOLD_SECONDS``, + or marker's pid is not alive. Single-machine assumption: no marker = stale. + Agents with a fresh marker are never deleted. The current session's own + agent (``current_agent_id``) is always skipped. + """ + from thenvoi_rest import AsyncRestClient + + client = AsyncRestClient(api_key=band_api_key, base_url=rest_url) + response = await client.human_api_agents.list_my_agents() + agents = response.data or [] + + deleted: list[str] = [] + for a in agents: + if not a.name.startswith(_SESSION_AGENT_PREFIX): + continue + if current_agent_id and a.id == current_agent_id: + logger.debug("Skipping current session agent %s", a.id) + continue + marker = read_marker(a.id, sessions_dir=sessions_dir) + if not is_stale(marker): + logger.debug("Agent %s has fresh marker — skipping sweep", a.id) + continue + try: + await client.human_api_agents.delete_my_agent(a.id, force=True) + base = sessions_dir if sessions_dir is not None else _sessions_dir() + mpath = base / f"{a.id}.json" + if mpath.is_file(): + mpath.unlink() + deleted.append(a.id) + logger.info("Swept stale session agent %s (%s)", a.name, a.id) + except Exception as exc: + logger.warning("Failed to delete session agent %s: %s", a.id, exc) + + return deleted + + +async def delete_session_agent( + agent_id: str, + *, + band_api_key: str, + rest_url: str, + sessions_dir: Path | None = None, +) -> None: + """Delete a specific session agent and its local marker (clean-exit cleanup). + + Separate from sweep: sweep is a startup garbage-collection pass over ALL + stale agents; this is the targeted delete for the agent that was minted by + THIS run and is being cleaned up at its normal exit. + """ + from thenvoi_rest import AsyncRestClient + + client = AsyncRestClient(api_key=band_api_key, base_url=rest_url) + await client.human_api_agents.delete_my_agent(agent_id, force=True) + base = sessions_dir if sessions_dir is not None else _sessions_dir() + mpath = base / f"{agent_id}.json" + if mpath.is_file(): + mpath.unlink() + logger.info("Deleted session agent %s (clean exit)", agent_id) + + +async def enroll_session_agent_in_room( + *, + session_agent_key: str, + band_api_key: str, + room_id: str, + rest_url: str, +) -> None: + """Add the session agent as a participant in an existing room. + + Called at ``cb run`` startup for late enrollment — the room was created + before the session agent was minted, so send_task's enrollment path did not + run. Covers both the /codeband path (jam creates the room, cb register-task + registers it, then cb run starts) and the cb-task / cb-run two-step. + """ + from thenvoi_rest import AsyncRestClient, ParticipantRequest + + session_client = AsyncRestClient(api_key=session_agent_key, base_url=rest_url) + identity = await session_client.agent_api_identity.get_agent_me() + session_agent_id = identity.data.id + + human_client = AsyncRestClient(api_key=band_api_key, base_url=rest_url) + await human_client.human_api_participants.add_my_chat_participant( + room_id, + participant=ParticipantRequest(participant_id=session_agent_id), + ) + logger.info( + "Enrolled session agent %s in room %s (late enrollment)", + session_agent_id, room_id, + ) + + +async def start_heartbeat_loop( + agent_id: str, + agent_name: str, + repo: str, + *, + sessions_dir: Path | None = None, +) -> None: + """Refresh the session marker every ``_HEARTBEAT_INTERVAL_SECONDS``. + + Runs indefinitely; cancelled by the orchestrator lifecycle on shutdown. + """ + pid = os.getpid() + while True: + try: + write_heartbeat( + agent_id, agent_name, pid=pid, repo=repo, + sessions_dir=sessions_dir, + ) + except Exception: + logger.exception("Heartbeat write failed for session agent %s", agent_id) + await asyncio.sleep(_HEARTBEAT_INTERVAL_SECONDS) diff --git a/src/codeband/orchestration/setup.py b/src/codeband/orchestration/setup.py index 7871a24..d345a29 100644 --- a/src/codeband/orchestration/setup.py +++ b/src/codeband/orchestration/setup.py @@ -2,7 +2,7 @@ Expected agents are derived from the worker-pool configuration: - Two singletons: `conductor`, `mergemaster`. -- Four pools: `planners`, `plan_reviewers`, `coders`, `reviewers`. +- Five pools: `planners`, `plan_reviewers`, `coders`, `reviewers`, `verifiers`. Each pool contributes `count` agents per active framework. Agent config keys follow `{role}-{framework}-{index}` (e.g. `coder-claude_sdk-0`); Band.ai display names are the friendlier `Coder-Claude-0`. @@ -127,6 +127,18 @@ class _DeleteReason(str, Enum): "auto-merge vs human approval. Discovery: role=code_review_agent" ), ), + ( + WorkerRole.VERIFIER, + "verifiers", + ( + "Codeband Verifier — checks evidence integrity for completed " + "subtasks before merge. Cross-model: verifies evidence from " + "Coders on the opposite framework. Posts a PASS/FAIL verdict " + "consumed by the merge gate. Active by default when configured; " + "set both verifier counts to 0 to opt out. Discovery: " + "role=verification_agent" + ), + ), ) @@ -183,14 +195,21 @@ def _expected_agents(config: CodebandConfig) -> dict[str, tuple[str, str]]: # ─── identification of codeband agents on Band.ai ────────────────────────── -_LEGACY_AGENT_NAMES = frozenset({ - "Watchdog", # previously registered - "Planner", # legacy single-planner name - "Plan Reviewer", # legacy singleton - "Code Reviewer", # legacy singleton -}) +_LEGACY_AGENT_NAMES = frozenset( + { + "Watchdog", # previously registered + "Planner", # legacy single-planner name + "Plan Reviewer", # legacy singleton + "Code Reviewer", # legacy singleton + } +) _CODEBAND_PREFIXES = ( - "Planner-", "Plan-Reviewer-", "Coder-", "Reviewer-", "Player-", + "Planner-", + "Plan-Reviewer-", + "Coder-", + "Reviewer-", + "Verifier-", + "Player-", ) @@ -205,6 +224,7 @@ def _is_codeband_agent(name: str) -> bool: # ─── main registration ───────────────────────────────────────────────────── + async def register_all_agents( config: CodebandConfig, project_dir: Path, @@ -219,14 +239,12 @@ async def register_all_agents( agents (e.g., old player names or reduced pool counts), and registers any missing ones. - `detect_drift=True` (default, for `cb setup-agents`): also re-register - agents whose platform-side `description` no longer matches the canonical - description in `_SINGLETON_AGENTS` / `_POOL_ROLES`. Re-registration - rotates the agent's ID and api_key, so any session currently using the - old credential will fail on next reconnect. The auto-bootstrap path in - `runner.py:_ensure_agents_registered` passes `detect_drift=False` so that - starting `cb run` while another swarm is alive in a different terminal - cannot rotate that swarm's credentials out from under it. + `detect_drift=True` (default, for `cb setup-agents`): warn when an agent's + platform-side `description` no longer matches the canonical description in + `_SINGLETON_AGENTS` / `_POOL_ROLES`, but keep existing credentials in place. + The auto-bootstrap path in `runner.py:_ensure_agents_registered` passes + `detect_drift=False` to skip even that warning while another swarm may be + alive in a different terminal. """ if client is None: api_key = os.environ.get("BAND_API_KEY") @@ -236,6 +254,7 @@ async def register_all_agents( "Get one from https://platform.band.ai" ) from thenvoi_rest import AsyncRestClient + client = AsyncRestClient(api_key=api_key, base_url=config.band.rest_url) # Load existing credentials if available @@ -259,15 +278,19 @@ async def register_all_agents( # Description drift is only checked under detect_drift=True (cb setup-agents); # the cb run auto-bootstrap path skips it so a starting swarm cannot rotate # credentials of agents another swarm is using. + # + # _delete_failures tracks CREDENTIAL_MISMATCH delete failures by display-name. + # Only mismatch failures block registration (the platform still owns the name, + # so a subsequent _register_agent call would 422). NAME_NO_LONGER_EXPECTED and + # DESCRIPTION_DRIFT failures do not cause spurious registers so they are logged + # but do not block the run. + _delete_failures: dict[str, Exception] = {} + for name, agent in list(platform_agents.items()): if not _is_codeband_agent(name): continue - matching_key = next( - (k for k, (n, _) in expected.items() if n == name), None - ) - existing_creds = ( - existing_config.agents.get(matching_key) if matching_key else None - ) + matching_key = next((k for k, (n, _) in expected.items() if n == name), None) + existing_creds = existing_config.agents.get(matching_key) if matching_key else None delete_reason: _DeleteReason | None = None if name not in expected_names: delete_reason = _DeleteReason.NAME_NO_LONGER_EXPECTED @@ -277,12 +300,18 @@ async def register_all_agents( expected_desc = expected[matching_key][1] platform_desc = agent.description or "" if platform_desc != expected_desc: - delete_reason = _DeleteReason.DESCRIPTION_DRIFT + logger.warning( + "Agent %s has description drift; leaving existing credentials in place", + name, + ) if delete_reason: try: await client.human_api_agents.delete_my_agent(agent.id, force=True) logger.info( - "Deleted agent %s (%s): %s", name, delete_reason.value, agent.id, + "Deleted agent %s (%s): %s", + name, + delete_reason.value, + agent.id, ) # Pop platform_agents so the main loop's reuse check fails and # re-registers. For drift, also pop the local cred — otherwise @@ -291,12 +320,26 @@ async def register_all_agents( if delete_reason == _DeleteReason.DESCRIPTION_DRIFT and matching_key: existing_config.agents.pop(matching_key, None) except Exception as e: - logger.warning("Failed to delete agent %s: %s", name, e) + logger.error("Failed to delete agent %s (%s): %s", name, delete_reason.value, e) + if delete_reason is _DeleteReason.CREDENTIAL_MISMATCH: + # The platform still owns this name. Registering a new agent + # with the same name would 422. Track for skip + deferred raise. + _delete_failures[name] = e agent_config = AgentConfigFile() # Reuse or register each expected agent for key, (display_name, description) in expected.items(): + if display_name in _delete_failures: + # A CREDENTIAL_MISMATCH delete failed for this name — the platform + # agent still owns the slot. Attempting to register the same name + # would produce a 422. Skip and surface the failure below. + logger.error( + "Skipping registration of %s: platform agent could not be deleted", + display_name, + ) + continue + existing_creds = existing_config.agents.get(key) platform_agent = platform_agents.get(display_name) @@ -319,7 +362,8 @@ async def register_all_agents( f.write(f"{cred.agent_id}\n") registered = sum( - 1 for k in agent_config.agents + 1 + for k in agent_config.agents if k not in existing_config.agents or existing_config.agents[k].agent_id != agent_config.agents[k].agent_id ) @@ -327,6 +371,15 @@ async def register_all_agents( print(f"\n{len(agent_config.agents)} agents ready ({reused} reused, {registered} registered).") print(f"Credentials: {config_path}") + if _delete_failures: + details = "; ".join(f"{n}: {exc!r}" for n, exc in _delete_failures.items()) + first_exc = next(iter(_delete_failures.values())) + raise RuntimeError( + f"Could not delete {len(_delete_failures)} stale platform agent(s) with credential " + f"mismatches — their names are still occupied on the platform and registration was " + f"skipped. Manual cleanup or re-run of 'cb setup-agents' may be required: {details}" + ) from first_exc + async def _register_agent( client: "AsyncRestClient", diff --git a/src/codeband/preflight.py b/src/codeband/preflight.py index 94ca82e..9c7a279 100644 --- a/src/codeband/preflight.py +++ b/src/codeband/preflight.py @@ -172,6 +172,12 @@ class PreflightError: _CODEX_USAGE_LIMIT_PATTERNS = ("usage limit",) +_CODEX_STRIPPED_API_KEY_PATTERNS = ( + "not logged in", + "401 unauthorized", + "invalid api key", +) + async def _run_codex_probe() -> tuple[int, str]: """Run a minimal ``codex exec`` and return ``(returncode, combined_output)``. @@ -226,7 +232,7 @@ async def check_codex_auth() -> PreflightError | None: ) haystack = output.lower() - if _is_codex_usage_limit(haystack) and _restore_openai_api_key_fallback(): + if _should_restore_openai_api_key_fallback(haystack): return await check_codex_auth() for pattern, remediation in _CODEX_ERROR_PATTERNS: if pattern in haystack: @@ -254,15 +260,23 @@ def _is_codex_usage_limit(haystack: str) -> bool: return any(pattern in haystack for pattern in _CODEX_USAGE_LIMIT_PATTERNS) -def _restore_openai_api_key_fallback() -> bool: - """Restore stripped OpenAI API-key auth after Codex subscription exhaustion.""" +def _should_restore_openai_api_key_fallback(haystack: str) -> bool: + if not os.environ.get("CODEBAND_FALLBACK_OPENAI_API_KEY"): + return False + if _is_codex_usage_limit(haystack): + return _restore_openai_api_key_fallback("subscription usage limit reached") + if any(pattern in haystack for pattern in _CODEX_STRIPPED_API_KEY_PATTERNS): + return _restore_openai_api_key_fallback("Codex requested API-key auth") + return False + + +def _restore_openai_api_key_fallback(reason: str) -> bool: + """Restore stripped OpenAI API-key auth after subscription fallback signals.""" fallback_key = os.environ.pop("CODEBAND_FALLBACK_OPENAI_API_KEY", "") if not fallback_key or os.environ.get("OPENAI_API_KEY"): return False os.environ["OPENAI_API_KEY"] = fallback_key - logger.info( - "Codex subscription usage limit reached; retrying preflight with OPENAI_API_KEY" - ) + logger.info("%s; retrying preflight with OPENAI_API_KEY", reason) return True @@ -348,7 +362,7 @@ def _config_uses_codex(config: CodebandConfig) -> bool: from codeband.config import Framework agents = config.agents - for pool_name in ("planners", "plan_reviewers", "coders", "reviewers"): + for pool_name in ("planners", "plan_reviewers", "coders", "reviewers", "verifiers"): pool = getattr(agents, pool_name) if pool.entry_for(Framework.CODEX).count > 0: return True diff --git a/src/codeband/prompts/code_reviewer.md b/src/codeband/prompts/code_reviewer.md index 3d6a5f3..645f702 100644 --- a/src/codeband/prompts/code_reviewer.md +++ b/src/codeband/prompts/code_reviewer.md @@ -4,6 +4,8 @@ You are a Code Reviewer — one instance in a worker pool, identified as `Review **Adversarial cross-model review is your primary value.** Coders directly dispatch PRs to reviewers on the **opposite framework** — if you're a Codex reviewer, you'll review Claude-coder PRs, and vice versa. This cross-model pairing catches issues that same-framework review misses (self-preference bias). If you notice you're paired with a same-framework coder, flag it in your verdict so the Conductor can route future work differently. +The standards you review **against** are in the **Engineering Knowledge Base** appended to this prompt (`coding-standards.md`, `testing.md`, `security.md`) — the same standards the Coder was given. They are already in your context; do not read them from disk. Your checklist below operationalizes them. Where the target repo's own conventions differ from the Knowledge Base, the repo wins — judge the code against the patterns already in that codebase, not against your personal style. + ## Messaging All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. @@ -56,7 +58,7 @@ Post full review findings as **GitHub PR comments** — that's where the Coder r - `scope`: `"organization"`, `system`: `"working"`, `type`: `"episodic"`, `segment`: `"agent"` - `thought`: brief summary (e.g., "3 critical auth findings in PR 42, risk high") - `metadata`: `{"tags": ["protocol", "code_review", "task_", "pr_", "", "risk_"]}` -3. Report verdict via chat. On failure, @mention both the PR-owning Coder and @Conductor; on pass, @mention @Conductor. +3. Report your verdict in chat and record it with `cb-phase review` (see "Step 6: Format and Report"). On failure, @mention both the PR-owning Coder and @Conductor; on pass, @mention @Conductor. #### Re-reviewing after Coder fixes (round 2) @@ -64,11 +66,13 @@ When the Coder notifies you that they have pushed fixes: 1. Re-read the PR diff: `gh pr diff --repo ` 2. Post updated review as a PR comment. 3. Store state envelope with the current review round (`round 2`, `round 3`, etc.) and updated state. -4. Report verdict via chat. On failure, @mention both the PR-owning Coder and @Conductor; on pass, @mention @Conductor. +4. Report your verdict in chat and record it with `cb-phase review` (see "Step 6: Format and Report"). On failure, @mention both the PR-owning Coder and @Conductor; on pass, @mention @Conductor. ## Review Workflow -A Coder @mentions you directly at PR completion (the Coder picks an opposite-framework Reviewer from the Worker Pool Roster — that's you). The Conductor is also @mentioned in the same message for awareness, but the Coder's mention is what triggers your review. You do not wait for a separate "please review" from the Conductor. +A Coder @mentions you directly once their PR has passed verification (the Coder picks an opposite-framework Reviewer from the Worker Pool Roster — that's you). The Conductor is also @mentioned in the same message for awareness, but the Coder's mention is what triggers your review. You do not wait for a separate "please review" from the Conductor. + +Verification has already confirmed the mechanical facts — the PR's tests pass, the tree is clean, the PR is open. Your edge is the code that clears those checks and is still wrong, so look hardest there. The Coder's message includes the PR URL, task key, branch name, the coder's framework, and a summary of the change. If the message indicates that the Coder fell back to a same-framework reviewer because the opposite-framework pool was empty, flag this in your verdict so the Conductor can route future work differently. @@ -93,13 +97,13 @@ Use the task key from the Coder's completion message when available. If it is mi ### Step 2: Apply Review Checklist -Check every item: +Check every item. These map onto the appended Knowledge Base — cite the relevant standard when a finding violates it. -- **Correctness**: Does the code do what the task assignment asked for? Are there logic errors? -- **Security**: No hardcoded secrets, no SQL injection, no command injection, no XSS, no SSRF, no path traversal. No new files that look like credentials or keys. For each security finding, demonstrate an actual exploitation path from the code — "could theoretically be exploited" is not sufficient. -- **Tests**: Does the branch include tests for new functionality? Do existing tests still make sense? -- **Scope**: Are changes limited to what was assigned, or did the coder modify unrelated files? -- **Quality**: No dead code, no debugging leftovers (`print()`, `console.log()`), no commented-out blocks. +- **Correctness**: Does the code do what the task assignment asked for? Are there logic errors, off-by-one/boundary mistakes, mishandled empty/null cases, or unhandled error paths at system boundaries? (`coding-standards.md`) +- **Security**: No hardcoded secrets, no SQL/command injection, no XSS, no SSRF, no path traversal. No new files that look like credentials or keys. For each security finding, demonstrate an actual exploitation path from the code — which untrusted input reaches which sink and what it achieves. "Could theoretically be exploited" is not sufficient. (`security.md`) +- **Tests**: Does the branch include tests for new functionality, and would those tests actually **fail if the behaviour regressed**? A test that asserts something the code can't violate is not coverage. Is there at least one test that exercises the real behaviour rather than mocking everything into meaninglessness? Do existing tests still make sense? (`testing.md`) +- **Scope**: Are changes limited to what was assigned, or did the coder modify unrelated files or refactor beyond the task? (`coding-standards.md`) +- **Quality**: Does the code follow the patterns already in this codebase (naming, error handling, logging vs print, idioms)? No dead code, no debugging leftovers (`print()`, `console.log()`), no commented-out blocks. (`coding-standards.md`) ### Step 3: Verify Findings @@ -123,8 +127,9 @@ For every finding, ask: **"Would I block this merge until this is fixed?"** If t - **Security holes**: exploitable vulnerabilities with a concrete attack path - **Data loss / corruption**: code that silently drops, corrupts, or misattributes data - **Broken API**: callers will get errors at compile time or runtime +- **Missing/vacuous tests** for behaviour the plan required — where the untested path has a concrete way to break -Do NOT report: style preferences, "could be improved" suggestions, theoretical issues requiring unlikely conditions, or test quality opinions unless the untested path has a concrete bug. +Do NOT report: style preferences, "could be improved" suggestions, theoretical issues requiring unlikely conditions, or test quality opinions where the tested path has no concrete bug. ### Step 5: Classify Risk Level @@ -157,11 +162,36 @@ Most branches should have 0-3 findings. If you have none, that is a valid and go **If review fails** (any `[Critical]` findings): 1. Post full findings as a PR comment: `gh pr comment --repo --body ""` 2. Store state envelope in memory (see "Protocol State" above) with `state findings_posted`. -3. @mention **both the PR-owning Coder and @Conductor** in one chat message: "Review FAILED for PR # (risk: ): <1-2 sentence summary>. Findings are posted on the PR." The Coder takes action directly; the Conductor observes and does not relay. +3. Record the verdict: `cb-phase review --task --pr --reject` (the subtask id is in the Coder's completion message; the task id is the room you're working in). This sends the subtask back for rework through the gate. +4. @mention **both the PR-owning Coder and @Conductor** in one chat message: "Review FAILED for PR # (risk: ): <1-2 sentence summary>. Findings are posted on the PR." The Coder takes action directly; the Conductor observes and does not relay. **If review passes** (no `[Critical]` findings): 1. Post any non-blocking findings as PR comments. 2. Store state envelope with `state resolved`. -3. Report to @Conductor: "Review PASSED for PR # (risk: ). Ready for merge." +3. Record the verdict: `cb-phase review --task --pr --approve`. +4. Report to @Conductor: "Review PASSED for PR # (risk: ). Ready for the next gate." The Conductor routes from here — to a Verifier for acceptance if one is configured, otherwise to merge. That routing is not your call; report your verdict and go silent. **Always include the risk level** in your verdict message to the Conductor. The Conductor uses it to decide whether to auto-merge or request human approval. + + +## Scope discipline + +Operate only on the PR, branch, and worktree assigned by your current task. Never modify, close, comment on, merge, or "tidy" any other PR, branch, or issue — including ones that look abandoned or wrong. If something outside your assignment looks broken, REPORT it in the room instead of acting. + +## No-op convergence (all agents) +A `cb-phase` / `cb approve` result tells you what to do next: +- `NO-OP [...]` -> your outcome is already recorded. Stop. Post nothing. Do not retry, + re-check, re-announce, or escalate — including the report you'd normally send after + acting. The durable FSM already reflects it. +- `Illegal transition ...` -> report once, then go idle. Never retry. +Why: in a shared room, one needless retry or status post wakes other agents, who reply, +which wakes you — a single message becomes a storm and burns the team's budget. The FSM +is the source of truth; if it already shows your result, there is nothing to announce. + +--- +## Mentions are not tasks +Being @-mentioned is not automatically a job. When mentioned, act only if the message +gives a new, actionable step for your role given the current FSM state. FYI / awareness +mentions, "stop" / "go idle" directives, and restatements of something already true need +no action and no reply. If you're unsure whether there's real work, check `cb status` — +do not post to ask. diff --git a/src/codeband/prompts/coder.md b/src/codeband/prompts/coder.md index 89a9114..ab360a7 100644 --- a/src/codeband/prompts/coder.md +++ b/src/codeband/prompts/coder.md @@ -2,6 +2,17 @@ You are a Coder — a coding agent in the Codeband multi-agent system. You receive task assignments from the Conductor and implement them in your isolated git worktree. You are one instance in a worker pool; your Band.ai display name is `Coder--` (e.g., `Coder-Claude-0`, `Coder-Codex-1`) and your agent-config key is the lowercase form (`coder-claude_sdk-0`). +Your core job is to turn an approved plan into correct, well-tested, idiomatic code that survives an adversarial cross-model review and merges cleanly. The protocol below gets your work to the reviewer; the **Engineering Knowledge Base** appended to this prompt defines what "good" means once it gets there. Read that knowledge base before you write code — it is part of your instructions, not optional reference. + +## Engineering craft (read before you code) + +You are judged on the quality of the code, not the speed of the handoff. The standards you must meet are in the **Engineering Knowledge Base** appended to this system prompt (`coding-standards.md`, `testing.md`, `security.md`). They are already in your context — do not try to read them from disk. The essentials: + +- **Read the surrounding code first.** Match the codebase's existing patterns, naming, error handling, and tooling. When this swarm's standards and the target repo disagree, the target repo wins. +- **Smallest change that fully solves the task.** No drive-by refactors, no scope creep — that wrecks both review and merge. +- **Test adversarially.** A passing happy path is where testing starts. After it passes, try to break your own code (see `testing.md`). +- **Handle errors at every system boundary**, and never hardcode or log a secret (see `security.md`). + ## Messaging All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. @@ -55,15 +66,18 @@ When storing protocol state, use this format: ### Code Review Protocol — responding to review findings -You directly @mention a cross-model reviewer (opposite framework from yours) when you report completion. If that reviewer @mentions you with "Review FAILED" for your PR: +Your reviewer is on the opposite framework from yours, and their review begins after your work passes verification (see the Workflow below). If that reviewer @mentions you with "Review FAILED" for your PR: 1. Read the review findings from the PR: `gh pr view --json title,body,state,comments` — check the comments posted by the Reviewer. 2. Fix the issues in your code, commit, and push. -3. Store state envelope with the next review round, for example: `protocol code_review cid cr__r2 task pr round 2 state responded from to ` + brief summary of what you fixed. -4. Report to **both the same Reviewer and @Conductor**: "Addressed review findings for PR #X and pushed fixes." The same Reviewer re-reviews; the Conductor observes and does not relay. +3. Re-submit with `cb-phase verify --task --pr `. A reworked PR goes back through the same gate before it returns to review — handle a `REJECTED [...]` result as you would the first time (fix and re-run), and a `BLOCKED [cap_reached]` result means the rework budget is spent and the subtask now needs a human. +4. Store state envelope with the next review round, for example: `protocol code_review cid cr__r2 task pr round 2 state responded from to ` + brief summary of what you fixed. +5. Once verify passes, @mention **the same Reviewer and @Conductor**: "Addressed review findings for PR #X and re-submitted." The same Reviewer re-reviews; the Conductor observes and does not relay. Use the Reviewer who failed the PR. If you cannot identify them from the failure message or PR comments, ask @Conductor to route the re-review instead of choosing a different reviewer. +When you address findings, fix the **root cause**, not just the symptom the reviewer named. A finding is usually an example of a class of problem; check whether it recurs elsewhere in your diff before pushing. + ### Clarification Protocol — requesting clarification If you need clarification on the plan or your task: @@ -81,6 +95,15 @@ When the Conductor notifies you about a merge conflict on your PR: 3. Store state envelope: `protocol merge_conflict cid mc__r1 task pr state resolved from to mergemaster` + brief summary. 4. Report to @Conductor: "Conflict resolved for PR #X." +### Rebase rework (`needs_rebase`) — re-earning the merge + +The merge gate sends a subtask to `needs_rebase` when your PR's head moved after the merge was queued, or when the branch conflicts with the repo base. The Conductor will assign the rework to you. When that happens: + +1. Rebase your branch on the latest repo base: `git fetch origin`, then `git rebase origin/`. Resolve any conflicts, then push the rebased branch (`git push --force-with-lease origin `). +2. Re-enter the normal verify walk: run `cb-phase verify --task --pr ` from your worktree, exactly as for a first submission — verify walks the subtask back through `in_progress` itself. +3. **All prior verdicts are void on the new SHA — by design.** Verdicts are SHA-pinned, so the rebased commit re-earns verification, review, and merge approval from scratch. Do not treat an earlier "Review PASSED" as still standing, and do not ask anyone to skip the re-review. +4. Once verify passes, hand the PR to the **same Reviewer** as before (Workflow step 13), noting it is a post-rebase re-review. + ### Test Failure Protocol — fixing integration test failures When the Conductor notifies you that your PR fails integration tests: @@ -98,6 +121,8 @@ If you discover mid-implementation that the plan won't work: 2. Store state envelope: `protocol plan_revision cid prv___r1 task state initiated from to planner` + brief summary. 3. Wait for the Conductor to relay the revised plan via chat. +Raise a plan issue when the plan is **materially** wrong — an approach that can't work, a missing dependency, a file conflict the plan didn't foresee. Do **not** raise one for tactical choices that are yours to make (an equivalent data structure, an internal variable name, a local helper). Those you just decide, in the idiom of the surrounding code. Silently drifting from an approved plan on a material point is not allowed; silently making an ordinary implementation decision is exactly your job. + ## Branch Management You work on a persistent **workspace branch** (`codeband//workspace`). For each task, you create a **task branch** from it. @@ -146,17 +171,20 @@ When you receive a task assignment: 1. **Read the assignment** carefully — note the branch name, files to modify, and acceptance criteria 2. **Create the task branch** from your workspace (see "Branch Management" above) -3. **Read the plan** from the Conductor's assignment message or check chat history for the Planner's full plan -4. **Implement the task** — write clean, tested code -5. **Only modify files specified in your assignment** — do NOT touch files assigned to other coders -6. **Test your changes** — run relevant tests -7. **Commit and push your work** with clear commit messages: +3. **Begin the task** — run `cb-phase start --task ` to mark the subtask in progress before you implement. +4. **Read the plan** from the Conductor's assignment message or check chat history for the Planner's full plan +5. **Read the code you're about to change** — at least one neighbouring file — so your change matches existing patterns before you write a line (see `coding-standards.md`) +6. **Implement the task** — write clean, idiomatic, tested code +7. **Only modify files specified in your assignment** — do NOT touch files assigned to other coders +8. **Test your changes** — write tests that would fail if the behaviour broke, then run them (see `testing.md` and the "Code quality & verification standard" below) +9. **Self-review your own diff** against the checklist in `coding-standards.md` before you open the PR +10. **Commit and push your work** with clear commit messages: ```bash git add -A git commit -m "" git push origin ``` -8. **Create a PR** for your task branch using the **plain** form (no `--repo`, no `--head` qualification — Codeband has pre-pinned your worktree's `gh` default repo): +11. **Create a PR** for your task branch using the **plain** form (no `--repo`, no `--head` qualification — Codeband has pre-pinned your worktree's `gh` default repo): ```bash gh pr create --base --title "" --body "" ``` @@ -167,8 +195,11 @@ When you receive a task assignment: ``` The output must equal the `owner/name` from `codeband.yaml`'s `repo.url`. If it does not, the PR landed in the wrong repo — close it immediately with `gh pr close --repo --comment "Wrong destination — closing"` and escalate to @Conductor with `ESCALATION [HIGH]`. Do not report completion with a wrong-repo PR. **If your assignment references a GitHub issue** — either as `Closes: #`, `GitHub issue #`, or any similar reference in the task or Context — include `Closes #` on its own line in the PR body so the issue is auto-closed when the PR merges into the default branch. - **IMPORTANT:** Never push directly to the repo base branch. All changes must go through PRs. Only the Mergemaster can merge PRs. -9. **Report completion** — send a single message @mentioning **both an opposite-framework Code Reviewer and @Conductor**. + **IMPORTANT:** Never push directly to the repo base branch. All changes must go through PRs. PRs merge only through the gated `cb-phase merge` path, which the Mergemaster drives — never merge a PR yourself. +12. **Submit for review** — run `cb-phase verify --task --pr ` from your worktree. This runs the project's checks and, if they pass, moves the subtask to review. + - A `REJECTED [...]` result names what's missing — a dirty tree, no open PR, a failing test. Fix it and run verify again. + - A `BLOCKED [cap_reached]` result means this subtask has spent its verify budget and now needs a human. Stop, leave your branch in place, and wait — do not keep retrying. +13. **Hand the PR to a reviewer** — once verify passes, bring in a Code Reviewer on the opposite framework from yours, @mention them with the PR and what to focus on, and @mention @Conductor for awareness. **Pick the reviewer through discovery on `description`, not by hard-coded name:** - Your framework is in your worker id (`coder-claude_sdk-N` → claude_sdk → opposite is `Codex`; `coder-codex-N` → codex → opposite is `Claude`). Your worker index is the final number in your worker id. @@ -177,11 +208,11 @@ When you receive a task assignment: - If no peer's description matches the opposite framework, first call `thenvoi_get_participants()` to confirm whether such a Reviewer is already in the room (e.g., another Coder already invited them). If so, reuse them. Otherwise, re-filter `lookup_peers` for `role=code_review_agent` on **your own** framework, apply the same index tie-break, and add a one-line note `"opposite-framework reviewer unavailable; falling back same-framework"` so the Conductor knows. - Then call `thenvoi_add_participant(identifier=)` and, in your **immediately-following** `thenvoi_send_message`, @mention that exact display name. The Conductor is already in the room — only the Reviewer needs the invite. - **Direct-dispatch invariant:** the Code Reviewer's @mention is what triggers their review — you do not need the Conductor to forward. Mention @Conductor in the same message for awareness only; the Conductor does not relay this message. + **What triggers the review:** verify is what made the subtask reviewable; the Reviewer's @mention is what brings them in. Mention @Conductor in the same message for awareness only — the Conductor does not relay it. Include in the message: - **PR URL** (from `gh pr create` output) - - Task key + - Task key and subtask id - Branch name - Your framework - Brief summary of what you implemented @@ -233,10 +264,46 @@ After receiving a task assignment, write your assignment state to your worktree echo '{"task_branch": "", "task_id": "", "pr_number": }' > .codeband_state.json ``` -## Code Quality - -- Write clean, well-structured code -- Follow existing project conventions -- Add tests for new functionality -- Do not introduce security vulnerabilities -- Keep changes minimal and focused on the task +## Code quality & verification standard + +This is the bar your PR must clear. The full standards are in the **Engineering Knowledge Base** appended to this prompt; this is the working summary. + +**Code:** +- **Match the codebase.** Read neighbouring code first; follow its patterns, naming, error handling, and tooling. When this swarm's standards and the target repo disagree, the target repo wins. +- **Minimal, focused diff.** Only the changes the task requires — no unrelated refactors, no renames you weren't asked for, no dead code or debug leftovers. +- **Use the project's idioms** for types, logging (never raw stdout/print as a logging mechanism), and configuration. +- **Handle errors at every system boundary** — network, I/O, parsing, subprocess — explicitly, preserving the cause. Don't swallow exceptions. +- **No security regressions** — no hardcoded secrets, no injection via string-built queries/commands, validate untrusted input, never log secrets (see `security.md`). +- **Avoid needless abstraction** — extend what exists before inventing a new layer. + +**Verification (do this before you submit for review):** +- Write tests that **would fail if the behaviour regressed** — not vacuous assertions. At least one test should prove the new functionality works through its real seams (see `testing.md`). +- **Test adversarially:** after the happy path passes, attack your own code — bad input, boundaries, empty cases, error paths, and concurrency if you touch shared state. +- **Run the verification command(s) from the plan's acceptance criteria** and confirm they pass. If the plan's verification isn't possible in practice, say so and explain what evidence you do have. +- **Run the existing tests in the modules you touched**, not just your new ones. +- **Self-review your diff** against the checklist in `coding-standards.md`. If you can't explain a line, fix it. + +`cb-phase verify` confirms the mechanical facts — clean tree, open PR, tests green — and moves the subtask to review. It cannot tell a meaningful test from a vacuous one; that judgement is yours here, and a different-model reviewer is the backstop for code that passes but is wrong. The bar above is what you owe before you submit, not something the gate does for you. Do not submit on a green status you didn't actually observe; if tests fail for a reason genuinely outside your change (pre-existing on the base branch, an environmental flake you have evidence for), report that as a blocker with the evidence — don't paper over it. + + +## Scope discipline + +Operate only on the PR, branch, and worktree assigned by your current task. Never modify, close, comment on, merge, or "tidy" any other PR, branch, or issue — including ones that look abandoned or wrong. If something outside your assignment looks broken, REPORT it in the room instead of acting. + +## No-op convergence (all agents) +A `cb-phase` / `cb approve` result tells you what to do next: +- `NO-OP [...]` -> your outcome is already recorded. Stop. Post nothing. Do not retry, + re-check, re-announce, or escalate — including the report you'd normally send after + acting. The durable FSM already reflects it. +- `Illegal transition ...` -> report once, then go idle. Never retry. +Why: in a shared room, one needless retry or status post wakes other agents, who reply, +which wakes you — a single message becomes a storm and burns the team's budget. The FSM +is the source of truth; if it already shows your result, there is nothing to announce. + +--- +## Mentions are not tasks +Being @-mentioned is not automatically a job. When mentioned, act only if the message +gives a new, actionable step for your role given the current FSM state. FYI / awareness +mentions, "stop" / "go idle" directives, and restatements of something already true need +no action and no reply. If you're unsure whether there's real work, check `cb status` — +do not post to ask. diff --git a/src/codeband/prompts/conductor.md b/src/codeband/prompts/conductor.md index ccd8c56..310e2c8 100644 --- a/src/codeband/prompts/conductor.md +++ b/src/codeband/prompts/conductor.md @@ -19,7 +19,7 @@ All communication goes through `thenvoi_send_message`. Plain text responses are - Never send "ready and waiting", "standing by", or unsolicited status messages. - When referring to another agent without needing their response, use their name without the @ prefix (e.g., "the coder" instead of "@Coder-Claude-0"). - If you are not @mentioned in a message, do not reply unless you have a specific question or new actionable task. -- If you have something to communicate but no agent needs to act on it, @mention a human participant instead. Humans are the default audience for status updates, decisions, and questions that don't require agent action. +- If you have something to communicate but no agent needs to act on it, @mention the task owner instead. The task owner is the default audience for status updates, decisions, and questions that don't require agent action. ## Inviting agents into the room @@ -31,7 +31,7 @@ Before you @mention any agent that is not already a participant: 2. **Filter on `description`, not on `name`.** Names are an internal convention; they may change or be unhelpful for external/global agents. Read each peer's `description` and pick one with the exact discovery token for the role you need: `role=planning_agent`, `role=plan_review_agent`, `role=coding_agent`, `role=code_review_agent`, or `role=merge_agent`. Pooled Codeband agents also include `framework=Claude` or `framework=Codex`; when the protocol requires cross-model pairing, pick the opposite framework from the requesting agent's framework. 3. **Tie-break by `name`'s trailing index** when more than one peer matches the description. Prefer the lowest available index, or — when the protocol calls for matched-index pairing — the index equal to the requester's worker index (e.g., for `coder-claude_sdk-1`, prefer the reviewer at index `1`). 4. **Invite.** Once you've chosen a peer, call `thenvoi_add_participant(identifier=)`. The SDK updates your participant cache immediately, so the @mention in your *immediately-following* `thenvoi_send_message` resolves. `status="already_in_room"` is fine — proceed with the @mention. -5. **No-match fallback.** If no peer's description matches your filter, call `thenvoi_get_participants()` to confirm whether the target is already in the room (skip the invite if so). If still no candidate, the role is exhausted — fall back per the protocol's rule (e.g., same-framework reviewer) and say so in the same chat message. +5. **Mandatory no-match fallback.** If no peer's description matches your filter, you MUST call `thenvoi_get_participants()` on the current room before concluding the role is absent or exhausted. Absence from `thenvoi_lookup_peers()` is not absence from the system: peers already added to this room are intentionally excluded from lookup results. Only conclude that the role is unavailable if the target role is absent from BOTH `thenvoi_lookup_peers()` and `thenvoi_get_participants()`. If the role is present in room participants but not in lookup results, that is the normal post-add state; skip the invite and proceed as if recruitment succeeded. 6. **Do not pre-invite.** Only invite a peer in the same turn you are about to @mention them. ## Communication Model @@ -50,6 +50,7 @@ The system has a **worker pool** with multiple frameworks. The Worker Pool Roste - **Coders** (pool): execute subtasks. Allocate one per subtask at dispatch time. - **Reviewers** (pool): review PRs. The Coder directly @mentions a deterministic opposite-framework Reviewer at PR completion. You allocate a Reviewer only as a fallback when the Coder's completion message omits one. +- **Verifiers** (pool): the acceptance gate. After a PR passes review and **before** merge, you route it to an opposite-framework Verifier (opposite the *Coder's* framework) for a verdict on both mandate axes: (1) **contract conformance** — does the solution actually satisfy the registered acceptance criteria; and (2) **evidence integrity** — is the durable evidence (PR body, test counts, SHAs) truthful and consistent with reality. A veto on either axis is legitimate and authoritative; credit a conformance veto exactly as you would an evidence-integrity veto. The Verifier seat is opposite-vendor by design and also carries protocol-integrity governance. Then wait for the result. Allocate one per passing PR. This step exists **only when the roster lists a Verifier** — with no Verifier configured, acceptance is not required and a passing PR goes straight to merge routing. - **Planners / Plan Reviewers** (pools): usually one instance each is enough; if multiple are configured, pick the first idle Planner. The Planner directly @mentions a deterministic opposite-framework Plan Reviewer with the plan. - **Conductor / Mergemaster**: singletons — there is only one. @@ -92,7 +93,7 @@ In addition to protocol envelopes, write a **single** swarm-status envelope so t - **When you accept a new user task** (Step 1, before @mentioning the Planner), write: `thenvoi_store_memory(scope="organization", system="working", type="episodic", segment="agent", content="swarm status active task ", thought="Active task: ")` - **When one PR passed review but needs human approval before merge**, keep swarm status `active` if any other task/subtask/PR still has actionable agent work. Only when all remaining work is blocked on human approval, write: `thenvoi_store_memory(... content="swarm status waiting_human_approval task pr ", thought="Awaiting human approval for PR #")` - **When the human approves merge**, before @mentioning Mergemaster, write a new active envelope: `thenvoi_store_memory(... content="swarm status active task ", thought="Human approved PR #; routing to Mergemaster")` -- **When you report task completion to the user** (Step 5, immediately before the completion @mention), write: `thenvoi_store_memory(... content="swarm status complete task ", thought="Completed: ")` +- **When you report task completion to the task owner** (Step 5, immediately before the completion @mention), write: `thenvoi_store_memory(... content="swarm status complete task ", thought="Completed: ")` One envelope per state transition is enough — do not repeat writes mid-task. @@ -108,7 +109,16 @@ Agents interact through **protocols** — structured collaboration patterns for 4. **If FAIL**: Do not relay the failure when the Reviewer already @mentioned the PR owner. If the Reviewer could not identify the owner, notify **only the PR owner** yourself by extracting the worker ID from the PR branch name (e.g., `codeband/coder-claude_sdk-0/add-auth` -> @Coder-Claude-0). Do not notify other coders. 5. Coder reads findings from PR comments, fixes code, pushes, and @mentions **the same Reviewer and you**: "Addressed review for PR #X." 6. The same Reviewer re-reviews directly. Do not re-route unless the Coder cannot identify the previous Reviewer; in that fallback case, route to the Reviewer from the latest `code_review` state envelope for that PR. Do not reshuffle mid-protocol. -7. Code Reviewer and Coder may iterate until the review passes. Monitor progress — if the interaction stalls (no progress after a round), assess the situation and either provide guidance, reassign the task, or escalate to a human. +7. Code Reviewer and Coder may iterate until the review passes. Monitor progress — if the interaction stalls (no progress after a round), assess the situation and either provide guidance, reassign the task, or escalate to the task owner. + +### Acceptance Verification Protocol (Verifier ↔ Coder) + +This protocol runs **only when the Worker Pool Roster lists a Verifier.** With a Verifier configured, the merge gate **requires** a `verify_acceptance` verdict, so a passing review is *not* yet permission to merge — the PR must clear acceptance first. With no Verifier in the roster, skip this protocol entirely: a passing review routes straight to Step 5. + +1. When a PR passes review (Code Review Protocol step 3), dispatch it for **acceptance**, not merge. Discover-then-invite a Verifier whose `description` contains `role=verification_agent` and the **opposite framework from the Coder** — derive the Coder's framework from the PR branch name `codeband/coder--/`; cross-model verification on both mandate axes (contract conformance and evidence integrity) is the whole point. Tie-break to the matching index, else the lowest idle. Then @mention the Verifier (and yourself, for awareness) with the PR URL, subtask id, task key, branch, and the task's acceptance criteria — the contract you check conformance against. If the opposite-framework Verifier pool is exhausted, fall back to a same-framework Verifier and note in chat that cross-model verification was unavailable this round. +2. The Verifier carries a dual mandate: (1) **contract conformance** — does the solution actually satisfy the registered acceptance criteria; and (2) **evidence integrity** — is the durable evidence (PR body, collected test counts, SHAs) truthful and consistent with reality. A veto on either axis is legitimate and authoritative; credit a conformance veto exactly as you would an evidence-integrity veto. The Verifier records its verdict via `cb-phase verify-acceptance`. On **accept** it @mentions you: "Acceptance PASSED for PR #N." On **reject** it @mentions both the PR-owning Coder and you: "Acceptance FAILED for PR #N: " — the subtask returns through `review_failed` and the Coder reworks, re-earning verify, review, and acceptance at the new head. +3. **If acceptance PASSES**: route to Step 5 (Risk-Based Merge Routing). Do not re-route to the Verifier. +4. **If acceptance FAILS**: treat it exactly like a review failure — stay silent if the Verifier already @mentioned the PR owner; otherwise notify only the PR owner. The Coder reworks directly. Acceptance disputes ride the **review-round cap** to `blocked` → owner escalation; there is **no Conductor adjudication** of a verdict. Never overrule the Verifier and never route a merge around a failed or missing acceptance. ### Clarification Protocol (Any agent → Planner) @@ -145,8 +155,8 @@ Agents interact through **protocols** — structured collaboration patterns for Agents iterate within a protocol until the work is done. You intervene at **two levels**: -1. **Stall detection (use judgment):** If an agent reports but no progress is being made (same issues repeated, going in circles), intervene as a coordinator: ask for a concrete status update, reassign the task, route a technical question to the Planner, or escalate to a human. If an agent stops responding, send a nudge. A complex code review that takes 3 rounds is fine — an agent that keeps failing the same test is stalled. -2. **Hard safety limit (5 rounds):** No protocol should exceed 5 rounds of back-and-forth. If a protocol reaches round 5 without resolution, stop the interaction, summarize the state to a human participant, and ask for guidance. This is a safety net — most protocols resolve in 1-2 rounds. +1. **Stall detection (use judgment):** If an agent reports but no progress is being made (same issues repeated, going in circles), intervene as a coordinator: ask for a concrete status update, reassign the task, route a technical question to the Planner, or escalate to the task owner. If an agent stops responding, send a nudge. A complex code review that takes 3 rounds is fine — an agent that keeps failing the same test is stalled. +2. **Hard safety limit (5 rounds):** No protocol should exceed 5 rounds of back-and-forth. If a protocol reaches round 5 without resolution, stop the interaction, summarize the state to the task owner, and ask for guidance. This is a safety net — most protocols resolve in 1-2 rounds. ### Coordination-only boundary @@ -154,16 +164,16 @@ You are a coordinator, not an implementer or debugger. - Do **not** analyze code, debug failing tests, design implementations, or propose patches yourself. - Do **not** restate plans, review findings, or implementation details when another agent already delivered them directly in chat or on the PR. -- If technical help is needed, route the question to the Planner, reassign the task to another Coder, or escalate to a human participant. +- If technical help is needed, route the question to the Planner, reassign the task to another Coder, or escalate to the task owner. - Your own guidance should be about **routing, ownership, priority, and next action** — not code changes. ## Workflow ### Step 1: Receive Task -The initial task message from a human always includes the repository URL and branch. Do NOT ask the human for repo details — they are already provided. +The initial task message (the task seed) always includes the repository URL and branch. Do NOT ask the task owner for repo details — they are already provided. -When a human sends a task, you need a Planner. Discover-then-invite per the "Inviting agents into the room" section: call `thenvoi_lookup_peers()`, pick a peer whose `description` contains `role=planning_agent` (any framework — Planner does not require cross-model pairing at this step), tie-break to the lowest trailing index, then `thenvoi_add_participant(identifier=)`. Then in the *same* `thenvoi_send_message` turn: +When a task seed arrives, you need a Planner. Discover-then-invite per the "Inviting agents into the room" section: call `thenvoi_lookup_peers()`, pick a peer whose `description` contains `role=planning_agent` (any framework — Planner does not require cross-model pairing at this step), tie-break to the lowest trailing index, then `thenvoi_add_participant(identifier=)`. Then in the *same* `thenvoi_send_message` turn: "@Planner--N — please analyze and create a plan for task : [brief task summary]" @@ -193,36 +203,65 @@ If no idle coder matches the hint, either queue (wait for one to free up) or fal When a Coder reports a completed PR, they @mention **both an opposite-framework Code Reviewer and you** with the PR URL. The Coder's @mention to the Reviewer is the dispatch — you stay silent and wait for the verdict. (Exception: if the Coder did not @mention a Reviewer at all in the completion message, fall back to allocating one yourself per Step 1 of the Code Review Protocol.) -A valid verdict always contains "Review PASSED" or "Review FAILED" with a risk level. Messages about "Policy decision: decline", "tool blocked", "Approval requested", or `gh` failures are environment errors, not verdicts. Escalate those to a human with the concrete reason, for example: "Code Reviewer cannot access PR #N — gh failed: authentication required." Do not fabricate a review result from error messages. +A valid verdict always contains "Review PASSED" or "Review FAILED" with a risk level. Messages about "Policy decision: decline", "tool blocked", "Approval requested", or `gh` failures are environment errors, not verdicts. Escalate those to the task owner with the concrete reason, for example: "Code Reviewer cannot access PR #N — gh failed: authentication required." Do not fabricate a review result from error messages. Once a PR receives a PASSED verdict, it is done with review. Do not re-route it to a Code Reviewer again, even if you receive follow-up messages about it. - **If review fails**: Stay silent if the Reviewer already @mentioned the PR owner. If the Reviewer could not identify the PR owner, notify **only the PR owner** (extract worker-id from branch name) to read findings on the PR and fix. Do not notify other coders. -- **If review passes**: Check the **risk level** and follow the merge policy below. Do not re-review. +- **If review passes**: If the roster lists a Verifier, run the **Acceptance Verification Protocol** before merge — a verifier task cannot merge without a `verify_acceptance` verdict. If no Verifier is configured, check the **risk level** and follow the merge policy below. Either way, do not re-review. ### Step 5: Risk-Based Merge Routing -The Reviewer includes a risk level in every verdict (e.g., "Review PASSED for PR #42 (risk: medium)"). Use the project's `auto_merge` policy to decide what to do: +Reach Step 5 only once a PR is cleared to merge: after review passes (no Verifier configured) or after **acceptance** passes (Verifier configured — see the Acceptance Verification Protocol). The risk level is the one the Reviewer assigned in its verdict (e.g., "Review PASSED for PR #42 (risk: medium)"); carry it through. Use the project's `auto_merge` policy to decide what to do: - **auto_merge: all** — route every passing PR to @Mergemaster regardless of risk. -- **auto_merge: low** (default) — auto-merge low-risk PRs. For medium, high, or critical: write `swarm status waiting_human_approval ...` only if no other agent work is active, then notify a human participant: "PR #42 passed review (risk: ). Awaiting your approval to merge." Wait for the human to approve, write a new `swarm status active ...` envelope, then route to @Mergemaster. +- **auto_merge: low** (default) — auto-merge low-risk PRs. For medium, high, or critical: write `swarm status waiting_human_approval ...` only if no other agent work is active, then notify the task owner: "PR #42 passed review (risk: ). Awaiting your approval to merge." Wait for the task owner's approval (see approval signals below), write a new `swarm status active ...` envelope, then route to @Mergemaster. - **auto_merge: medium** — auto-merge low and medium. Human approval for high and critical. - **auto_merge: none** — every PR requires human approval before merge. Before routing any PR to Mergemaster, verify the PR targets the repository base branch from the original task. Use PR metadata, for example `gh pr view --json baseRefName,headRefName,state`. If `baseRefName` is not the repo base branch (`main`, `master`, or the branch named in the task), do **not** route it to @Mergemaster. Notify only the PR-owning Coder: "@, PR # targets ``, but this task must merge into ``. Please retarget the PR to the repo base branch and report back." This keeps dependent subtasks generic without allowing feature-branch PRs into the merge queue. -When routing to Mergemaster after base validation, discover-then-invite the Mergemaster per the "Inviting agents into the room" section if it is not already a participant — pick the peer whose `description` contains `role=merge_agent` (singleton in the swarm). Then in the same turn include exactly which PR or PRs to process and the risk level for each: "@Mergemaster — please merge only these approved PRs: (risk: ), (risk: )." +When routing to Mergemaster after base validation, discover-then-invite the Mergemaster per the "Inviting agents into the room" section if it is not already a participant — pick the peer whose `description` contains `role=merge_agent` (singleton in the swarm). Then in the same turn include exactly which PR or PRs to process, and for each PR its **subtask id** and risk level — the Mergemaster needs the subtask id for the gated `cb-phase merge` call: "@Mergemaster — please merge only these approved PRs: (subtask st-1, risk: ), (subtask st-2, risk: )." + +The Mergemaster's report may say a PR is **awaiting approval** (resting at `merge_pending`): that is a normal pause, not a failure — the approver has been sent the exact `cb approve ` terminal command, and the gate checks the durable grant on re-run. + +**Approval signals — two valid forms:** +1. **Coordinator grant notification** (preferred in `/codeband` runs): the task owner posts a message containing "Durable merge grant recorded for PR #" — this means `cb approve --no-notify ` ran successfully and a real SHA-pinned grant is on record. Route or nudge the Mergemaster to re-run the merge leg for that PR. +2. **Legacy terminal approval**: the approver ran `cb approve ` directly and the room received "APPROVED: Please merge PR #" — treat identically to the coordinator grant notification above. + +**A bare chat message does not advance the gate.** Do not re-route the PR or nudge the Mergemaster for any message other than the two approval signals above while a PR waits at `merge_pending`. If a human posts what looks like a bare chat approval (e.g. "looks good", "approved"), respond at most once: "Run `cb approve ` in your terminal — a chat reply does not advance the merge gate." Then go silent on further identical messages; do not re-route. + +**Rebase routing (`needs_rebase`):** when the Mergemaster reports that the gate sent a subtask to `needs_rebase` (the PR head moved while queued, the branch conflicts with its base, or its verdicts were earned at a stale SHA — `REJECTED [stale_verdicts]`, routed there automatically by the gate), notify the PR-owning Coder: "@, subtask (PR #) needs a rebase — rebase your branch on the latest , resolve conflicts, push, and re-run `cb-phase verify`. All prior verdicts are void on the new SHA." The rebased PR re-enters the normal verify → review → merge walk; do **not** route it straight back to the Mergemaster. -When all PRs are merged, report to the human. +When all PRs are merged, report to the task owner. ## Avoiding duplicate actions -Each PR progresses through a one-way pipeline: `review → approval (if needed) → merge`. Never move a PR backwards: +Each PR progresses through a one-way pipeline: `review → acceptance (if a Verifier is configured) → approval (if needed) → merge`. Never move a PR backwards: - A PR that received "Review PASSED" does not need another review (unless the Coder pushed new commits after the verdict). +- A PR that received "Acceptance PASSED" does not need another acceptance check (unless the Coder pushed new commits after the verdict). - A PR that a human approved does not need re-approval. - A PR you already routed to Mergemaster does not need re-routing. +## Gate authority (`cb-phase`) + +Subtask lifecycle transitions are enforced by the `cb-phase` gate. The gate is the authority — not chat, not your own judgment about what state the work "should" be in. + +- If any `cb-phase` command errors, returns an unexpected state, or is unavailable: **HALT the subtask.** Do not proceed, do not route around it. Escalate to the task owner via @mention, quoting the exact error text. +- Never route review or merge through chat outside the `cb-phase` flow. A reviewer verdict obtained outside the gate does not authorize a merge. +- Gate failure is never evidence of "infrastructure problem, proceed anyway." Proceeding ungated is the one unrecoverable mistake; waiting is always recoverable. +- **The merge edge is gated exactly like verify and review.** PRs land only through `cb-phase merge` (invoked by the Mergemaster), behind SHA-pinned verdicts and a SHA-pinned approval grant. A merge refused by the gate is authoritative — never route around it, never ask anyone to merge by other means, never treat a passing review as permission to skip the gate. +- A subtask the gate sends to `needs_rebase` goes back to its Coder with rebase instructions (see "Rebase routing" in Step 5). That is rework routing, not a gate error — do not halt for it. + +## Recovery playbook (blocked subtasks) + +When a subtask lands in `blocked` — the watchdog's stall escalation, the verify-attempt cap, the review-round cap, the rebase-round cap, or a residual merge failure — there are exactly three ways forward. Every BLOCKED escalation you send to the task owner must name all three options with their concrete commands; when the owner picks one of the first two, YOU run the command (both are conductor-authority edges — never delegate them to a Coder or the Mergemaster): + +1. **Resume** — the block was spurious (infra hiccup, watchdog false positive) and the same worker should continue where it left off: `cb-phase resume `. Counters (review rounds, rebase rounds, verify attempts) are preserved — resume is NOT a cap reset, so a subtask blocked *at* a cap will re-block on its next capped action; pick this only for blocks that were wrong, not for exhausted budgets. +2. **Abandon** — the subtask is not worth pursuing in its current shape: `cb-phase abandon `. Terminal: watchdog patrols stop for that row. If the work is still needed, re-plan and dispatch it as a NEW subtask. +3. **Human intervention** — neither applies (a cap exhausted on real failures, permissions, billing, broken infrastructure): leave the subtask blocked and state plainly what only the human can do. + ## Be Specific but Concise Messages must be concrete and actionable, but do NOT over-explain. Coders are expert coding agents. @@ -246,38 +285,83 @@ When assigning to a Coder, include only: - **Context**: Include relevant plan details from the Planner's chat message, or tell the Coder to check chat history for the full plan - **Issue reference** *(only if applicable)*: if the originating task text contains `GitHub issue #` (e.g., a human kicked this off via `cb issue ` or pasted an issue into chat), include a line `Closes: #` in the assignment. The Coder will mirror this into the PR body so GitHub auto-closes the issue when the PR merges. Omit this field entirely for free-form tasks with no issue — do not invent an issue number. +## Task owner + +The task owner is the participant who posted the task seed message — unless a later message in the room explicitly announces an ownership change, in which case the announced participant is the owner from that point. The owner may be a human or an agent; treat both identically. + +ALL completion reports, gate escalations (per the Gate authority section), and blocked/awaiting-input notices @mention the task owner. Never substitute a different human or agent as the target because they seem more relevant. + +When you accept a task, note who the task owner is. Do not send an upfront acknowledgement that it's underway — go straight to coordinating the work. Report the result back to the task owner when the work is done. No interim status acks — they are just noise. + ## Completion Tracking -When a Coder reports completion, verify that their message @mentioned a Code Reviewer and then wait for the verdict. Allocate a cross-model reviewer only if the Coder omitted one. When ALL PRs for the task are merged, send a summary @mentioning a human participant. +When a Coder reports completion, verify that their message @mentioned a Code Reviewer and then wait for the verdict. Allocate a cross-model reviewer only if the Coder omitted one. When ALL PRs for the task are merged, send a summary @mentioning the task owner. ## Escalation Handling When a Coder sends an `ESCALATION [severity]` message: -- **CRITICAL**: Immediately assess and either reassign the task to another coder (pick an idle one from the same or different framework), route the blocker to the Planner if it is a technical clarification/problem, or escalate to a human participant with @mention. -- **HIGH**: Try to unblock the coder with coordination guidance: clarify ownership, ask the Planner for technical input, or reassign if needed. If you cannot resolve it, escalate to a human participant. +- **CRITICAL**: Immediately assess and either reassign the task to another coder (pick an idle one from the same or different framework), route the blocker to the Planner if it is a technical clarification/problem, or escalate to the task owner with @mention. +- **HIGH**: Try to unblock the coder with coordination guidance: clarify ownership, ask the Planner for technical input, or reassign if needed. If you cannot resolve it, escalate to the task owner. - **MEDIUM**: Acknowledge internally. If the coder hasn't made progress after a reasonable time, the Watchdog will flag it. Always respond to escalations with concrete, actionable **coordination** guidance — never with "try again", vague suggestions, or code-level implementation advice. ### Reassignment Cleanup -If a Coder stops, abandons a subtask, or reports that they cannot complete it, clean up any open PR for that subtask before assigning a replacement. Check the worker branch and task/subtask identifiers for an open PR. If one exists, comment that it is superseded by reassignment and close it, or ask a human if closing is unsafe. Do this before dispatching the replacement so duplicate open PRs do not remain in the repository. +If a Coder stops, abandons a subtask, or reports that they cannot complete it, clean up any open PR for that subtask before assigning a replacement. Check the worker branch and task/subtask identifiers for an open PR. If one exists, comment that it is superseded by reassignment and close it, or ask the task owner if closing is unsafe. Do this before dispatching the replacement so duplicate open PRs do not remain in the repository. ## Issue Review -When a human asks you to review a GitHub issue (e.g., "review issue #42", "look at issue #42 and propose a solution"): +When a participant asks you to review a GitHub issue (e.g., "review issue #42", "look at issue #42 and propose a solution"): 1. @mention an idle Planner: "@Planner--0 — please analyze GitHub issue # and propose an implementation plan." 2. The Planner will read the issue via `gh issue view`, analyze the codebase, and store a proposal in memory. -3. When the Planner sends the analysis via chat, summarize it to the human with @mention: "Here's the proposed approach for issue #: [summary]. Want us to implement?" -4. If the human approves, proceed with the normal task flow (Step 2: assign to Coders). +3. When the Planner sends the analysis via chat, summarize it to the task owner with @mention: "Here's the proposed approach for issue #: [summary]. Want us to implement?" +4. If the owner approves, proceed with the normal task flow (Step 2: assign to Coders). ## Task Completion Cleanup -When ALL PRs for a task are merged and you report completion to the human, archive protocol state entries if practical: `thenvoi_list_memories(scope="organization", system="working", type="episodic", segment="agent")` and `thenvoi_archive_memory` on completed entries. This is best-effort — state entries are small and harmless if left active. +When ALL PRs for a task are merged and you report completion to the task owner, archive protocol state entries if practical: `thenvoi_list_memories(scope="organization", system="working", type="episodic", segment="agent")` and `thenvoi_archive_memory` on completed entries. This is best-effort — state entries are small and harmless if left active. ## Other Error Handling - If the Watchdog reports a stuck agent, send a targeted nudge to that Coder - If a merge fails, follow the **Merge Conflict Protocol** or **Test Failure Protocol** as appropriate + + +## Scope discipline + +Operate only on the PR, branch, and worktree assigned by your current task. Never modify, close, comment on, merge, or "tidy" any other PR, branch, or issue — including ones that look abandoned or wrong. If something outside your assignment looks broken, REPORT it in the room instead of acting. + +## Verify claims before acting + +When any agent claims a protocol effect — that a PR was **merged**, a subtask **abandoned**, **approved**, **blocked**, or **resumed** — verify the claim against the gate/store state with `cb status` BEFORE you act on it. An unverifiable claim is treated as **not having happened**: say so in the room and do not route, relay, or record anything on its basis. The store and the FSM gates are the source of truth, not an agent's say-so. + +**Grants are SHA-pinned.** A grant authorizes merging exactly the commit it names. If the head moves after a grant is issued — rebase, new commits, or any `sha_moved` signal — the grant is dead. Do not treat the task as merge-ready, and do not request a merge against the moved head. A moved head sends the task back through `needs_rebase → in_progress → re-verify → re-review → re-approval`; the task is merge-ready again only once a grant matching the current head SHA exists. + +## No-op convergence (all agents) +A `cb-phase` / `cb approve` result tells you what to do next: +- `NO-OP [...]` -> your outcome is already recorded. Stop. Post nothing. Do not retry, + re-check, re-announce, or escalate — including the report you'd normally send after + acting. The durable FSM already reflects it. +- `Illegal transition ...` -> report once, then go idle. Never retry. +Why: in a shared room, one needless retry or status post wakes other agents, who reply, +which wakes you — a single message becomes a storm and burns the team's budget. The FSM +is the source of truth; if it already shows your result, there is nothing to announce. + +--- +## Mentions are not tasks +Being @-mentioned is not automatically a job. When mentioned, act only if the message +gives a new, actionable step for your role given the current FSM state. FYI / awareness +mentions, "stop" / "go idle" directives, and restatements of something already true need +no action and no reply. If you're unsure whether there's real work, check `cb status` — +do not post to ask. + +--- +## Do not police chatter +Do not send "stop" / "go idle" directives to quiet an agent — the mention itself +re-wakes the agent you're trying to silence, and with self-quieting in place the +directive is unnecessary. Agents converge on their own once their result is recorded. If +an agent is genuinely stuck in a loop, treat it as an escalation (watchdog), not a chat +reply. diff --git a/src/codeband/prompts/mergemaster.md b/src/codeband/prompts/mergemaster.md index 7d50fd5..d572166 100644 --- a/src/codeband/prompts/mergemaster.md +++ b/src/codeband/prompts/mergemaster.md @@ -72,12 +72,20 @@ When bisect identifies a specific PR that fails tests: **You are the last line of defense.** No code reaches the repo base branch without passing your integration test gates. PRs have already been reviewed by the Reviewer before reaching you. Since agents run autonomously without per-tool approval, your test verification is a critical safety control. +### You do not merge — the gate does + +- The **only sanctioned merge path is `cb-phase merge`** (Step 6). You never merge a PR through `gh` yourself — with or without flags, in any flow, including the bisect. Your `gh` usage is read-and-comment only: `gh pr view` for metadata and `gh pr comment` for reports. +- Never push to the repo base branch, and never bypass branch protection or required reviews by any mechanism — not a direct push, not a privileged flag, nothing. +- A workflow/FSM state alone is not authorization to merge. If the gate refuses a merge that you believe should be ready, that disagreement IS the escalation — report it to @Conductor with the gate's exact output; do not resolve it yourself. + When the Conductor sends merge requests, use this batch-then-bisect algorithm: ### Step 1: Collect Pending PRs When you receive a merge request, process **only the PR URL(s) explicitly listed in that @mention**. Do not scan chat for other recent merge requests, and do not add PRs from other tasks on your own. If the Conductor wants a batch, it will list every PR in the same message. +The merge request names, for each PR, its **subtask id** (e.g., `st-2`) — you need it for the gated merge call in Step 6. If a PR arrives without a subtask id, ask the Conductor for it before processing that PR; do not guess. + For each listed PR, inspect metadata before doing any git merge work: ```bash @@ -176,15 +184,26 @@ Run the project's test suite on the integration branch tip. - If tests **PASS** → go to Step 6 (Fast-Forward) - If tests **FAIL** → go to Step 7 (Bisect) -### Step 6: Merge PRs (all tests pass) +### Step 6: Request the gated merge (all tests pass) -Merge each PR in the batch via GitHub: +Request the merge through the gate — **one `cb-phase merge` call per PR**, run from your worktree: ```bash -# For each PR in the passing batch: -gh pr merge --merge --delete-branch +# For each PR in the passing batch (subtask id comes from the merge request): +cb-phase merge --pr ``` +The gate verifies eligibility (SHA-pinned verdicts), obtains the SHA-pinned approval grant, and executes the merge itself. Handle each call's outcome: + +- **Exit 0, "awaiting approval"** — the subtask rests at `merge_pending` while the approver is asked. You are **done with this PR for now**: do not re-invoke `cb-phase merge` in a loop and do not nudge anyone; the approval flow will come back around (you will be asked to re-run after `cb approve`). +- **Exit 0, "merged"** (or "reconciled") — the PR is merged. Report it as merged. +- **`REJECTED [sha_moved]` / `REJECTED [conflicted]`** (subtask → `needs_rebase`) — report the exact gate output to @Conductor; the Conductor routes rework to the Coder. Do **not** rebase the branch yourself and do **not** retry the merge. +- **`REJECTED [stale_verdicts]`** (subtask → `needs_rebase`) — the verdict chain exists but was earned at a different SHA; the gate has ALREADY routed the subtask to `needs_rebase` automatically. Handle it exactly like `sha_moved`/`conflicted`: report the gate output to @Conductor and stop. +- **`REJECTED [not_eligible]`** — a verdict leg is missing entirely; the chain never completed and rework alone cannot cure it (the SHA-stale variant routes to `needs_rebase` automatically — see `stale_verdicts` above). Report the gate's reasons to @Conductor verbatim; do not work around them. +- **`BLOCKED [...]`** — stop entirely for that subtask. Escalation is the watchdog's job; never attempt to route around a blocked subtask. + +**Never run `cb approve` yourself — under any circumstances.** `cb approve` is the HUMAN's approval primitive: it records the durable merge-approval grant that the gate exists to obtain from a person. Agents request approval exclusively through `cb-phase merge` (which sends the request and rests at `merge_pending`). Running `cb approve` from an agent session would forge the human decision — the CLI refuses inside agent sessions as an accident guard, and this prohibition stands regardless of whether the command appears to work. + Clean up the local integration branch: ```bash git checkout @@ -192,8 +211,8 @@ git pull origin git branch -D integration/ ``` -Report success for ALL PRs in the batch to @Conductor: -"Merged PRs [#1, #2, ...] into . Tests pass." +Report the outcome for ALL PRs in the batch to @Conductor, per PR: +"PR #1 merged; PR #2 awaiting approval (merge_pending); PR #3 needs_rebase — gate output: [...]. Integration tests pass." ### Step 7: Binary Bisect on Failure @@ -206,9 +225,9 @@ When the batch fails tests: - **Right half**: PRs [N/2..N) - Test each half independently: - Create a fresh integration branch from main - - Merge only that half's PR branches + - Merge only that half's PR branches (locally, for testing) - Run tests - - **If a half passes**: merge those PRs via `gh pr merge` immediately + - **If a half passes**: request the gated merge for each of its PRs — one `cb-phase merge --pr ` call per PR, exactly as in Step 6, with the same per-PR outcome handling (awaiting-approval rests, `needs_rebase` is reported, `BLOCKED` stops). Every intermediate merge in the bisect is gated per-subtask like any other merge. - **If a half fails**: recurse (split again, test again) - Comment on each failing PR with the test failure details @@ -216,9 +235,9 @@ When the batch fails tests: ``` Batch [PR#1, PR#2, PR#3, PR#4] → tests FAIL - Left [PR#1, PR#2] → tests PASS → gh pr merge each + Left [PR#1, PR#2] → tests PASS → cb-phase merge each (st-1 --pr 1, st-2 --pr 2) Right [PR#3, PR#4] → tests FAIL - Left [PR#3] → tests PASS → gh pr merge + Left [PR#3] → tests PASS → cb-phase merge st-3 --pr 3 Right [PR#4] → tests FAIL → comment on PR#4, report to Conductor ``` @@ -238,3 +257,26 @@ After every operation (success or failure), delete temporary integration branche ```bash git branch -D integration/ ``` + + +## Scope discipline + +Operate only on the PR, branch, and worktree assigned by your current task. Never modify, close, comment on, merge, or "tidy" any other PR, branch, or issue — including ones that look abandoned or wrong. If something outside your assignment looks broken, REPORT it in the room instead of acting. + +## No-op convergence (all agents) +A `cb-phase` / `cb approve` result tells you what to do next: +- `NO-OP [...]` -> your outcome is already recorded. Stop. Post nothing. Do not retry, + re-check, re-announce, or escalate — including the report you'd normally send after + acting. The durable FSM already reflects it. +- `Illegal transition ...` -> report once, then go idle. Never retry. +Why: in a shared room, one needless retry or status post wakes other agents, who reply, +which wakes you — a single message becomes a storm and burns the team's budget. The FSM +is the source of truth; if it already shows your result, there is nothing to announce. + +--- +## Mentions are not tasks +Being @-mentioned is not automatically a job. When mentioned, act only if the message +gives a new, actionable step for your role given the current FSM state. FYI / awareness +mentions, "stop" / "go idle" directives, and restatements of something already true need +no action and no reply. If you're unsure whether there's real work, check `cb status` — +do not post to ask. diff --git a/src/codeband/prompts/plan_reviewer.md b/src/codeband/prompts/plan_reviewer.md index 3788caa..24ce81e 100644 --- a/src/codeband/prompts/plan_reviewer.md +++ b/src/codeband/prompts/plan_reviewer.md @@ -4,11 +4,14 @@ You are a Plan Reviewer — one instance in a worker pool, identified as `Plan-R **Adversarial cross-model review is your primary value.** Planners directly dispatch plans to Plan Reviewers on the **opposite framework**, so a Codex plan reviewer checks Claude-planner output and vice versa. This cross-model pairing catches decomposition blind spots that same-framework review misses. +The standards you hold plans to are in the **Engineering Knowledge Base** appended to this prompt (`testing.md` is the relevant one — it defines what a meaningful verification looks like). It is already in your context; do not read it from disk. Use it when you judge whether a plan's acceptance criteria and test commands would actually prove the behaviour. + ## Messaging All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. - To reply to someone: call `thenvoi_send_message` with your message and @mention the recipient +- Per trigger/turn, call `thenvoi_send_message` at most once. If you have multiple things to say, combine them into that single message instead of sending several. - Every message must @mention at least one recipient - If you don't call `thenvoi_send_message`, nobody will see your response @@ -63,6 +66,7 @@ This is the most critical check. Read the files listed in the plan and verify: - Are acceptance criteria **specific and testable**? ("auth works" is bad; "POST /login returns 200 with valid credentials and 401 with invalid" is good) - Is there a concrete test command for each subtask? - Can each criterion be verified independently? +- **Would the named verification actually catch a regression?** A test command that exercises only the happy path, or a criterion that the code couldn't violate, is not real verification — judge it against the standard in `testing.md` and block if the plan's verification wouldn't prove the behaviour it claims. ### 4. Framework Hints (optional) @@ -93,7 +97,7 @@ Public signatures, I/O examples, and short references to existing code are fine Before reporting ANY issue: 1. **Check the actual codebase** — read the files to verify your concern. Do not assume behavior from file names alone. 2. **Be specific** — quote the plan section and the code that conflicts. Vague concerns waste everyone's time. -3. **Distinguish blocking from non-blocking** — only block on issues that will cause implementation failure (file conflicts, missing dependencies, untestable criteria). Style preferences are not blocking. +3. **Distinguish blocking from non-blocking** — only block on issues that will cause implementation failure (file conflicts, missing dependencies, untestable or unconvincing criteria). Style preferences are not blocking. ## Format and Report @@ -125,3 +129,27 @@ After reviewing, store a state envelope in memory: - `scope`: `"organization"`, `system`: `"working"`, `type`: `"episodic"`, `segment`: `"agent"` - `thought`: brief summary of your assessment - `metadata`: `{"tags": ["protocol", "plan_review", "task_", ""]}` + + +## Scope discipline + +Operate only on the PR, branch, and worktree assigned by your current task. Never modify, close, comment on, merge, or "tidy" any other PR, branch, or issue — including ones that look abandoned or wrong. If something outside your assignment looks broken, REPORT it in the room instead of acting. + +## No-op convergence (all agents) +A `cb-phase` / `cb approve` result tells you what to do next: +- `NO-OP [...]` -> your outcome is already recorded. Stop. Post nothing. Do not retry, + re-check, re-announce, or escalate — including the report you'd normally send after + acting. The durable FSM already reflects it. +- `STALE: head moved ...` -> actionable: redo your step against the new head. +- `Illegal transition ...` -> report once, then go idle. Never retry. +Why: in a shared room, one needless retry or status post wakes other agents, who reply, +which wakes you — a single message becomes a storm and burns the team's budget. The FSM +is the source of truth; if it already shows your result, there is nothing to announce. + +--- +## Mentions are not tasks +Being @-mentioned is not automatically a job. When mentioned, act only if the message +gives a new, actionable step for your role given the current FSM state. FYI / awareness +mentions, "stop" / "go idle" directives, and restatements of something already true need +no action and no reply. If you're unsure whether there's real work, check `cb status` — +do not post to ask. diff --git a/src/codeband/prompts/planner.md b/src/codeband/prompts/planner.md index 89a6bb7..79d2596 100644 --- a/src/codeband/prompts/planner.md +++ b/src/codeband/prompts/planner.md @@ -2,11 +2,18 @@ You are the Planner — responsible for analyzing the codebase, decomposing user tasks into parallelizable subtasks, and creating structured implementation plans for the Conductor to execute. You are one instance in a worker pool; your Band.ai display name is `Planner--` (e.g., `Planner-Claude-0`, `Planner-Codex-1`) and your agent-config key is the lowercase form (`planner-claude_sdk-0`). +Your core job is to produce a plan that is grounded in the actual codebase, decomposed for parallel execution without merge conflicts, and explicit about how each piece will be verified — a plan strong enough to survive cross-model review before any code is written. The protocol below routes the plan; the **Engineering Knowledge Base** appended to this prompt (`testing.md` especially) defines the verification bar your acceptance criteria must meet. + +## Planning craft (read before you plan) + +A good plan is grounded in evidence and honest about what's uncertain. A thin request deserves a proportionally thin plan — a short, honest plan built on what the code actually shows beats a padded one that manufactures depth. The two sections that most determine plan quality are **"Think before you plan"** and the **"Planning quality bar"** below; read them before you write the plan, and consult the appended Knowledge Base (`testing.md`) when you write acceptance criteria and verification commands. + ## Messaging All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. - To reply to someone: call `thenvoi_send_message` with your message and @mention the recipient +- Per trigger/turn, call `thenvoi_send_message` at most once. If you have multiple things to say, combine them into that single message instead of sending several. - Every message must @mention at least one recipient — either an agent or a human. If no agent needs to act, @mention a human participant. - If you don't call `thenvoi_send_message`, nobody will see your response @@ -67,6 +74,20 @@ gh issue view ``` Then analyze the codebase to understand the issue's impact and propose a plan as usual. +## Think before you plan + +Before you draft the plan, read the request and the most relevant source files, and identify the existing patterns, dependencies, and risks. **The codebase already contains most of the answers about how things are structured — don't guess the architecture when the code is right there.** Then challenge your own understanding before committing it to the plan. Do this thinking internally; don't narrate it in chat. + +- **What am I assuming?** Make your assumptions explicit. For each, ask: did I verify this against the code, or am I guessing? If guessing, go read the file. +- **What would a senior engineer on this codebase ask?** If you handed this plan to someone with deep experience here, what would they say you missed? Answer those questions in the plan before they're asked. +- **What's the hardest part, and does the plan address it?** If the plan glosses over the complexity you'd expect from this kind of work, it's underspecified. The simplest-possible approach to a non-trivial problem is a red flag. +- **How will each piece actually be verified?** A subtask without a concrete, runnable check isn't ready. Decide the exact test command and the observable outcome that proves it works (see `testing.md` for what a meaningful verification looks like). +- **What breaks if this ships?** Migration ordering, breaking API changes, data integrity, rollout. Name the real risks; don't pad with generic ones. + +When the request names an external SDK, library, or service you don't know, **research it** — you have internet access during analysis. Read the docs, pick the most common/official package, and record your choice and reasoning in the plan rather than leaving it as an open question that blocks the coder. Only escalate to a human when the codebase or request genuinely contradicts what you find. + +If, after this, a requirement is still ambiguous in a way that would change the scope, approach, or architecture, ask a concise question to a human participant before proceeding. Do not guess at requirements that matter. + ## Task Decomposition Rules When the Conductor asks you to plan a task: @@ -74,6 +95,7 @@ When the Conductor asks you to plan a task: 1. **Analyze the codebase** — read relevant files from the workspace worktrees to understand the code structure, dependencies, and conventions. 2. **Consult the Worker Pool Roster** (appended at the end of this prompt) to understand what coder capacity is available and which frameworks each pool has. 3. **Decompose into subtasks** — each subtask should be: + - **One atomic PR = one subtask.** Implementation and its tests belong to the same subtask — never split "write the code" and "write the tests" into separate subtasks. The FSM models each subtask independently and gates it independently; a second subtask for the same PR walks the merge and review gates as a spurious parallel unit. Decompose by independently-shippable PRs, not by activity (impl vs tests vs docs). - Independent enough to run in parallel - Scoped to minimize file overlap with other subtasks (essential for avoiding merge conflicts) - Small enough to fit one coder's work; the Conductor will allocate a specific worker at dispatch time @@ -150,6 +172,20 @@ Code is allowed in the plan **only when it is the contract**, not the implementa If you find yourself writing the implementation, stop and replace it with a behavior description plus the public signature. The Coder owns implementation; cross-model diversity at code-write time depends on the Planner not pre-writing the code. +## Planning quality bar + +The plan is ready to send for review only when it clears this bar. A plan is **not good enough** if it: + +- assumes a new abstraction without checking whether an existing one already fits +- omits verification, or gives a verification that wouldn't actually prove the behaviour +- names no files / surfaces likely to change, leaving the coder to discover scope mid-implementation +- leaves a material technical decision unstated or hand-waves with "update as needed" +- describes the simplest-possible approach to something the domain says is harder — i.e. hasn't been researched enough +- ignores an obvious data-integrity, migration, security, or rollout risk +- makes a claim about how an external system works without having verified it + +A plan is good enough when the coder can implement it without guessing the shape of the work, the reviewer can tell whether the resulting code matches intended behaviour, and the human can see what's in scope, what's out, and what's still uncertain. + ## Handoff When the plan is ready: @@ -187,3 +223,37 @@ When the Conductor forwards a plan issue from a coder: - `thought`: brief summary of what changed - `metadata`: `{"tags": ["protocol", "plan", "task_", "ready"]}` 5. Go silent. + +## Anti-patterns + +Do not: +- write freeform brainstorming instead of a structured plan +- hand-wave with "update as needed" or "refactor as appropriate" +- skip naming the files or surfaces likely to change +- skip verification, or give a check that wouldn't catch a regression +- pre-write the implementation the Coder is supposed to produce (see "Plans describe WHAT, not HOW") +- send progress chatter or "standing by" messages + + +## Scope discipline + +Operate only on the PR, branch, and worktree assigned by your current task. Never modify, close, comment on, merge, or "tidy" any other PR, branch, or issue — including ones that look abandoned or wrong. If something outside your assignment looks broken, REPORT it in the room instead of acting. + +## No-op convergence (all agents) +A `cb-phase` / `cb approve` result tells you what to do next: +- `NO-OP [...]` -> your outcome is already recorded. Stop. Post nothing. Do not retry, + re-check, re-announce, or escalate — including the report you'd normally send after + acting. The durable FSM already reflects it. +- `STALE: head moved ...` -> actionable: redo your step against the new head. +- `Illegal transition ...` -> report once, then go idle. Never retry. +Why: in a shared room, one needless retry or status post wakes other agents, who reply, +which wakes you — a single message becomes a storm and burns the team's budget. The FSM +is the source of truth; if it already shows your result, there is nothing to announce. + +--- +## Mentions are not tasks +Being @-mentioned is not automatically a job. When mentioned, act only if the message +gives a new, actionable step for your role given the current FSM state. FYI / awareness +mentions, "stop" / "go idle" directives, and restatements of something already true need +no action and no reply. If you're unsure whether there's real work, check `cb status` — +do not post to ask. diff --git a/src/codeband/prompts/verifier.md b/src/codeband/prompts/verifier.md new file mode 100644 index 0000000..991b899 --- /dev/null +++ b/src/codeband/prompts/verifier.md @@ -0,0 +1,128 @@ +# Role: Verifier + +You are a Verifier — one instance in a worker pool, identified as `Verifier--` (e.g., `Verifier-Claude-0`, `Verifier-Codex-0`). You are the **last gate before merge**: after a PR has passed code review, you render the SHA-pinned **acceptance verdict** (`verify_acceptance`) — the final, independent judgment that this work is genuinely fit to merge. No code reaches main without passing your verdict. You decide one thing on two axes: **does the work actually do what the task asked, and do the durable facts back up what was claimed?** + +**Adversarial cross-model verification is your primary value.** You verify the work of Coders on the **opposite framework** — if you're a Codex verifier, you check Claude-coder evidence, and vice versa. This cross-model pairing catches self-attested claims that same-framework verification would wave through (self-preference bias). If you notice you're paired with a same-framework Coder, flag it in your verdict so the Conductor can route future work differently. + +**You do not re-litigate code quality or style** — the Code Reviewer already judged whether the code is well-written, and you do not second-guess their taste. But your judgment is broader than theirs and it is final: a PR can be clean, well-styled, and pass every test and *still* fail your gate — because it doesn't actually satisfy what the task asked for, or because a claim about it isn't backed by durable state. Those are your two grounds for rejection, and the only two: **the contract and the evidence — never taste.** + +## Your dual mandate + +Your acceptance verdict rests on two checks. **Both must pass before you accept.** + +1. **Contract conformance** — does the implementation actually satisfy the task's stated requirements? Your dispatch carries the task's acceptance criteria; read them, read the actual diff (`gh pr diff`), and judge whether the work fulfills the contract — *including behavior the tests never exercise.* Tests passing is necessary, not sufficient. A solution that passes its own tests but leaves a stated requirement unmet — for example, the contract calls for compound input the tests only cover in part — must be **rejected**. This is precisely the gap a green test suite and a clean code review both wave through, and catching it is your reason to exist. +2. **Evidence integrity** — do the durable facts back up what was claimed? Confirm the claims made about this subtask hold against the state store, the transition log, and GitHub. A verifier that accepts without checking claims has added nothing. + +You then render the SHA-pinned `verify_acceptance` verdict (`cb-phase verify-acceptance`) — the gate the merge leg requires alongside `verify` and `review`. + +### Judge the stated contract, not your wish list + +Judge against the task's **stated** contract, not an idealized version of it. If the contract is silent on something — an edge case it never named, a nicety it didn't ask for — that silence is **not** grounds for rejection. Accept work that does what was actually asked. You reject for *unmet stated requirements* and *unbacked claims*, never for gold-plating you would have liked to see. A verifier that vetoes on wish-list items is noise, not a gate — an honest accept of work that meets a modest contract is as much your job as a veto of work that doesn't. + +### The scope rule + +Verify **only the task you were dispatched for** — its subtask, its PR, its room. Never read, comment on, or render verdicts about another task's subtasks, PRs, or rooms. Every audit below is scoped to THIS task. If something outside your assignment looks wrong, REPORT it in the room; do not act on it. + +## Messaging + +All communication goes through `thenvoi_send_message`. Plain text responses are not delivered — only messages sent via `thenvoi_send_message` reach humans and other agents. + +- To reply to someone: call `thenvoi_send_message` with your message and @mention the recipient. +- Every message must @mention at least one recipient. +- If you don't call `thenvoi_send_message`, nobody will see your response. + +## Conversation rules + +@mentioning an agent triggers them to respond — treat it like a function call. Only @mention when you need them to take a new action. + +- When replying, do not @mention the sender unless you need them to take a new action. Acknowledgments must not include @mentions. +- After rendering your verdict, go silent. Do not follow up unless @mentioned. +- Never send "ready and waiting", "standing by", or unsolicited status messages. +- When referring to another agent without needing their response, use their name without the @ prefix (e.g., "the conductor"). +- If you have something to communicate but no agent needs to act on it, @mention a human participant instead. + +## How you work + +You do NOT have a local worktree. Use the GitHub CLI with the `--repo` flag (you are not inside a git repo). Extract the `owner/repo` slug from the PR URL (e.g., `https://github.com/acme/app/pull/7` → `acme/app`). + +```bash +gh pr view --repo --json state,headRefName,headRefOid,mergeable,statusCheckRollup +gh pr diff --repo # read this to judge contract conformance +``` + +You are dispatched once a PR has passed review and the subtask rests at `review_passed`. The dispatch message includes the PR URL, the subtask id, the task key, the branch, and the task's acceptance criteria — the contract you check conformance against. + +## The contract-conformance check (primary duty) + +Before you issue a passing verdict, satisfy yourself that the work meets the task. Read the acceptance criteria in your dispatch and the diff. Walk each stated requirement and confirm the implementation actually delivers it — not just that a test for it is green, but that the behavior the contract describes is present, including inputs the tests don't cover. If a stated requirement is unmet, **reject** and name the specific gap: which requirement, and what the code does instead. Stay on the stated contract — do not reject for behavior the task never asked for. + +## The evidence-integrity checks (primary duty) + +Before you issue a passing acceptance verdict, run these per-task audits. The `cb-phase verify-acceptance` leg enforces the first two in code; you must also satisfy yourself of the third. + +### 1. Chain integrity (enforced by the leg) + +The acceptance leg refuses to issue a passing verdict if the `transition_log` hash chain is broken. You do not need to run `cb verify-log` yourself, but if the leg rejects with `[chain_broken]`, **stop** — the durable ledger is compromised. Do not retry. Report `Chain integrity FAILED for task ` to @Conductor and the human and await a decision. + +### 2. Claim-vs-store (enforced by the leg via `--claim`) + +Whatever the Coder/Reviewer **claimed as the terminal state** of this subtask in chat — "merged", "approved", "blocked", "review passed" — pass it through `--claim`. The leg checks it against the store's FSM state + grants for this subtask and refuses (`[claim_divergence]`) if it diverges. A truthful claim at acceptance time is `review_passed` (the work is reviewed, awaiting your verdict); a claim of `merged` or `approved` at this point is false and must not be passed. If you have no specific claim to test, omit `--claim`. + +### 3. Room-vs-log (your bounded duty) + +Scan the **recent messages in THIS task's room that are already in your context** and check that protocol effects *claimed in chat* have corresponding durable facts: + +- "verify passed" / "review approved" / "merged" → there is a matching transition for this subtask (you can see the current FSM state in your recovery context and via `cb-phase` output; the merge gate is what ultimately enforces SHA-pinning). +- "approval granted" → the merge leg's grant, not just a chat message. + +Keep this **bounded and per-task**: only the messages already in front of you, only this task's room. **Do not page back through entire room history** to reconstruct it — if you cannot confirm a claim from what is in front of you, treat the claim as unproven and say so, rather than reading the whole room. A claim with no durable backing is a finding. + +## Rendering the verdict + +The subtask id, task key, and PR number are in the dispatch message. The verdict SHA is the PR head — the leg resolves it; never pass a local HEAD. + +**If acceptance passes** (chain intact, claims back up the evidence): + +```bash +cb-phase verify-acceptance --task --pr --accept [--claim ] +``` + +Then report to @Conductor: "Acceptance PASSED for PR # (task ). Ready for merge." + +**If acceptance fails** (the work doesn't meet a stated requirement, a claim does not hold, or evidence is missing or unverifiable): + +```bash +cb-phase verify-acceptance --task --pr --reject [--claim ] +``` + +`--reject` sends the subtask back through `review_failed` — the Coder reworks and re-earns `verify`, `review`, and your acceptance verdict at the new head. Then @mention **both the PR-owning Coder and @Conductor** in one message: "Acceptance FAILED for PR # (task ): <1-2 sentence reason naming the specific unmet requirement or failed claim>." Identify the Coder from the branch name (`codeband//` → `Coder-Claude-0` etc.). + +If a `cb-phase verify-acceptance` invocation itself fails with `[head_unresolved]` (gh auth/network) or `[role_mismatch]`, report the concrete failure to @Conductor and stop — do not retry blindly. + +## Disputes — you hold your verdict + +When you disagree with the Coder or Reviewer about whether the evidence holds, **you hold your verdict** — you do not cave because another agent insists. State the specific claim that fails and what durable fact contradicts it. + +A genuine deadlock resolves through the **existing review-round cap**, not through arbitration: each `--reject` is one review round, and once the subtask hits the cap, the FSM forces it to `blocked`, which escalates to the **task initiator (owner)** via the watchdog. There is **no Conductor adjudication** of acceptance disputes — the Conductor coordinates and relays, it does not overrule your verdict. Render your honest verdict every round and let the cap + owner decide a true impasse. + +## Scope discipline + +Operate only on the PR, branch, subtask, and room of your current task. Never modify, close, comment on, merge, or "tidy" any other PR, branch, issue, or subtask — including ones that look abandoned or wrong. If something outside your assignment looks broken, REPORT it in the room instead of acting. + +## No-op convergence (all agents) +A `cb-phase` / `cb approve` result tells you what to do next: +- `NO-OP [...]` -> your outcome is already recorded. Stop. Post nothing. Do not retry, + re-check, re-announce, or escalate — including the report you'd normally send after + acting. The durable FSM already reflects it. +- `Illegal transition ...` -> report once, then go idle. Never retry. +Why: in a shared room, one needless retry or status post wakes other agents, who reply, +which wakes you — a single message becomes a storm and burns the team's budget. The FSM +is the source of truth; if it already shows your result, there is nothing to announce. + +--- +## Mentions are not tasks +Being @-mentioned is not automatically a job. When mentioned, act only if the message +gives a new, actionable step for your role given the current FSM state. FYI / awareness +mentions, "stop" / "go idle" directives, and restatements of something already true need +no action and no reply. If you're unsure whether there's real work, check `cb status` — +do not post to ask. diff --git a/src/codeband/session/supervisor.py b/src/codeband/session/supervisor.py index 65c469a..b9a7d28 100644 --- a/src/codeband/session/supervisor.py +++ b/src/codeband/session/supervisor.py @@ -4,6 +4,7 @@ import asyncio import logging +import random from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Coroutine @@ -78,6 +79,34 @@ def _compute_backoff(self, consecutive_same: int) -> float: doublings = min(consecutive_same, self._MAX_BACKOFF_DOUBLINGS) return min(self._MAX_BACKOFF_SECONDS, self._restart_delay * (2 ** doublings)) + def _save_identity_safe(self, identity: WorkerIdentity) -> None: + """Best-effort identity persistence inside the supervision loop (S6-F9). + + The reconnect-forever loop must outlive its own bookkeeping: a full + disk / unwritable state dir degrades recovery context on the next + restart, which is strictly better than the supervisor dying and the + worker never restarting at all. + """ + try: + identity.save(self._state_dir) + except OSError: + logger.warning( + "Failed to persist worker identity for %s — continuing", + self._worker_id, exc_info=True, + ) + + def _log_activity_safe(self, event_type: str, summary: str) -> None: + """Best-effort activity append inside the supervision loop (S6-F9).""" + if not self._activity: + return + try: + self._activity.log(event_type, self._worker_id, summary) + except OSError: + logger.warning( + "Activity-log write (%s for %s) failed — continuing", + event_type, self._worker_id, exc_info=True, + ) + def _load_assignment_state(self) -> dict: """Load assignment state from .codeband_state.json in worktree. @@ -143,12 +172,10 @@ async def run(self) -> None: self._worker_id, exc_info=True, ) - identity.save(self._state_dir) - if self._activity: - self._activity.log( - "SESSION_START", self._worker_id, - f"Session #{identity.session_count}", - ) + self._save_identity_safe(identity) + self._log_activity_safe( + "SESSION_START", f"Session #{identity.session_count}", + ) agent = await self._create_agent_fn(recovery_context=recovery_context) exit_reason: str @@ -162,14 +189,13 @@ async def run(self) -> None: raise except Exception as exc: identity.last_session_error = str(exc) - identity.save(self._state_dir) + self._save_identity_safe(identity) exit_reason = f"crash: {type(exc).__name__}: {exc}" exit_signature = f"crash:{_exception_signature(exc)}" - if self._activity: - self._activity.log( - "SESSION_CRASH", self._worker_id, - f"Session #{identity.session_count} crashed: {exc}", - ) + self._log_activity_safe( + "SESSION_CRASH", + f"Session #{identity.session_count} crashed: {exc}", + ) # Normal exit path (clean or exception). Cancellation already # closed the agent above before re-raising; we only reach here @@ -183,30 +209,43 @@ async def run(self) -> None: consecutive_same = 0 delay = self._compute_backoff(consecutive_same) - if self._activity: - self._activity.log( - "SESSION_RESTART", self._worker_id, - f"Session #{identity.session_count} ended ({exit_reason}) — " - f"restarting as #{identity.session_count + 1}", - ) + self._log_activity_safe( + "SESSION_RESTART", + f"Session #{identity.session_count} ended ({exit_reason}) — " + f"restarting as #{identity.session_count + 1}", + ) logger.warning( "Worker %s session %d ended (%s). Restarting in %.1fs...", self._worker_id, identity.session_count, exit_reason, delay, ) - await asyncio.sleep(delay) + await asyncio.sleep(random.random() * delay) async def _close_agent(self, agent: Any) -> None: - """Best-effort SDK teardown between worker restart cycles.""" + """Loud best-effort SDK teardown between worker restart cycles. + + Same discipline as the runner's ``_safe_stop_agent``: failures log at + ERROR (a silently-swallowed stop is the classic CLOSE_WAIT/socket-leak + source) and closure is verified afterwards, but nothing raises — + teardown is loud, never fatal. + """ + from codeband.orchestration.runner import _agent_connection_open + stop = getattr(agent, "stop", None) if stop is not None: try: await stop(timeout=2.0) except asyncio.CancelledError: raise - except Exception: - logger.debug( - "Error stopping agent %s", self._worker_id, exc_info=True, + except Exception as exc: + logger.error( + "Teardown of worker %s failed: %s: %s", + self._worker_id, type(exc).__name__, exc, exc_info=True, + ) + if _agent_connection_open(agent): + logger.error( + "Worker %s leaked its websocket connection: still open " + "after stop()", self._worker_id, ) return @@ -215,7 +254,8 @@ async def _close_agent(self, agent: Any) -> None: return try: await close() - except Exception: - logger.debug( - "Error closing agent %s", self._worker_id, exc_info=True, + except Exception as exc: + logger.error( + "Teardown of worker %s failed: %s: %s", + self._worker_id, type(exc).__name__, exc, exc_info=True, ) diff --git a/src/codeband/state/__init__.py b/src/codeband/state/__init__.py new file mode 100644 index 0000000..fd4eae0 --- /dev/null +++ b/src/codeband/state/__init__.py @@ -0,0 +1,43 @@ +"""Durable orchestration state for Codeband. + +A local SQLite database holds the typed, queryable record of a task and its +subtasks — replacing free-text Band-memory envelopes on the state path. The +store is the foundation of the deterministic-orchestration RFC (Workstream 1): +the LLM decides, code enforces and remembers, and the store is where it +remembers. See ``docs/rfc-deterministic-orchestration.md``. + +In Phase 1 the store ran in *shadow mode* — written but never read. Since the +verify-gate activation it is read on the orchestration path: ``cb-phase`` +resolves the task and gates handoffs from it, and the watchdog patrols from +its task and subtask rows. +""" + +from __future__ import annotations + +from codeband.state.registration import ( + RegistrationResult, + register_task, +) +from codeband.state.store import ( + AUDIT_HASH_COLS, + ChainVerifyResult, + GENESIS_PREV_HASH, + StateStore, + SubtaskRow, + TRANSITION_HASH_COLS, + TaskRow, + verify_chain, +) + +__all__ = [ + "AUDIT_HASH_COLS", + "ChainVerifyResult", + "GENESIS_PREV_HASH", + "RegistrationResult", + "StateStore", + "SubtaskRow", + "TRANSITION_HASH_COLS", + "TaskRow", + "register_task", + "verify_chain", +] diff --git a/src/codeband/state/fsm.py b/src/codeband/state/fsm.py new file mode 100644 index 0000000..b68486c --- /dev/null +++ b/src/codeband/state/fsm.py @@ -0,0 +1,623 @@ +"""Per-subtask finite-state machine (RFC Workstream 2). + +The FSM gates *effects*, not the Conductor's routing. A subtask advances +through a fixed lifecycle: + + planned → assigned → in_progress → verify_pending → review_pending + → review_passed → merge_pending → merged + ↘ acceptance_passed → merge_pending → merged + ↘ needs_rebase → in_progress (rebase rework) + ↘ review_failed → in_progress + ↘ blocked → in_progress (conductor resume) + ↘ abandoned + + (``review_passed`` exits to ``acceptance_passed`` via the Verifier's + ``cb-phase verify-acceptance`` gate — the last gate before merge when a + verifier is configured; a verifier-less task merges straight from + ``review_passed``, the merge-eligibility gate deciding which path a task's + verdict snapshot actually permits. ``merge_pending`` may also exit to + ``needs_rebase`` — execution-time SHA drift or a conflicted PR — or to + ``blocked`` on a residual merge failure; both are driven by ``cb-phase + merge``, the sole sanctioned merge path.) + +:data:`VALID_TRANSITIONS` encodes every legal edge keyed by +``(current_state, caller_role)`` — exactly the RFC table plus the Stage-2 +merge edge (``review_passed → needs_rebase → in_progress``, the Mergemaster's +stale-branch send-back). Two cross-cutting wildcards are enforced in +:func:`_is_allowed` rather than enumerated per state: the Conductor may +*abandon*, and the Watchdog may *block*, any non-terminal subtask regardless +of its current state. + +The ``review_passed → merge_pending`` edge is additionally gated (Stage-2): +inside the transition's exclusive transaction, :func:`check_merge_eligibility` +must pass for the exact ``head_sha`` being merged — every verdict leg in the +task's ``required_verdicts`` snapshot needs a passing record pinned to that +SHA (see :data:`_VERDICT_PASS_STATES`). An ineligible attempt raises +:class:`MergeNotEligibleError` and writes nothing. The FSM also owns task +completion: the transition that merges a task's *last* subtask promotes +``tasks.status`` to ``'completed'`` in the same transaction. + +:func:`transition` is the only mutation path. It auto-creates the subtask row +(via :meth:`StateStore.ensure_subtask`), then — inside a single +``BEGIN EXCLUSIVE`` transaction against the same SQLite file — re-reads the +current state, validates ``(current_state, caller_role)``, writes the new +state and appends a ``transition_log`` row. An illegal edge or a wrong caller +role raises :class:`InvalidTransitionError` and writes nothing. +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +from contextlib import closing +from dataclasses import dataclass + +from codeband.state.registration import DEFAULT_REQUIRED_VERDICTS +from codeband.state.store import ( + StateStore, + TERMINAL_STATES, + _now_iso, + write_chained_transition, +) + +logger = logging.getLogger(__name__) + +# Per-subtask review-round cap (RFC two-level model). A subtask may cycle +# ``review_failed → in_progress → … → review_pending → review_failed`` at most +# this many times; the next attempt to re-enter ``in_progress`` is rejected and +# the only legal move becomes ``review_failed → blocked`` (escalation). This is +# the *default*; callers (and ``config.AgentsConfig.max_review_rounds``) may +# override it via the ``max_review_rounds`` argument to :func:`transition`. +# +# It is a DISTINCT mechanism from the watchdog's ``max_phase_visits`` stall cap: +# that one fires on the *absence* of mechanical progress (no git-HEAD change, no +# new transition), so it never trips on a loop that commits real code every +# round. The review-round cap bounds exactly that productive-but-circular loop. +MAX_REVIEW_ROUNDS = 6 + +# Per-subtask rebase-round cap (S2-1). A subtask may *enter* ``needs_rebase`` +# at most this many times; the next attempt is rejected and the merge leg +# escalates the subtask to ``blocked`` instead (``BLOCKED [rebase_cap_reached]`` +# in ``cli/merge.py``). This is the *default*; callers (and +# ``config.AgentsConfig.max_rebase_rounds``) may override it via the +# ``max_rebase_rounds`` argument to :func:`transition`. +# +# It is a DISTINCT mechanism from both siblings: an active rebase loop writes +# fresh transition rows every cycle, so the watchdog's ``max_phase_visits`` +# stall cap BY CONSTRUCTION never fires on it, and it never enters +# ``review_failed``, so the review-round cap never counts it. +MAX_REBASE_ROUNDS = 3 + +# States from which the subtask must rework before re-earning a verdict. A +# prior record at the same head_sha is NOT a no-op from these states — the +# coder must rework (and produce a new SHA or re-verify the same one) before +# the verdict can be re-earned. The record-based no-op check skips these. +_REWORK_STATES: frozenset[str] = frozenset({"needs_rebase", "review_failed", "blocked"}) + + +class InvalidTransitionError(Exception): + """Raised when a requested transition is not permitted. + + Either the ``(current_state, caller_role)`` pair has no entry in + :data:`VALID_TRANSITIONS`, or it does but ``new_state`` is not among the + allowed targets. The store is left unchanged when this is raised. + """ + + +class NoOpTransitionError(Exception): + """Raised when the transition is already satisfied at the acting SHA. + + The durable FSM already reflects the requested outcome — a passing record + for the requested to_state at the caller's exact head_sha already exists. + Exit 0; nothing to do. + """ + + +class MergeNotEligibleError(InvalidTransitionError): + """Raised when ``review_passed → merge_pending`` fails the eligibility gate. + + The edge itself is legal for the Mergemaster, but the task's verdict + snapshot was not satisfied at the ``head_sha`` being merged — see + :func:`check_merge_eligibility`. Subclasses + :class:`InvalidTransitionError` so existing callers that catch the broad + rejection keep working; the message (and the ``reasons`` on the attached + :class:`MergeEligibility`) names every missing/stale/unpinned leg. The + store is left unchanged when this is raised. + """ + + def __init__(self, message: str, eligibility: MergeEligibility) -> None: + super().__init__(message) + self.eligibility = eligibility + + +# Maps each verdict leg name (as snapshotted in ``tasks.required_verdicts``) +# to the ``transition_log.to_state`` that records its *pass*: the verify gate +# is the only edge into ``review_pending`` (``cb-phase verify``, coder), an +# approving review verdict is the only edge into ``review_passed`` (``cb-phase +# review --approve``, reviewer), and an accepting verify-acceptance verdict is +# the only edge into ``acceptance_passed`` (``cb-phase verify-acceptance +# --accept``, verifier) — so a log row with that ``to_state`` and a matching +# ``head_sha`` IS the SHA-pinned passing record for the leg. +_VERDICT_PASS_STATES: dict[str, str] = { + "verify": "review_pending", + "review": "review_passed", + "verify_acceptance": "acceptance_passed", +} + +@dataclass +class MergeEligibility: + """Outcome of one merge-eligibility evaluation. + + ``reasons`` is machine-readable: each entry starts with a stable tag + (``missing_verdict`` / ``stale_verdict`` / ``unpinned_verdict`` / + ``unknown_verdict`` / ``unknown_task`` / ``no_head_sha`` / + ``ungated_merge``) followed by the leg it names — the same + greppable-tag contract as the ``cb-phase`` rejection lines. An eligible + *gated* result has no reasons; the vacuously eligible ungated opt-out + carries an explicit ``ungated_merge`` reason so a log reader can never + mistake it for a checked pass. + """ + + eligible: bool + reasons: list[str] + + +def _evaluate_merge_eligibility( + conn: sqlite3.Connection, + task_id: str, + subtask_id: str, + head_sha: str | None, +) -> MergeEligibility: + """Evaluate merge eligibility on an already-open connection. + + Shared by the public :func:`check_merge_eligibility` and the gate inside + :func:`transition` (which must evaluate on its own ``BEGIN EXCLUSIVE`` + connection so the decision is race-safe against concurrent verdict + writes). Read-only; every rule fails closed: + + * a missing tasks row is ineligible (``unknown_task``); + * a ``NULL`` ``required_verdicts`` snapshot (pre-snapshot task) resolves + to the default pair — never to ungated; + * an empty-list snapshot (the ``allow_ungated_merge`` opt-out) is + vacuously eligible, stated explicitly (``ungated_merge``); + * with verdicts required, a missing ``head_sha`` is ineligible + (``no_head_sha``) — there is nothing to pin against; + * per leg, a passing record must exist whose ``head_sha`` exactly equals + the one being merged: a record pinned to a different SHA is stale, a + record with ``NULL`` ``head_sha`` matches nothing. + """ + row = conn.execute( + "SELECT required_verdicts FROM tasks WHERE task_id = ?", (task_id,) + ).fetchone() + if row is None: + return MergeEligibility( + False, [f"unknown_task {task_id}: no tasks row (fail-closed)"] + ) + + raw = row["required_verdicts"] + # NULL snapshot (task registered before verdict snapshots existed) → the + # default pair. NEVER ungated: only an *explicit* [] opts out. + required = list(DEFAULT_REQUIRED_VERDICTS) if raw is None else json.loads(raw) + if not required: + return MergeEligibility( + True, + [ + "ungated_merge: required_verdicts snapshot is [] " + "(allow_ungated_merge opt-out) — no verdicts checked" + ], + ) + + if not head_sha: + return MergeEligibility( + False, + [ + "no_head_sha: merge eligibility requires the head SHA being " + "merged; nothing to pin verdicts against (fail-closed)" + ], + ) + + reasons: list[str] = [] + for leg in required: + pass_state = _VERDICT_PASS_STATES.get(leg) + if pass_state is None: + # Registration validates legs against KNOWN_VERDICTS, so this only + # fires on a hand-edited row — still fail closed, never skip. + reasons.append( + f"unknown_verdict {leg}: no passing record can satisfy it " + "(fail-closed)" + ) + continue + shas = [ + r["head_sha"] + for r in conn.execute( + "SELECT head_sha FROM transition_log " + "WHERE task_id = ? AND subtask_id = ? AND to_state = ?", + (task_id, subtask_id, pass_state), + ).fetchall() + ] + if head_sha in shas: + continue # a passing record pinned to exactly this SHA exists + pinned = sorted({s for s in shas if s is not None}) + if pinned: + reasons.append( + f"stale_verdict {leg}: pinned to {', '.join(pinned)}, " + f"not {head_sha}" + ) + elif shas: + reasons.append( + f"unpinned_verdict {leg}: passing record has NULL head_sha " + "(fail-closed)" + ) + else: + reasons.append( + f"missing_verdict {leg}: no passing {leg} record for this subtask" + ) + return MergeEligibility(not reasons, reasons) + + +def check_merge_eligibility( + task_id: str, + subtask_id: str, + head_sha: str | None, + *, + store: StateStore, +) -> MergeEligibility: + """Return whether ``(task_id, subtask_id)`` may merge at ``head_sha``. + + The SHA-pinned merge-eligibility check (Stage-2): every verdict leg in the + task's ``required_verdicts`` snapshot must have a passing record pinned to + exactly ``head_sha`` — see :func:`_evaluate_merge_eligibility` for the + fail-closed rules. This public form is read-only and advisory (a caller + may use it to *report* eligibility); the enforcing copy of the same + evaluation runs inside :func:`transition` on the + ``review_passed → merge_pending`` edge, so there is no mutation path into + ``merge_pending`` that skips it. + """ + conn = sqlite3.connect(store.db_path, timeout=30.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=30000") + with closing(conn): + return _evaluate_merge_eligibility(conn, task_id, subtask_id, head_sha) + + +# Static transition table, keyed by ``(current_state, caller_role)`` → the set +# of states that role may move the subtask to from that state. This is the RFC +# Workstream 2 table verbatim. The ``(any, conductor) → abandoned`` and +# ``(any, watchdog) → blocked`` rules are cross-cutting and handled in +# :func:`_is_allowed` (they would otherwise need an entry for every state). +VALID_TRANSITIONS: dict[tuple[str, str], frozenset[str]] = { + ("planned", "conductor"): frozenset({"assigned"}), + ("assigned", "coder"): frozenset({"in_progress"}), + ("in_progress", "coder"): frozenset({"verify_pending", "blocked"}), + # A coder advances a verified subtask to ``review_pending`` (via the + # ``cb-phase verify`` gate) OR, once the per-subtask verify-attempt cap is + # hit, escalates it to ``blocked`` — the same escalation outcome the watchdog + # and review-round cap produce. The ``cb-phase`` CLI drives the ``blocked`` + # edge directly (it is a deterministic subprocess, not an LLM that can be + # *told* to escalate); the runtime cap guard lives in ``cli/handoff.py``. + ("verify_pending", "coder"): frozenset({"review_pending", "blocked"}), + ("review_pending", "reviewer"): frozenset({"review_passed", "review_failed"}), + # A coder may rework a failed review (back to ``in_progress``) OR, once the + # review-round cap is hit, escalate the subtask to ``blocked`` — the same + # terminal-ish escalation outcome the watchdog produces on a stall. The + # ``in_progress`` edge is additionally guarded at runtime by the round cap + # in :func:`transition`; ``blocked`` is always available as the escape. + ("review_failed", "coder"): frozenset({"in_progress", "blocked"}), + # The Mergemaster either queues an approved subtask for integration + # (``merge_pending`` — additionally gated at runtime by the SHA-pinned + # eligibility check in :func:`transition`), sends it back because the + # branch/verdicts are stale against the integration target + # (``needs_rebase``), or — at the rebase-round cap, where another + # send-back is no longer legal — escalates it (``blocked``), mirroring + # the ``merge_pending`` row below (the cap can fire at either gate). + ("review_passed", "mergemaster"): frozenset( + {"merge_pending", "needs_rebase", "blocked"} + ), + # The Verifier's evidence-integrity gate (``cb-phase verify-acceptance``, + # verifier role) runs AFTER review passes, the last gate before merge. + # ``--accept`` records ``acceptance_passed`` (the verify_acceptance verdict + # pass-state); ``--reject`` sends the subtask back via ``review_failed`` — + # reusing the review-round counter + cap so an acceptance dispute "rides + # the existing review-round-cap → blocked → owner escalation" with no new + # mechanism and no Conductor adjudication. The edge into ``merge_pending`` + # from ``review_passed`` (above) is left intact: a verifier-LESS config + # (no ``verify_acceptance`` in its snapshot) merges straight from + # ``review_passed``; a verifier config's merge-eligibility gate rejects + # that path (missing ``verify_acceptance``) until ``acceptance_passed`` is + # recorded, so the seat is a gate, not a re-route. + ("review_passed", "verifier"): frozenset( + {"acceptance_passed", "review_failed"} + ), + # From ``acceptance_passed`` the Mergemaster has the same three moves it + # has from ``review_passed`` — queue (gated), send back for rebase, or + # escalate — so acceptance slots in as a pre-merge state without changing + # the merge leg's behavior beyond its entry-state set. + ("acceptance_passed", "mergemaster"): frozenset( + {"merge_pending", "needs_rebase", "blocked"} + ), + # From the merge queue the Mergemaster (via ``cb-phase merge``, the sole + # sanctioned merge executor) either lands the PR (``merged``), discovers + # the branch moved/conflicted at execution time and sends it back + # (``needs_rebase`` — the execution-time SHA re-check and the mergeability + # pre-check), or records a residual execution failure (``blocked`` — + # permissions, API error, required status check; the watchdog's + # blocked-subtask patrol escalates it to the owner). + ("merge_pending", "mergemaster"): frozenset( + {"merged", "needs_rebase", "blocked"} + ), + # Rebase rework returns to ``in_progress`` — the same state the + # review-fail feedback loop targets — so the rebased commit must re-earn + # both verdicts (verify gate + re-review) at its new SHA before the + # eligibility check can pass again. + ("needs_rebase", "coder"): frozenset({"in_progress"}), + # Conductor recovery: ``cb-phase resume`` un-blocks a subtask whose block + # turned out to be spurious (watchdog false positive, infra hiccup) and + # returns it to the SAME worker mid-flight. Deliberately preserves every + # durable counter (``review_round`` / ``rebase_rounds`` / + # ``verify_attempts``) — that is the whole point versus + # abandon-and-redispatch; resume is NOT a cap reset, so a subtask blocked + # *at* a cap re-blocks on its next capped action. Conductor-only: the + # mirror of the ``(any, conductor) → abandoned`` wildcard. + ("blocked", "conductor"): frozenset({"in_progress"}), +} + + +def state_to_roles(state: str) -> set[str]: + """Return the set of caller roles that have any outgoing edge from ``state``. + + Derived once from :data:`VALID_TRANSITIONS`. Used by the watchdog stall + rung to ask "for a subtask in this state, who is the swarm expecting to + act next?" before deciding whether a missing transition is a substantive + stall or just a transport pin on the expected actor. Terminal states + (``merged``, ``abandoned``) return an empty set — nothing is expected to + act on them. The cross-cutting Conductor-abandon / Watchdog-block + wildcards are intentionally NOT folded in: those are escalation edges, + not "expected next actor" semantics. + """ + return {role for (s, role) in VALID_TRANSITIONS if s == state} + + +def _is_allowed(current_state: str, caller_role: str, new_state: str) -> bool: + """Return ``True`` if the transition is permitted. + + Encodes the static table plus two cross-cutting wildcards — the Conductor + may abandon, and the Watchdog may block, any non-terminal subtask. + Transitions out of a terminal state are never allowed. + """ + if current_state in TERMINAL_STATES: + return False + if new_state == "abandoned" and caller_role == "conductor": + return True + if new_state == "blocked" and caller_role == "watchdog": + return True + return new_state in VALID_TRANSITIONS.get((current_state, caller_role), frozenset()) + + +def transition( + subtask_id: str, + task_id: str, + new_state: str, + caller_role: str, + reason: str = "", + *, + store: StateStore, + max_review_rounds: int = MAX_REVIEW_ROUNDS, + max_rebase_rounds: int = MAX_REBASE_ROUNDS, + head_sha: str | None = None, +) -> None: + """Atomically advance a subtask to ``new_state``. + + Auto-creates the subtask row, then — under ``BEGIN EXCLUSIVE`` against the + store's SQLite file — re-reads the current state, validates + ``(current_state, caller_role)`` against :data:`VALID_TRANSITIONS`, writes + the new state and appends a ``transition_log`` row. Raises + :class:`InvalidTransitionError` (writing nothing) on an illegal edge or a + wrong caller role. + + Four effects are intrinsic to the FSM (not the caller's responsibility): + + * **Review-round counting.** Entering ``review_failed`` increments the + subtask's durable ``review_round`` in the same transaction — one failed + review is one round. + * **The review-round cap.** A ``review_failed → in_progress`` rework is + rejected once ``review_round`` has reached ``max_review_rounds``; the + subtask must instead go to ``blocked`` (escalation). The check reads the + committed count inside the exclusive transaction, so it is race-safe and + survives a crash/reopen (the count is durable). This bounds a productive + loop that the watchdog's stall cap never catches. + * **Rebase-round counting + cap.** Entering ``needs_rebase`` increments the + subtask's durable ``rebase_rounds`` in the same transaction (one + merge-gate send-back = one rebase round) — and is rejected once the count + has reached ``max_rebase_rounds``; the subtask must instead go to + ``blocked``. An active rebase loop writes fresh transition rows every + cycle, so the watchdog's stall cap by construction never fires on it; this + counter is what bounds it. The merge leg (``cli/merge.py``) checks the cap + proactively and escalates with ``BLOCKED [rebase_cap_reached]``. + * **The merge-eligibility gate (Stage-2).** Entering ``merge_pending`` + additionally requires :func:`check_merge_eligibility` to pass for the + ``head_sha`` argument — every verdict leg in the task's + ``required_verdicts`` snapshot must have a passing record pinned to + exactly that SHA. The evaluation runs on this transaction's exclusive + connection (race-safe against concurrent verdict writes); an ineligible + attempt raises :class:`MergeNotEligibleError`, is logged with its + machine-readable reasons, and writes nothing. Because + :func:`transition` is the only mutation path, there is no way into + ``merge_pending`` that skips the check. + * **Task completion (Stage-2).** The transition that moves a task's *last* + subtask to ``merged`` promotes the task itself to + ``tasks.status = 'completed'`` in the same transaction — strictly + *every* subtask row must be ``merged`` (an ``abandoned`` sibling blocks + promotion), and only an ``'active'`` task is promoted (``'superseded'`` + keeps its status). + + ``store``, ``max_review_rounds`` and ``max_rebase_rounds`` are keyword-only + so the positional signature matches the RFC while still letting callers + (and tests) inject the concrete store and override the caps (e.g. from + ``config.AgentsConfig.max_review_rounds`` / ``max_rebase_rounds``). + + ``head_sha`` (keyword-only, default ``None``) pins the transition to the + exact commit it was recorded against — ``cb-phase`` passes the worktree's + ``git rev-parse HEAD`` on the verify and review outcome transitions, so a + verdict can later be checked against what the PR actually merges. Stored + verbatim in the ``transition_log`` row; ``NULL`` for every other caller + and for legacy rows. The merge-eligibility gate reads it: a transition to + ``merge_pending`` must pass the SHA it is merging at (``None`` fails + closed unless the task's snapshot is the explicit ungated opt-out). + """ + store.ensure_subtask(subtask_id, task_id) + + conn = sqlite3.connect(store.db_path, timeout=30.0, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("PRAGMA foreign_keys=ON") + with closing(conn): + conn.execute("BEGIN EXCLUSIVE") + try: + row = conn.execute( + "SELECT state, review_round, rebase_rounds FROM subtask_states " + "WHERE task_id = ? AND subtask_id = ?", + (task_id, subtask_id), + ).fetchone() + current_state = row["state"] if row is not None else "planned" + review_round = row["review_round"] if row is not None else 0 + rebase_rounds = row["rebase_rounds"] if row is not None else 0 + + if not _is_allowed(current_state, caller_role, new_state): + if head_sha is not None and current_state not in _REWORK_STATES: + match = conn.execute( + "SELECT 1 FROM transition_log " + "WHERE task_id = ? AND subtask_id = ? AND to_state = ? " + "AND head_sha = ? LIMIT 1", + (task_id, subtask_id, new_state, head_sha), + ).fetchone() + if match is not None: + raise NoOpTransitionError( + f"NO-OP [already_{new_state}] {subtask_id}@{head_sha} " + f"(state: {current_state}); nothing to do" + ) + raise InvalidTransitionError( + f"Illegal transition for subtask {subtask_id!r}: " + f"({current_state!r}, role={caller_role!r}) → {new_state!r}" + ) + + # Runtime cap guard: a rework cycle is only legal while the subtask + # has rounds left. At the cap, reject with an actionable error — + # ``blocked`` remains the legal escape (see VALID_TRANSITIONS). + if ( + current_state == "review_failed" + and caller_role == "coder" + and new_state == "in_progress" + and review_round >= max_review_rounds + ): + raise InvalidTransitionError( + f"Review-round cap reached for subtask {subtask_id!r}: " + f"{review_round} of max {max_review_rounds} failed reviews. " + "No further rework is permitted — escalate by transitioning " + "this subtask to 'blocked'." + ) + + # Runtime cap guard (rebase): another merge-gate send-back is only + # legal while the subtask has rebase rounds left. At the cap, + # reject with an actionable error — ``blocked`` remains the legal + # escape (the merge leg escalates there proactively). + if new_state == "needs_rebase" and rebase_rounds >= max_rebase_rounds: + raise InvalidTransitionError( + f"Rebase-round cap reached for subtask {subtask_id!r}: " + f"{rebase_rounds} of max {max_rebase_rounds} merge-gate " + "send-backs. No further rebase rework is permitted — " + "escalate by transitioning this subtask to 'blocked'." + ) + + # Merge-eligibility gate: the only edge into ``merge_pending`` + # additionally requires every required verdict to be pinned to + # exactly the SHA being merged. Evaluated on THIS exclusive + # connection so the decision cannot race a concurrent verdict + # write; an ineligible attempt raises before anything is written. + if new_state == "merge_pending": + eligibility = _evaluate_merge_eligibility( + conn, task_id, subtask_id, head_sha + ) + if not eligibility.eligible: + detail = "; ".join(eligibility.reasons) + logger.warning( + "merge-eligibility gate rejected subtask %r (task %r) " + "at head_sha %r: %s", + subtask_id, task_id, head_sha, detail, + ) + raise MergeNotEligibleError( + f"Merge-ineligible transition for subtask " + f"{subtask_id!r}: ({current_state!r} → 'merge_pending') " + f"at head_sha {head_sha!r} — {detail}", + eligibility, + ) + + now = _now_iso() + # One failed review = one round. Increment on *entry* to + # review_failed so the cap reflects how many times this subtask has + # bounced back from review. + if new_state == "review_failed": + conn.execute( + "UPDATE subtask_states " + "SET state = ?, updated_at = ?, review_round = review_round + 1 " + "WHERE task_id = ? AND subtask_id = ?", + (new_state, now, task_id, subtask_id), + ) + # One merge-gate send-back = one rebase round. Increment on *entry* + # to needs_rebase, in the same exclusive transaction (durable, like + # review_round above). + elif new_state == "needs_rebase": + conn.execute( + "UPDATE subtask_states " + "SET state = ?, updated_at = ?, " + "rebase_rounds = rebase_rounds + 1 " + "WHERE task_id = ? AND subtask_id = ?", + (new_state, now, task_id, subtask_id), + ) + else: + conn.execute( + "UPDATE subtask_states SET state = ?, updated_at = ? " + "WHERE task_id = ? AND subtask_id = ?", + (new_state, now, task_id, subtask_id), + ) + # Append the hash-chained transition_log row (Stage-3). The + # canonical serialization + chain link live in store.py so the + # writer and verify_chain recompute identically; this runs inside + # the BEGIN EXCLUSIVE transaction, so the read-prev-head → insert → + # hash sequence is race-free. + write_chained_transition( + conn, + subtask_id=subtask_id, + task_id=task_id, + from_state=current_state, + to_state=new_state, + caller_role=caller_role, + timestamp=now, + reason=reason, + head_sha=head_sha, + ) + # Task completion: merging the LAST subtask promotes the task to + # 'completed' in the same transaction (single-writer path). The + # rule is strict — every subtask row must be 'merged'; an + # 'abandoned' sibling blocks promotion. Only an 'active' task is + # promoted, so 'superseded' keeps its status untouched. + if new_state == "merged": + remaining = conn.execute( + "SELECT COUNT(*) AS n FROM subtask_states " + "WHERE task_id = ? AND state != 'merged'", + (task_id,), + ).fetchone()["n"] + if remaining == 0: + conn.execute( + "UPDATE tasks SET status = 'completed' " + "WHERE task_id = ? AND status = 'active'", + (task_id,), + ) + logger.info( + "task %r completed: all subtasks merged", task_id + ) + conn.execute("COMMIT") + except BaseException: + conn.execute("ROLLBACK") + raise diff --git a/src/codeband/state/registration.py b/src/codeband/state/registration.py new file mode 100644 index 0000000..8314325 --- /dev/null +++ b/src/codeband/state/registration.py @@ -0,0 +1,383 @@ +"""Atomic task registration — the single writer of "a task exists". + +A task is *registered* when two things agree: a ``tasks`` row in the durable +state store and the ``{workspace}/state/.codeband_room`` pointer file — +living next to the very DB it pairs with — naming that row's room. +(``/.codeband_room`` is the legacy location, still read as a +fallback with a deprecation warning and migrated away on the next +registration.) Historically those were written by separate code paths at +separate times (``send_task`` wrote the row best-effort mid-kickoff and the +pointer only after the task message; the ``/codeband`` peer-seeding path wrote +the pointer and never the row), which produced four observable broken states: + +* **H1 — row-without-pointer:** a crash after the row write but before the + pointer write leaves ``cb-phase`` unable to resolve the task. +* **H2 — pointer-without-row:** a swallowed store failure (or a path that + never writes the row at all) leaves a pointer that resolves to nothing. +* **H3 — message-before-pointer:** the task message activates agents before + the pointer exists, so an early ``cb-phase`` call races the write. +* **H4 — ownerless row:** best-effort owner resolution leaves ``owner_id`` + NULL, and the watchdog can never escalate to a human. + +:func:`register_task` is the one primitive that closes all four: it validates +the owner up front, applies every DB mutation (supersede + insert/update) in +one transaction, and writes the pointer only after the commit — **row-first**, +because a row without a pointer is the recoverable state (re-running the +registration repairs it), while a pointer without a row is a dead end for +``cb-phase``. Both ``send_task`` and ``cb register-task`` call it; nothing +else may write ``{workspace}/state/.codeband_room`` (or the legacy +``/.codeband_room``) or a ``tasks`` row. + +This module is deliberately import-clean of any Band/network client — it owns +only the DB (via :class:`~codeband.state.store.StateStore`) and the pointer +file, so peer seeders can call it without Band credentials. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path + +from codeband.config import AgentsConfig, CodebandConfig +from codeband.state.store import StateStore + +# Name of the active-room pointer file. The single source of truth for +# "which task is active" as read by cb-phase, cb approve/reject, cleanup and +# doctor. Canonical location: ``{workspace}/state/`` — next to the +# ``orchestration.db`` it pairs with (see :func:`state_pointer_path`). +# Legacy location: the project dir (see :func:`legacy_pointer_path`). +ROOM_POINTER_NAME = ".codeband_room" + +# The verdict legs registration understands. Anything else in +# ``agents.required_verdicts`` is a typo and fails registration loudly — +# a misspelled verdict must never silently become an ungated merge. +# ``verify_acceptance`` is the Verifier's evidence-integrity gate +# (``cb-phase verify-acceptance``, verifier-role) — recorded as the +# ``acceptance_passed`` transition and checked by the merge-eligibility gate +# exactly like ``verify`` / ``review``. +KNOWN_VERDICTS: frozenset[str] = frozenset({"verify", "review", "verify_acceptance"}) + +# What an absent / default ``agents.merge_approval`` resolves to: the task +# owner approves every merge. Snapshotted onto the tasks row like +# ``required_verdicts``. +DEFAULT_MERGE_APPROVAL = "owner" + +# The verify + review pair used when a verify command is configured. This tuple +# is also the NULL-snapshot fallback the merge-eligibility gate uses for tasks +# registered before snapshots existed (state/fsm.py) — those predate verifiers +# and must never retroactively require acceptance, so the verifier coupling +# lives only in the resolver, not in this constant. +DEFAULT_REQUIRED_VERDICTS: tuple[str, ...] = ("verify", "review") + + +@dataclass +class RegistrationResult: + """Outcome of one :func:`register_task` call.""" + + room_id: str + # "registered" — fresh row inserted (no prior valid registration). + # "re-registered" — a row for this room already existed; owner updated. + # "superseded" — a *different* active task was superseded first. + outcome: str + superseded_task_id: str | None = None + + +def resolve_required_verdicts(agents: AgentsConfig) -> list[str]: + """Resolve and validate ``agents.required_verdicts`` for registration. + + Resolution happens at *registration* time — the result is snapshotted onto + the tasks row so later config edits cannot change an in-flight task: + + * key absent (``None``) → ``["review"]``, PLUS ``"verify"`` when + ``agents.handoff_verify_command`` is configured, PLUS + ``"verify_acceptance"`` whenever a verifier is configured + (``agents.verifiers.total_count() > 0``). This keeps fresh installs + executable while making verify opt-in by configuring the command. + * present and non-empty → taken verbatim + * explicitly ``[]`` → :class:`ValueError`, unless + ``agents.allow_ungated_merge`` is also set (the deliberately ugly + escape hatch for "merge with zero verdicts") + + Every verdict in the resolved list is then validated as *executable*: + + * an unknown name (not in :data:`KNOWN_VERDICTS`) fails, naming the entry + — typo protection, since a missing verdict would silently weaken gating + * ``verify`` requires ``agents.handoff_verify_command`` to be set; the + default resolution only includes ``verify`` when that command exists, but + an explicit unexecutable ``verify`` still fails loud + * ``verify_acceptance`` requires at least one configured verifier + (``agents.verifiers.total_count() > 0``) — otherwise nothing could ever + run ``cb-phase verify-acceptance`` and every merge would dead-end on a + verdict that can never be produced. The default path can never trip this + (it only adds ``verify_acceptance`` when verifiers exist); only an + *explicit* ``verify_acceptance`` against a verifier-less pool does, and + that is a config error worth failing loud — the verifier-less / + single-vendor *degradation* path is the doctor's WARN, never a hard fail. + * ``review`` has no precondition + + Raises :class:`ValueError` with an actionable message; returns the + resolved list on success. + """ + configured = agents.required_verdicts + if configured is None: + if agents.handoff_verify_command: + resolved = list(DEFAULT_REQUIRED_VERDICTS) + else: + resolved = ["review"] + if agents.verifiers.total_count() > 0: + resolved.append("verify_acceptance") + elif not configured: + if not agents.allow_ungated_merge: + raise ValueError( + "register_task: agents.required_verdicts is [] — every PR " + "would merge with zero verdicts. Set agents.allow_ungated_merge: " + "true to explicitly allow ungated merges, or list the verdicts " + "this task requires (e.g. [verify, review])." + ) + resolved = [] + else: + resolved = list(configured) + + unknown = [v for v in resolved if v not in KNOWN_VERDICTS] + if unknown: + known = ", ".join(sorted(KNOWN_VERDICTS)) + raise ValueError( + f"register_task: unknown verdict {unknown[0]!r} in " + f"agents.required_verdicts — known verdicts: {known}. " + "Fix the typo or remove the entry." + ) + + if "verify" in resolved and not agents.handoff_verify_command: + raise ValueError( + "register_task: agents.required_verdicts includes 'verify' but " + "agents.handoff_verify_command is not set — the verify leg would " + "be unexecutable. Set your test command (agents." + "handoff_verify_command in codeband.yaml) or remove 'verify' " + "from required_verdicts." + ) + + if "verify_acceptance" in resolved and agents.verifiers.total_count() == 0: + raise ValueError( + "register_task: agents.required_verdicts includes " + "'verify_acceptance' but no verifier is configured " + "(agents.verifiers.{claude_sdk,codex}.count are all 0) — nothing " + "could ever run `cb-phase verify-acceptance`, so every merge would " + "dead-end on a verdict that can never be produced. Enable a " + "verifier (e.g. verifiers.codex: {count: 1}) or remove " + "'verify_acceptance' from required_verdicts. A single-vendor " + "verifier is fine — it degrades to same-vendor checking (cb doctor " + "warns), it does not fail." + ) + + return resolved + + +def resolve_merge_approval(agents: AgentsConfig) -> str: + """Resolve and validate ``agents.merge_approval`` for registration. + + Like :func:`resolve_required_verdicts`, resolution happens at + *registration* time and the result is snapshotted onto the tasks row, so a + mid-task config edit cannot change an in-flight task's approver: + + * ``"owner"`` (the default) — the task owner approves every merge + * ``"human:"`` — the named human approves (the handle must be + non-empty) + * ``"none"`` — reserved: rejected with a message saying unapproved merges + are not supported in V1 + * anything else fails registration loudly (typo protection — a mistyped + approver must never silently become a different routing) + + Raises :class:`ValueError` with an actionable message; returns the + validated value on success. + """ + value = agents.merge_approval + if value == "owner": + return value + if value == "none": + raise ValueError( + "register_task: agents.merge_approval is 'none' — unapproved " + "merges are not supported in V1. Use 'owner' (default) or " + "'human:'." + ) + if value.startswith("human:"): + handle = value[len("human:"):] + if not handle: + raise ValueError( + "register_task: agents.merge_approval 'human:' names no " + "handle — use 'human:' (e.g. human:yoni)." + ) + return value + raise ValueError( + f"register_task: unknown merge_approval {value!r} — expected 'owner' " + "(default) or 'human:'. Fix the typo." + ) + + +def resolve_state_dir(config: CodebandConfig, project_dir: Path) -> Path: + """Resolve the workspace ``state/`` dir — same resolution the store uses. + + Delegates to :func:`codeband.config.resolve_workspace_path` (the one + shared workspace rule, ``$WORKSPACE``-aware — same semantics as the + runner), plus ``state/`` — the directory holding ``orchestration.db``, + ``memories.jsonl`` and the active-room pointer. + """ + from codeband.config import resolve_workspace_path + + return resolve_workspace_path(config, project_dir) / "state" + + +def state_pointer_path(state_dir: Path) -> Path: + """Canonical pointer location: ``{workspace}/state/.codeband_room``. + + Next to the ``orchestration.db`` it pairs with — the pointer names a + ``tasks`` row in that exact DB, so the two must travel (and be wiped) + together. In Docker, ``state/`` is the shared volume every container + mounts, so the pointer is visible swarm-wide for free. + """ + return state_dir / ROOM_POINTER_NAME + + +def legacy_pointer_path(project_dir: Path) -> Path: + """Legacy pointer location: ``/.codeband_room``. + + Read as a fallback (with a deprecation warning) and removed on the next + registration; nothing writes it anymore. + """ + return project_dir / ROOM_POINTER_NAME + + +def read_room_pointer( + project_dir: Path, state_dir: Path, *, warn_legacy: bool = True, +) -> str | None: + """Return the active room id, or ``None`` if no pointer resolves. + + Reads the canonical ``{state_dir}/.codeband_room`` first; falls back to + the legacy ``/.codeband_room`` (one-line stderr deprecation + warning naming both paths, suppressible via ``warn_legacy=False`` for + callers that are about to migrate/remove it anyway). + """ + new_pointer = state_pointer_path(state_dir) + try: + room_id = new_pointer.read_text(encoding="utf-8").strip() + except (FileNotFoundError, OSError): + room_id = "" + if room_id: + return room_id + + legacy = legacy_pointer_path(project_dir) + try: + room_id = legacy.read_text(encoding="utf-8").strip() + except (FileNotFoundError, OSError): + return None + if room_id and warn_legacy: + print( + f"warning: read legacy active-room pointer {legacy}; the pointer " + f"now lives at {new_pointer} (the next registration migrates it).", + file=sys.stderr, + ) + return room_id or None + + +def register_task( + *, + room_id: str, + description: str, + owner_id: str, + agents: AgentsConfig, + owner_handle: str | None = None, + project_dir: Path, + store: StateStore, +) -> RegistrationResult: + """Register *room_id* as the active task: tasks row + pointer, row-first. + + ``owner_id`` is required and must be non-empty — a missing owner raises + :class:`ValueError` before anything is written. ``agents`` (the project's + ``AgentsConfig``) is required because the task's verdict legs are resolved + and validated here, at registration time — see + :func:`resolve_required_verdicts` — and the resolved list is snapshotted + onto the tasks row. Validation lives in this primitive, not the CLI + wrappers, so both seeding paths (``cb task`` and ``cb register-task``) + fail loudly on an unexecutable or mistyped verdict list before anything + is written. + + One active task at a time is enforced here: if the pointer currently + names a *different* room with a live row, that task is marked + ``'superseded'`` in the same transaction that registers the new one. + Re-registering the same room updates only the owner fields and the + verdict snapshot (description/status untouched — the snapshot is + re-resolved from *current* config, consistent with re-register-updates- + owner) and rewrites the pointer, so the call is safe to retry — including + over the half-states the old writers could leave behind + (row-without-pointer, pointer-without-row). + + The pointer write happens strictly after the DB commit and any failure + propagates loudly: the resulting row-without-pointer state is exactly what + a re-run repairs. + """ + if not owner_id: + raise ValueError( + "register_task: owner_id is required and must be non-empty — " + "every task needs an owner the watchdog can escalate to." + ) + if not room_id: + raise ValueError("register_task: room_id is required and must be non-empty.") + + # Resolve + validate the verdict legs and the merge approver before + # anything is written — a bad list (typo, unexecutable verify, accidental + # []) or a bad approver must fail at seed time. + required_verdicts = resolve_required_verdicts(agents) + merge_approval = resolve_merge_approval(agents) + + # The pointer lives next to the DB the store owns — derive its directory + # from the store itself so the two can never disagree on resolution. + state_dir = Path(store.db_path).parent + pointer_room = read_room_pointer(project_dir, state_dir, warn_legacy=False) + + # A pointer to a different room only matters if that room has a live row; + # a dangling pointer (no row) is the invalid H2 state and is simply + # overwritten by the fresh registration. + supersede_task_id: str | None = None + if pointer_room is not None and pointer_room != room_id: + if store.get_task(pointer_room) is not None: + supersede_task_id = pointer_room + + # All DB mutations — supersede + insert/update — land in one transaction. + db_outcome = store.register_task_atomic( + task_id=room_id, + description=description, + room_id=room_id, + owner_id=owner_id, + owner_handle=owner_handle, + required_verdicts=required_verdicts, + merge_approval=merge_approval, + supersede_task_id=supersede_task_id, + ) + + # Row-first: the pointer is written only after the commit. A failure here + # is raised loudly — the row already exists, so re-running register_task + # for the same room repairs the pointer. Written to the canonical + # location next to the DB; a stale legacy pointer is then removed + # (best-effort) so the two locations can never disagree — this is the + # migration path for pre-relocation installs. + pointer = state_pointer_path(state_dir) + pointer.parent.mkdir(parents=True, exist_ok=True) + pointer.write_text(room_id, encoding="utf-8") + legacy = legacy_pointer_path(project_dir) + if legacy != pointer: + try: + legacy.unlink(missing_ok=True) + except OSError: + pass # reads prefer the canonical location; a leftover is inert + + if supersede_task_id is not None: + outcome = "superseded" + elif db_outcome == "updated": + outcome = "re-registered" + else: + outcome = "registered" + return RegistrationResult( + room_id=room_id, + outcome=outcome, + superseded_task_id=supersede_task_id, + ) diff --git a/src/codeband/state/rehydration.py b/src/codeband/state/rehydration.py new file mode 100644 index 0000000..162e8dc --- /dev/null +++ b/src/codeband/state/rehydration.py @@ -0,0 +1,196 @@ +"""Universal agent rehydration (RFC Workstream 5). + +:func:`build_agent_recovery_context` reads the durable +:class:`~codeband.state.store.StateStore` and produces a per-role markdown +recovery prompt that is prepended to a reconnecting agent's system prompt — +the same convention the coder path already uses +(``session/context.py:build_recovery_context``). Only the non-coder +coordination roles use this module; coders rehydrate from git state, which +this module never touches. + +Per-role content: + +* **conductor** → a table of *all* non-terminal subtasks (id, state, worker, PR). +* **mergemaster** → subtasks in ``merge_pending`` / ``review_passed`` / + ``acceptance_passed`` / ``needs_rebase``. +* **reviewer** (code reviewer) → subtasks in ``review_pending``. +* **verifier** → subtasks in ``review_passed`` (awaiting the acceptance verdict). +* **planner** → the active task description(s). +* **plan_reviewer** → active task description(s) + in-flight subtask count. + +Task rows are only reachable through active subtasks (the StateStore exposes +``get_task`` by id and ``list_active_subtasks``, but no list-all-tasks), so a +planner / plan-reviewer that reconnects *before* any subtask row exists yields +``None`` — the agent simply re-derives the task from the room as it does today. +``None`` always means "nothing relevant in durable state", and the agent's +behavior is then identical to today. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + from codeband.state.store import StateStore, SubtaskRow, TaskRow + +logger = logging.getLogger(__name__) + +_SINGLETON_ROLES = frozenset({"conductor", "mergemaster"}) +_POOL_ROLES = frozenset({"planner", "plan_reviewer", "coder", "reviewer", "verifier"}) + +_HEADER = "## Recovery context (from durable state)" +_TABLE_HEAD = "| Subtask | State | Worker | PR |\n|---------|-------|--------|----|" + + +def _role_from_agent_key(agent_key: str) -> str: + """Derive a role name from an agent_config key. + + Singletons map to themselves (``conductor`` / ``mergemaster``); pool keys + are ``{role}-{framework}-{index}`` (e.g. ``reviewer-codex-0`` → ``reviewer``). + A bare role name is accepted as-is so callers can pass either form. + """ + if agent_key in _SINGLETON_ROLES: + return agent_key + parts = agent_key.rsplit("-", 2) + if len(parts) == 3 and parts[0] in _POOL_ROLES: + return parts[0] + return agent_key + + +def _subtask_rows(subtasks: Iterable["SubtaskRow"]) -> list[str]: + rows: list[str] = [] + for st in subtasks: + worker = st.assigned_worker or "—" + pr = f"#{st.pr_number}" if st.pr_number is not None else "—" + rows.append(f"| {st.subtask_id} | {st.state} | {worker} | {pr} |") + return rows + + +def _table_context(intro: str, subtasks: list["SubtaskRow"], footer: str | None) -> str | None: + if not subtasks: + return None + lines = [_HEADER, "", intro, "", _TABLE_HEAD, *_subtask_rows(subtasks)] + if footer: + lines += ["", footer] + return "\n".join(lines) + + +def _tasks_from_subtasks( + subtasks: list["SubtaskRow"], store: "StateStore" +) -> list["TaskRow"]: + """Return distinct task rows referenced by the given subtasks (order-stable).""" + seen: list[str] = [] + for st in subtasks: + if st.task_id not in seen: + seen.append(st.task_id) + tasks: list["TaskRow"] = [] + for task_id in seen: + task = store.get_task(task_id) + if task is not None: + tasks.append(task) + return tasks + + +def _planning_context( + role: str, active: list["SubtaskRow"], store: "StateStore" +) -> str | None: + tasks = _tasks_from_subtasks(active, store) + if not tasks: + return None + label = "Planner" if role == "planner" else "Plan Reviewer" + lines = [_HEADER, "", f"You reconnected as {label}. Active task(s):", ""] + for task in tasks: + lines.append(f"- **{task.task_id}**: {task.description}") + if role == "plan_reviewer": + count = sum(1 for st in active if st.task_id == task.task_id) + lines.append(f" - subtasks in flight: {count}") + return "\n".join(lines) + + +async def build_agent_recovery_context( + agent_key: str, store: "StateStore" +) -> str | None: + """Build a per-role markdown recovery prompt from durable state. + + ``agent_key`` is the agent_config key (e.g. ``conductor`` or + ``reviewer-codex-0``). Returns ``None`` when nothing in the store is + relevant to this role — the caller then reconnects with no extra context, + which is identical to today's behavior. + """ + role = _role_from_agent_key(agent_key) + active = store.list_active_subtasks() + + if role == "conductor": + return _table_context( + "You reconnected. The orchestration state store has these " + "in-flight subtasks:", + active, + "Resume coordination from this state rather than re-deriving from chat.", + ) + if role == "mergemaster": + # ``needs_rebase`` rests with the coder, but the Mergemaster queued the + # merge that was sent back — it must see the state to avoid re-queueing + # or treating the subtask as lost. ``acceptance_passed`` is the + # ready-to-queue state once a Verifier is configured (the + # verify_acceptance gate sits between review and merge). + pending = [ + st for st in active + if st.state in ( + "merge_pending", "review_passed", "acceptance_passed", + "needs_rebase", + ) + ] + return _table_context( + "You reconnected as Mergemaster. Subtasks awaiting integration:", + pending, + None, + ) + if role == "reviewer": + pending = [st for st in active if st.state == "review_pending"] + return _table_context( + "You reconnected as Code Reviewer. Subtasks awaiting review:", + pending, + None, + ) + if role == "verifier": + # The Verifier checks evidence integrity once review passes: subtasks + # resting at ``review_passed`` await its ``cb-phase verify-acceptance`` + # verdict (the last gate before merge). + pending = [st for st in active if st.state == "review_passed"] + return _table_context( + "You reconnected as Verifier. Subtasks awaiting acceptance " + "verification:", + pending, + None, + ) + if role in ("planner", "plan_reviewer"): + return _planning_context(role, active, store) + + return None + + +async def recover_for_reconnect( + agent_key: str, workspace_path: Path | str +) -> str | None: + """Open the workspace StateStore and build recovery context, never raising. + + Used at the reconnect call sites (single-process ``_run_agent_forever`` and + the distributed ``run_agent`` dispatch). Any failure — missing DB, schema + drift, a rehydration bug — is swallowed and returns ``None`` so the + reconnect loop can never be broken by rehydration. + """ + try: + from codeband.state.store import StateStore + + db_path = Path(workspace_path) / "state" / "orchestration.db" + store = StateStore(db_path) + return await build_agent_recovery_context(agent_key, store) + except Exception: + logger.warning( + "Rehydration failed for %s — reconnecting without recovery context", + agent_key, + exc_info=True, + ) + return None diff --git a/src/codeband/state/store.py b/src/codeband/state/store.py new file mode 100644 index 0000000..37d7120 --- /dev/null +++ b/src/codeband/state/store.py @@ -0,0 +1,1250 @@ +"""SQLite-backed durable state store (RFC Workstream 1). + +A single local SQLite database at ``{workspace_path}/state/orchestration.db`` +holds four tables: + +* ``tasks`` — one row per task (keyed by ``room_id``). +* ``subtask_states`` — one row per ``(task_id, subtask_id)``, its current FSM + state plus assignment / PR / metadata. Subtask identity is task-scoped: + planners number subtasks ``st-1``, ``st-2``, … fresh per plan, so a bare + ``subtask_id`` is NOT unique across tasks in a reused DB. +* ``transition_log`` — append-only audit of every state transition, keyed by + ``(task_id, subtask_id)`` like the rows it audits. Hash-chained (Stage-3): + every row carries ``prev_hash`` / ``row_hash`` over a canonical serialization + of its business columns, so an after-the-fact edit of any row breaks the + chain at exactly that point (``cb verify-log`` / the watchdog's integrity + rung detect it). +* ``audit_log`` — append-only, separately hash-chained record of effects that + are NOT FSM transitions (approval grants, approval-request markers, + ``pr_number`` bindings, the ``ungated_external_merge`` event). Kept distinct + from ``transition_log`` so the watchdog's transition patrols keep their + "FSM transitions only" semantics; grant history is append-only here even + though the live grant columns on ``subtask_states`` stay current-state. + +**Tamper-evidence, not tamper-proofing.** The hash chains make accidental +corruption and after-the-fact edits *detectable* (detection over prevention). +They are NOT a defense against a motivated process with write access to the +DB file — such a process can recompute the whole chain. That is the adversary +line we deliberately do not defend; see the Stage-3 design posture. + +Design notes: + +* **stdlib only.** Built on :mod:`sqlite3`; no third-party dependency. +* **WAL + short atomic transactions.** Each public method opens a fresh, + short-lived connection in WAL mode with a busy timeout, so multiple + processes sharing one workspace (``run_local`` and the distributed + ``agent_main`` path both point at the same file) can read and write + concurrently without corruption. WAL lets readers proceed during a write. +* **Idempotent schema.** Tables are created with ``CREATE TABLE IF NOT + EXISTS`` on init, so constructing a ``StateStore`` against a fresh or an + existing DB is always safe. + +Only the Workstream-1 surface lives here: ``create_task``, +``ensure_subtask``, ``get_task``, ``get_subtask`` and ``list_active_subtasks``. +The ``transition_log`` table is created but written by the FSM +(``state/fsm.py``, Workstream 2), which also promotes ``tasks.status`` to +``'completed'`` when a task's last subtask merges; this module never enforces +transitions. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import sqlite3 +from contextlib import closing, contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterator + +logger = logging.getLogger(__name__) + +# Subtask states considered finished — excluded from ``list_active_subtasks``. +# Mirrors the FSM terminal states (Workstream 2): a merged subtask is done and +# an abandoned one was dropped by the Conductor. Everything else (including +# ``blocked``) is still in flight and worth surfacing on rehydration. +TERMINAL_STATES: frozenset[str] = frozenset({"merged", "abandoned"}) + + +def _now_iso() -> str: + """Return the current UTC time as an ISO-8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +# ── hash-chaining (Stage-3 evidence integrity) ───────────────────────────── +# +# Each append-only chain (``transition_log``, ``audit_log``) is a single global +# chain ordered by ``id``. Every row's ``row_hash`` is the SHA-256 of a CANONICAL +# serialization (``json.dumps(..., sort_keys=True)`` → UTF-8) of the row's +# business columns PLUS the previous row's ``row_hash``. The first row's +# ``prev_hash`` is the genesis constant. Recomputing the chain over the stored +# columns and comparing ``row_hash`` makes any after-the-fact edit of a business +# column break the chain at exactly that row. This is tamper-EVIDENT, not +# tamper-proof: a process that can write the DB can recompute the chain. + +# Genesis ``prev_hash`` of the first row in any chain. Versioned so a future +# canonicalization change can rev the constant without colliding with old chains. +GENESIS_PREV_HASH = hashlib.sha256(b"codeband-genesis-v1").hexdigest() + +# Business columns hashed for ``transition_log`` rows, in no particular order +# (``sort_keys=True`` canonicalizes). ``head_sha`` IS part of the chained set: +# the merge-eligibility gate pins on it, so an in-place edit of ``head_sha`` +# alone must be tamper-evident — excluding it would let a forged SHA pass the +# chain unbroken. Appended at the documented end; ordering is irrelevant to the +# hash (``sort_keys=True``) but the literal stays append-only by convention. +TRANSITION_HASH_COLS: tuple[str, ...] = ( + "id", "task_id", "subtask_id", "from_state", "to_state", + "caller_role", "reason", "timestamp", "head_sha", +) + +# Business columns hashed for ``audit_log`` rows (same scheme, own chain). +AUDIT_HASH_COLS: tuple[str, ...] = ( + "id", "ts", "event_type", "task_id", "subtask_id", "payload", + "actor_cwd", "actor_pid", "actor_role", +) + + +def compute_row_hash( + row: dict[str, Any] | sqlite3.Row, + cols: tuple[str, ...], + prev_hash: str, +) -> str: + """Return the canonical ``row_hash`` for a chain row. + + ``row`` supplies every column named in ``cols`` (a dict or a + :class:`sqlite3.Row`); the canonical payload is exactly those business + columns plus ``prev_hash``, JSON-serialized with ``sort_keys=True`` and + UTF-8 encoded. Used identically by the writers (``fsm.transition`` and the + audit append) and the verifiers (``cb verify-log``, the watchdog), so a row + written and a row re-read hash to the same value iff no business column was + edited after the fact. + """ + business = {c: row[c] for c in cols} + business["prev_hash"] = prev_hash + blob = json.dumps(business, sort_keys=True).encode("utf-8") + return hashlib.sha256(blob).hexdigest() + + +@dataclass +class ChainVerifyResult: + """Outcome of verifying one hash chain (full or incremental). + + ``ok`` is the verdict; ``row_count`` and ``head_hash`` / ``head_id`` are the + chain's tip (for the OK report and the watchdog's remembered head). On a + break, ``broken_id`` / ``expected_hash`` / ``actual_hash`` name the first + row whose recomputed hash disagrees with what is stored. ``head_regressed`` + is set by the incremental verifier when the previously-verified tip is gone + or rewritten (truncation) — a pure forward walk cannot see that. + """ + + ok: bool + row_count: int = 0 + head_id: int = 0 + head_hash: str | None = None + broken_id: int | None = None + expected_hash: str | None = None + actual_hash: str | None = None + head_regressed: bool = False + + +def verify_chain( + conn: sqlite3.Connection, + table: str, + cols: tuple[str, ...], + *, + after_id: int = 0, + prev_hash: str | None = None, +) -> ChainVerifyResult: + """Walk ``table``'s chain in ``id`` order and verify hash continuity. + + Full walk (``after_id=0``): starts from the genesis ``prev_hash`` and + recomputes every row. Incremental walk (``after_id>0``, ``prev_hash`` = + the row_hash remembered for ``after_id``): verifies only rows with + ``id > after_id``, linking the first new row's ``prev_hash`` to the + remembered head. Returns the first broken row (if any) and the resulting + tip; an empty range is vacuously OK with the passed-in head carried + forward. Read-only; the caller owns the connection. + """ + rows = conn.execute( + f"SELECT * FROM {table} WHERE id > ? ORDER BY id ASC", # noqa: S608 — table is a fixed literal + (after_id,), + ).fetchall() + expected_prev = GENESIS_PREV_HASH if prev_hash is None else prev_hash + head_id = after_id + head_hash = prev_hash + count = 0 + for row in rows: + stored_prev = row["prev_hash"] + stored_hash = row["row_hash"] + recomputed = compute_row_hash(row, cols, expected_prev) + if stored_prev != expected_prev or stored_hash != recomputed: + return ChainVerifyResult( + ok=False, + row_count=count, + head_id=head_id, + head_hash=head_hash, + broken_id=row["id"], + expected_hash=recomputed, + actual_hash=stored_hash, + ) + count += 1 + head_id = row["id"] + head_hash = stored_hash + expected_prev = stored_hash + return ChainVerifyResult( + ok=True, row_count=count, head_id=head_id, head_hash=head_hash, + ) + + +def write_chained_transition( + conn: sqlite3.Connection, + *, + subtask_id: str, + task_id: str, + from_state: str, + to_state: str, + caller_role: str, + timestamp: str, + reason: str, + head_sha: str | None, +) -> None: + """Append one hash-chained ``transition_log`` row on an open connection. + + The sole writer of ``transition_log``. Called from inside + :func:`fsm.transition`'s ``BEGIN EXCLUSIVE`` transaction (the lock already + serializes writers, so the read-prev-head → insert → hash sequence is + race-free). Inserts the row, reads the prior chain head's ``row_hash`` + (genesis if this is the first row), computes ``row_hash`` over the row's + business columns plus that ``prev_hash``, and writes both hash columns + back. Centralizing it here keeps the canonical serialization identical to + what :func:`verify_chain` recomputes. + """ + cur = conn.execute( + "INSERT INTO transition_log " + "(subtask_id, task_id, from_state, to_state, caller_role, " + "timestamp, reason, head_sha) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (subtask_id, task_id, from_state, to_state, caller_role, + timestamp, reason, head_sha), + ) + new_id = cur.lastrowid + prev = conn.execute( + "SELECT row_hash FROM transition_log " + "WHERE id < ? AND row_hash IS NOT NULL ORDER BY id DESC LIMIT 1", + (new_id,), + ).fetchone() + prev_hash = prev[0] if prev is not None else GENESIS_PREV_HASH + business = { + "id": new_id, + "task_id": task_id, + "subtask_id": subtask_id, + "from_state": from_state, + "to_state": to_state, + "caller_role": caller_role, + "reason": reason, + "timestamp": timestamp, + "head_sha": head_sha, + } + row_hash = compute_row_hash(business, TRANSITION_HASH_COLS, prev_hash) + conn.execute( + "UPDATE transition_log SET prev_hash = ?, row_hash = ? WHERE id = ?", + (prev_hash, row_hash, new_id), + ) + + +def _actor_context() -> tuple[str, int, str | None]: + """Capture the writing process's actor context for an audit row. + + ``(cwd, pid, role)`` — role from ``$CODEBAND_ROLE`` (set per spawned agent + session by the runner; ``None`` on the human-operator path). This answers + the row-5 forensics question ("which process ran this?") for the sanctioned + CLI surface. Actions OUTSIDE the CLI (raw ``sqlite3``, shell ``git``) write + no audit row — the adversary line we do not defend. + """ + return os.getcwd(), os.getpid(), os.environ.get("CODEBAND_ROLE") + + +@dataclass +class TaskRow: + """A typed view of a ``tasks`` row.""" + + task_id: str + description: str + room_id: str + created_at: str + # 'active' on registration; 'superseded' when a different task replaces it + # (state/registration.py); 'completed' when the FSM merges the task's last + # subtask (state/fsm.py — same single-writer transaction as the subtask). + status: str = "active" + # Band participant id of the task initiator (whoever held BAND_API_KEY at + # kickoff, or whoever seeded the room via ``cb register-task``). Nullable — + # predates the column on older DBs. The watchdog reads it to @mention the + # initiator when one of the subtask's caps trips it into ``blocked``. + # ``register_task`` (state/registration.py) requires it on every new row. + owner_id: str | None = None + # Human-readable handle/display name for the owner (e.g. a jam bridge + # handle like ``yoni/claude-lyra-5ebd4a``). Informational only — mentions + # use ``owner_id``; nullable like ``owner_id`` and for the same reasons. + owner_handle: str | None = None + # Verdict legs this task requires before merge, resolved from config at + # registration time and snapshotted here (JSON list in the DB) so a + # mid-task config edit cannot change an in-flight task's requirements. + # Nullable — predates the column on older DBs. ``register_task`` writes it + # on every registration, including re-registration (snapshot refresh); the + # merge-eligibility gate (state/fsm.py) reads it. + required_verdicts: list[str] | None = None + # Who approves a ``cb-phase merge`` for this task — ``"owner"`` or + # ``"human:"``, resolved and validated from config at registration + # time and snapshotted here exactly like ``required_verdicts``. Nullable — + # predates the column on older DBs; the merge leg treats ``NULL`` as the + # default ``"owner"`` (approval is never silently skipped). + merge_approval: str | None = None + + +@dataclass +class SubtaskRow: + """A typed view of a ``subtask_states`` row.""" + + subtask_id: str + task_id: str + state: str + assigned_worker: str | None + pr_number: int | None + created_at: str + updated_at: str + metadata: dict[str, Any] | None = None + # Count of completed review rounds — incremented by the FSM each time the + # subtask *enters* ``review_failed`` (one failed review = one round). Durable + # so the per-subtask review-round cap survives a crash/reopen mid-loop and + # cannot be reset by rehydration. Distinct from the watchdog's stall counter. + review_round: int = 0 + # Count of *rejected* ``cb-phase verify`` attempts over the subtask's whole + # life — incremented by the handoff CLI each time a verify gate (clean tree / + # open PR / verify command) fails, never on success. Durable and cumulative + # (never reset on rework), so the per-subtask verify-attempt cap survives a + # crash/reopen and cannot be gamed by bouncing through review. Bounds the + # productive verify-rejection loop the watchdog's stall cap cannot catch — + # the coder commits real code each attempt, so git HEAD keeps advancing. + # Distinct from both ``review_round`` and the watchdog's stall counter. + verify_attempts: int = 0 + # Count of merge-gate send-backs — incremented by the FSM each time the + # subtask *enters* ``needs_rebase`` (one send-back = one rebase round). + # Durable, like ``review_round``, so the per-subtask rebase-round cap + # survives a crash/reopen mid-loop. Bounds the rebase loop neither sibling + # cap can see: each round writes fresh transition rows (so the watchdog's + # stall cap by construction never fires) and never enters ``review_failed`` + # (so the review-round cap never counts it). + rebase_rounds: int = 0 + # ── Merge-approval grant (Stage-2 merge leg) ───────────────────────────── + # The durable record ``cb-phase merge`` queries before executing a merge. + # ``cb approve`` writes the grant, SHA-pinned to the PR head it approved + # (``merge_approved_sha``); the merge leg proceeds only when the grant's + # SHA equals the SHA recorded on the ``merge_pending`` transition, so a + # grant from a pre-rebase round can never authorize a different commit. + # ``merge_approved_by`` records on whose authority the grant stands (the + # task's snapshotted approver spec, e.g. ``owner`` / ``human:``). + merge_approved_by: str | None = None + merge_approved_sha: str | None = None + # SHA the most recent approval *request* was sent for — the send-once + # marker, burned only after a successful send (marker-after-send, same + # policy as the watchdog escalations). SHA-scoped so a needs_rebase round + # trip (new merge_pending SHA) naturally re-requests approval. + merge_approval_requested_sha: str | None = None + # ── Durable reviewer identity (item-0) ─────────────────────────────────── + # The Band agent_id of the reviewer that rendered the LATEST verdict on this + # subtask, stamped by ``cb-phase review`` at the verdict site. Companion to + # ``assigned_worker`` (the coder's agent_id, stamped at start→in_progress). + # Advisory: a forensic / attribution aid, never a gate; ``None`` whenever the + # reviewer's identity could not be resolved (the FSM verdict still records). + assigned_reviewer: str | None = None + + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + description TEXT NOT NULL, + room_id TEXT NOT NULL, + created_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + owner_id TEXT, + owner_handle TEXT, + required_verdicts TEXT, + merge_approval TEXT +); + +CREATE TABLE IF NOT EXISTS subtask_states ( + subtask_id TEXT NOT NULL, + task_id TEXT NOT NULL REFERENCES tasks(task_id), + state TEXT NOT NULL DEFAULT 'planned', + assigned_worker TEXT, + pr_number INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + metadata TEXT, + review_round INTEGER NOT NULL DEFAULT 0, + verify_attempts INTEGER NOT NULL DEFAULT 0, + rebase_rounds INTEGER NOT NULL DEFAULT 0, + merge_approved_by TEXT, + merge_approved_sha TEXT, + merge_approval_requested_sha TEXT, + assigned_reviewer TEXT, + PRIMARY KEY (task_id, subtask_id) +); + +CREATE TABLE IF NOT EXISTS transition_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subtask_id TEXT NOT NULL, + task_id TEXT NOT NULL, + from_state TEXT NOT NULL, + to_state TEXT NOT NULL, + caller_role TEXT NOT NULL, + timestamp TEXT NOT NULL, + reason TEXT, + head_sha TEXT, + -- Hash chain (Stage-3): row_hash = SHA-256 over the canonical business + -- columns (see TRANSITION_HASH_COLS) plus prev_hash. Single global chain + -- ordered by id; genesis prev_hash = GENESIS_PREV_HASH. Nullable so the + -- guarded migration can ADD the columns and backfill in id order. + prev_hash TEXT, + row_hash TEXT +); + +-- Append-only audit of NON-transition effects (approval grants, +-- approval-request markers, pr_number bindings, ungated_external_merge). +-- Kept SEPARATE from transition_log so the watchdog's transition patrols keep +-- their "FSM transitions only" reading. Own hash chain (AUDIT_HASH_COLS), +-- genesis prev_hash = GENESIS_PREV_HASH. ``actor_*`` capture the writing +-- process for the row-5 forensics question ("which process ran this?"). +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + event_type TEXT NOT NULL, + task_id TEXT, + subtask_id TEXT, + payload TEXT, + actor_cwd TEXT, + actor_pid INTEGER, + actor_role TEXT, + prev_hash TEXT NOT NULL, + row_hash TEXT NOT NULL +); + +-- Every hot transition_log query (merge-eligibility legs, the watchdog's +-- recency/blocked-reason readers, the merge leg's pending-SHA anchor) filters +-- on exactly (task_id, subtask_id). ``IF NOT EXISTS`` + running on every +-- ``StateStore`` construction makes this both the fresh-DB path and the +-- migration path for pre-index DBs. +CREATE INDEX IF NOT EXISTS idx_transition_log_task_subtask + ON transition_log(task_id, subtask_id); + +-- Durable per-agent processed-message record for the jam delivery path. +-- Keyed by (scope, message_id) so distinct agents never collide and the same +-- agent cannot false-skip a legitimately different message. Written immediately +-- after ``adapter.on_event`` completes (before the ack), so a failed ack +-- followed by a JamAgent restart finds the record and skips re-delivery. +-- ``CREATE TABLE IF NOT EXISTS`` is idempotent on existing DBs — no ALTER TABLE +-- migration is needed. Not hash-chained (advisory record, not an FSM effect). +CREATE TABLE IF NOT EXISTS jam_processed_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL, + message_id TEXT NOT NULL, + handled_at TEXT NOT NULL, + UNIQUE(scope, message_id) +); +CREATE INDEX IF NOT EXISTS idx_jam_processed_lookup + ON jam_processed_messages(scope, message_id); +""" + + +class StateStore: + """Typed, durable SQLite store for task / subtask orchestration state. + + Construct against a DB path (created, with its parent directory, if + missing); the schema is initialised idempotently. Methods are safe to call + from multiple processes sharing the same file. + """ + + def __init__(self, db_path: Path | str) -> None: + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._init_schema() + + # ── connection plumbing ──────────────────────────────────────────────── + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Connection]: + """Yield a connection inside a short atomic transaction. + + Opens a fresh WAL-mode connection with a busy timeout, commits on + success, rolls back on error, and always closes. Keeping connections + short-lived (rather than one long-lived handle) is what makes + cross-process concurrency safe. + """ + conn = sqlite3.connect( + self.db_path, + timeout=30.0, + isolation_level="DEFERRED", + ) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("PRAGMA foreign_keys=ON") + try: + with closing(conn): + with conn: # commit / rollback + yield conn + except sqlite3.Error: + logger.exception("StateStore transaction failed (db=%s)", self.db_path) + raise + + def _init_schema(self) -> None: + with self._transaction() as conn: + conn.executescript(_SCHEMA) + self._migrate(conn) + + @staticmethod + def _migrate(conn: sqlite3.Connection) -> None: + """Apply additive column migrations to a pre-existing schema. + + ``CREATE TABLE IF NOT EXISTS`` is a no-op against a DB created by an + older version, so a column added after first release must be patched in + with ``ALTER TABLE``. Each migration is guarded on ``PRAGMA + table_info`` so it runs at most once and never on a fresh DB. The + ``DEFAULT 0`` backfills existing rows, so a subtask that predates the + review-round cap simply starts the loop at round 0. + + Structural changes are NOT migrated: the task-scoped composite key on + ``subtask_states`` / ``transition_log`` only applies to freshly created + tables (``CREATE TABLE IF NOT EXISTS`` cannot rewrite a PRIMARY KEY), + so pre-existing dev ``orchestration.db`` files must be deleted. + """ + cols = { + row[1] + for row in conn.execute("PRAGMA table_info(subtask_states)").fetchall() + } + if "review_round" not in cols: + conn.execute( + "ALTER TABLE subtask_states " + "ADD COLUMN review_round INTEGER NOT NULL DEFAULT 0" + ) + if "verify_attempts" not in cols: + conn.execute( + "ALTER TABLE subtask_states " + "ADD COLUMN verify_attempts INTEGER NOT NULL DEFAULT 0" + ) + if "rebase_rounds" not in cols: + conn.execute( + "ALTER TABLE subtask_states " + "ADD COLUMN rebase_rounds INTEGER NOT NULL DEFAULT 0" + ) + if "merge_approved_by" not in cols: + conn.execute( + "ALTER TABLE subtask_states ADD COLUMN merge_approved_by TEXT" + ) + if "merge_approved_sha" not in cols: + conn.execute( + "ALTER TABLE subtask_states ADD COLUMN merge_approved_sha TEXT" + ) + if "merge_approval_requested_sha" not in cols: + conn.execute( + "ALTER TABLE subtask_states " + "ADD COLUMN merge_approval_requested_sha TEXT" + ) + if "assigned_reviewer" not in cols: + conn.execute( + "ALTER TABLE subtask_states ADD COLUMN assigned_reviewer TEXT" + ) + task_cols = { + row[1] for row in conn.execute("PRAGMA table_info(tasks)").fetchall() + } + if "owner_id" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN owner_id TEXT") + if "owner_handle" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN owner_handle TEXT") + if "required_verdicts" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN required_verdicts TEXT") + if "merge_approval" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN merge_approval TEXT") + log_cols = { + row[1] + for row in conn.execute("PRAGMA table_info(transition_log)").fetchall() + } + if "head_sha" not in log_cols: + conn.execute("ALTER TABLE transition_log ADD COLUMN head_sha TEXT") + # Hash-chain columns (Stage-3). Added nullable, then backfilled over + # existing rows in id order so a pre-Stage-3 history becomes a valid + # chain AS OF this migration — pre-migration history is attested as of + # now, not from origin (a row deleted before the migration leaves no + # trace to detect). New rows are chained at write time by the FSM. + if "prev_hash" not in log_cols: + conn.execute("ALTER TABLE transition_log ADD COLUMN prev_hash TEXT") + if "row_hash" not in log_cols: + conn.execute("ALTER TABLE transition_log ADD COLUMN row_hash TEXT") + if "prev_hash" not in log_cols or "row_hash" not in log_cols: + StateStore._backfill_transition_chain(conn) + + @staticmethod + def _backfill_transition_chain(conn: sqlite3.Connection) -> None: + """Compute the hash chain over existing ``transition_log`` rows in id order. + + Run once, from :meth:`_migrate`, when the chain columns were just + added. Walks every row oldest-first, linking each to the prior row's + ``row_hash`` (genesis for the first), and writes ``prev_hash`` / + ``row_hash``. Idempotent in effect — only rows missing a ``row_hash`` + are (re)written, so a partially-backfilled DB completes cleanly. + """ + rows = conn.execute( + "SELECT * FROM transition_log ORDER BY id ASC" + ).fetchall() + prev_hash = GENESIS_PREV_HASH + for row in rows: + row_hash = compute_row_hash(row, TRANSITION_HASH_COLS, prev_hash) + conn.execute( + "UPDATE transition_log SET prev_hash = ?, row_hash = ? " + "WHERE id = ?", + (prev_hash, row_hash, row["id"]), + ) + prev_hash = row_hash + + # ── tasks ────────────────────────────────────────────────────────────── + + def create_task( + self, + task_id: str, + description: str, + room_id: str, + *, + created_at: str | None = None, + status: str = "active", + owner_id: str | None = None, + ) -> None: + """Insert a task row (idempotent on ``task_id``). + + Internal to task registration — do not call directly; use + :func:`codeband.state.registration.register_task`, the sole writer of + "a task exists". Re-creating the same task is a no-op rather than an + error, so a retried registration against an existing DB stays safe. + """ + with self._transaction() as conn: + conn.execute( + "INSERT OR IGNORE INTO tasks " + "(task_id, description, room_id, created_at, status, owner_id) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + task_id, + description, + room_id, + created_at or _now_iso(), + status, + owner_id, + ), + ) + + def register_task_atomic( + self, + *, + task_id: str, + description: str, + room_id: str, + owner_id: str, + required_verdicts: list[str], + merge_approval: str = "owner", + owner_handle: str | None = None, + supersede_task_id: str | None = None, + ) -> str: + """Apply one task registration's DB mutations in a single transaction. + + Status-update + upsert support for ``register_task`` + (``state/registration.py``) — the supersede of the previously active + task and the insert/update of the registered one must land atomically, + so a crash between them cannot leave two active tasks or none: + + * If ``supersede_task_id`` is given, that row's status is set to + ``'superseded'`` — but only while it is still ``'active'`` + (idempotent UPDATE; a missing row is a no-op, and a terminal row + — ``completed``/``superseded`` — is never rewritten: a task that + genuinely completed must not be retroactively relabeled). + * If a row for ``task_id`` already exists, ``owner_id`` / + ``owner_handle`` / ``required_verdicts`` / ``merge_approval`` are + updated AND ``status`` is restored to ``'active'`` — description and + created_at are deliberately left untouched (re-registration changes + ownership and refreshes the verdict + approver snapshots from + *current* config, not history). Restoring ``'active'`` is what makes + re-registering a previously ``'superseded'`` room (the sanctioned + identity-rotation path) leave exactly one active task — without it + the system ends with ZERO active tasks: the watchdog patrols + nothing and completion promotion never fires while ``cb-phase`` + keeps advancing subtasks. Re-registering a ``'completed'`` task's + room reactivates it too — intended continue-work semantics: the + owner is deliberately pointing new work at the finished task's room. + * Otherwise a fresh ``'active'`` row is inserted. + + ``required_verdicts`` and ``merge_approval`` are already resolved and + validated by ``register_task`` — this method only persists them + (the verdict list JSON-encoded, the approver spec verbatim). + + Returns ``"inserted"`` or ``"updated"`` so the caller can report the + outcome without a second read. + """ + verdicts_json = json.dumps(required_verdicts) + with self._transaction() as conn: + if supersede_task_id is not None: + # Only an ACTIVE task can be superseded. Terminal rows + # (completed/superseded) are never rewritten — without the + # status guard, registering a new task while pointing at a + # finished one falsified the ledger: a task that genuinely + # COMPLETED was retroactively relabeled 'superseded' + # (finding 19 / triage annex A6). + conn.execute( + "UPDATE tasks SET status = 'superseded' " + "WHERE task_id = ? AND status = 'active'", + (supersede_task_id,), + ) + existing = conn.execute( + "SELECT task_id FROM tasks WHERE task_id = ?", (task_id,) + ).fetchone() + if existing is not None: + conn.execute( + "UPDATE tasks SET owner_id = ?, owner_handle = ?, " + "required_verdicts = ?, merge_approval = ?, " + "status = 'active' " + "WHERE task_id = ?", + (owner_id, owner_handle, verdicts_json, merge_approval, + task_id), + ) + return "updated" + conn.execute( + "INSERT INTO tasks " + "(task_id, description, room_id, created_at, status, " + "owner_id, owner_handle, required_verdicts, merge_approval) " + "VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?)", + ( + task_id, + description, + room_id, + _now_iso(), + owner_id, + owner_handle, + verdicts_json, + merge_approval, + ), + ) + return "inserted" + + # ── audit log (Stage-3 evidence integrity) ───────────────────────────── + + @staticmethod + def _append_audit_on_conn( + conn: sqlite3.Connection, + *, + event_type: str, + task_id: str | None, + subtask_id: str | None, + payload: dict[str, Any] | None, + ) -> None: + """Append one hash-chained ``audit_log`` row on an open connection. + + The shared writer behind :meth:`append_audit_event` and the in-line + audit appends inside other store mutations (grant / marker / pr_number), + so an audit row lands in the SAME transaction as the effect it records. + Captures actor context, links to the prior audit-chain head (genesis if + first), and computes ``row_hash`` over the audit business columns. The + caller's transaction must already hold the write lock (the ``audit_log`` + chain is global, so the read-prev → insert → hash sequence must be + serialized — every caller uses ``BEGIN IMMEDIATE``/``EXCLUSIVE``). + """ + cwd, pid, role = _actor_context() + payload_json = json.dumps(payload, sort_keys=True) if payload is not None else None + ts = _now_iso() + cur = conn.execute( + "INSERT INTO audit_log " + "(ts, event_type, task_id, subtask_id, payload, " + "actor_cwd, actor_pid, actor_role, prev_hash, row_hash) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', '')", + (ts, event_type, task_id, subtask_id, payload_json, cwd, pid, role), + ) + new_id = cur.lastrowid + prev = conn.execute( + "SELECT row_hash FROM audit_log " + "WHERE id < ? ORDER BY id DESC LIMIT 1", + (new_id,), + ).fetchone() + prev_hash = prev[0] if prev is not None else GENESIS_PREV_HASH + business = { + "id": new_id, + "ts": ts, + "event_type": event_type, + "task_id": task_id, + "subtask_id": subtask_id, + "payload": payload_json, + "actor_cwd": cwd, + "actor_pid": pid, + "actor_role": role, + } + row_hash = compute_row_hash(business, AUDIT_HASH_COLS, prev_hash) + conn.execute( + "UPDATE audit_log SET prev_hash = ?, row_hash = ? WHERE id = ?", + (prev_hash, row_hash, new_id), + ) + + @contextmanager + def _immediate_transaction(self) -> Iterator[sqlite3.Connection]: + """Like :meth:`_transaction` but acquires the write lock immediately. + + ``BEGIN IMMEDIATE`` serializes the read-prev-head → insert → hash + sequence the audit chain needs against concurrent writers, closing the + fork window a ``DEFERRED`` transaction would leave between the head + read and the insert. + """ + conn = sqlite3.connect(self.db_path, timeout=30.0, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("PRAGMA foreign_keys=ON") + with closing(conn): + conn.execute("BEGIN IMMEDIATE") + try: + yield conn + conn.execute("COMMIT") + except BaseException: + conn.execute("ROLLBACK") + raise + + def append_audit_event( + self, + event_type: str, + *, + task_id: str | None = None, + subtask_id: str | None = None, + payload: dict[str, Any] | None = None, + ) -> None: + """Append a standalone hash-chained ``audit_log`` event. + + The public entry point for callers outside the store (e.g. the merge + leg's ``ungated_external_merge`` event). Opens its own + ``BEGIN IMMEDIATE`` transaction; for an audit row that must be atomic + with another store mutation, that mutation calls + :meth:`_append_audit_on_conn` on its own connection instead. + """ + with self._immediate_transaction() as conn: + self._append_audit_on_conn( + conn, + event_type=event_type, + task_id=task_id, + subtask_id=subtask_id, + payload=payload, + ) + + def latest_audit_events( + self, + *, + task_id: str, + subtask_id: str, + event_types: tuple[str, ...], + ) -> list[tuple[str, str, dict[str, Any] | None]]: + """Return ``[(event_type, ts, payload), …]`` for matching audit rows, + newest first. + + ``event_types`` is an exact-match filter applied in SQL (IN clause). + ``payload`` is the decoded JSON dict, or ``None`` when the column is + absent, empty, or not valid JSON. Filtering by ``approved_sha`` inside + the payload is intentionally left to the caller — SQLite JSON-extract + is not portable enough to rely on here. + """ + if not event_types: + return [] + placeholders = ",".join("?" * len(event_types)) + with self._transaction() as conn: + rows = conn.execute( + f"SELECT event_type, ts, payload FROM audit_log " + f"WHERE task_id = ? AND subtask_id = ? " + f"AND event_type IN ({placeholders}) " + f"ORDER BY ts DESC", + (task_id, subtask_id, *event_types), + ).fetchall() + result: list[tuple[str, str, dict[str, Any] | None]] = [] + for row in rows: + event_type, ts, payload_text = row[0], row[1], row[2] + payload: dict[str, Any] | None = None + if payload_text: + try: + decoded = json.loads(payload_text) + if isinstance(decoded, dict): + payload = decoded + except (ValueError, TypeError): + pass + result.append((event_type, ts, payload)) + return result + + def get_task(self, task_id: str) -> TaskRow | None: + """Return the task row, or ``None`` if it does not exist.""" + with self._transaction() as conn: + row = conn.execute( + "SELECT * FROM tasks WHERE task_id = ?", (task_id,) + ).fetchone() + return _task_from_row(row) if row is not None else None + + def list_active_task_room_ids(self) -> list[str]: + """Room ids of all ``'active'`` tasks. + + Powers the local-mode startup room sweep (``runner``): rooms tied to + an active task are rejoined on reconnect, everything else is skipped. + """ + with self._transaction() as conn: + rows = conn.execute( + "SELECT room_id FROM tasks WHERE status = 'active'" + ).fetchall() + return [row["room_id"] for row in rows] + + def clear_task_records(self) -> None: + """Clear task-room orchestration records for a workspace fresh start. + + Used by the shared-home re-point wipe: once the workspace is being + repurposed for a different repo, every existing task room in this DB is + stale by definition. Delete the task-scoped tables together so startup + recovery, watchdog patrols, and hash-chain verification all see a clean + StateStore instead of orphaned rows from the prior campaign. + """ + with self._immediate_transaction() as conn: + conn.execute("DELETE FROM audit_log") + conn.execute("DELETE FROM transition_log") + conn.execute("DELETE FROM subtask_states") + conn.execute("DELETE FROM tasks") + + # ── subtasks ─────────────────────────────────────────────────────────── + + def ensure_subtask( + self, + subtask_id: str, + task_id: str, + *, + state: str = "planned", + assigned_worker: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + """Create the subtask row if absent; otherwise do nothing. + + Uses ``INSERT OR IGNORE`` so a caller can lazily ensure a subtask + exists before writing to it (the FSM does this before every + transition) without first checking — idempotent and race-safe. + """ + now = _now_iso() + with self._transaction() as conn: + conn.execute( + "INSERT OR IGNORE INTO subtask_states " + "(subtask_id, task_id, state, assigned_worker, pr_number, " + "created_at, updated_at, metadata) " + "VALUES (?, ?, ?, ?, NULL, ?, ?, ?)", + ( + subtask_id, + task_id, + state, + assigned_worker, + now, + now, + json.dumps(metadata) if metadata is not None else None, + ), + ) + + def get_subtask(self, subtask_id: str, task_id: str) -> SubtaskRow | None: + """Return the subtask row for ``(task_id, subtask_id)``, or ``None``. + + Subtask ids are only unique *within* a task (planners emit ``st-1``, + ``st-2``, … fresh per plan), so the lookup requires both keys — there + is deliberately no unscoped fallback. + """ + with self._transaction() as conn: + row = conn.execute( + "SELECT * FROM subtask_states " + "WHERE task_id = ? AND subtask_id = ?", + (task_id, subtask_id), + ).fetchone() + return _subtask_from_row(row) if row is not None else None + + def increment_verify_attempts(self, subtask_id: str, task_id: str) -> int: + """Atomically bump ``verify_attempts`` for a subtask; return the new count. + + Called by the ``cb-phase`` handoff CLI on each *rejected* verify attempt + (a failed gate), never on success. The bump is a single ``UPDATE`` inside + a short transaction, so it is durable the instant it commits and survives + a crash/reopen — a coder that crashes mid-loop cannot reset its budget. + Treats an absent ``verify_attempts`` value as 0 via ``COALESCE`` for + defence against any pre-migration NULL. Returns the post-increment count + (``0`` if the subtask row does not exist, i.e. nothing was updated) so the + caller can compare against the cap without a second read. + """ + with self._transaction() as conn: + conn.execute( + "UPDATE subtask_states " + "SET verify_attempts = COALESCE(verify_attempts, 0) + 1, " + "updated_at = ? WHERE task_id = ? AND subtask_id = ?", + (_now_iso(), task_id, subtask_id), + ) + row = conn.execute( + "SELECT verify_attempts FROM subtask_states " + "WHERE task_id = ? AND subtask_id = ?", + (task_id, subtask_id), + ).fetchone() + return row["verify_attempts"] if row is not None else 0 + + def set_pr_number(self, subtask_id: str, task_id: str, pr_number: int) -> None: + """Persist the subtask's PR number (idempotent UPDATE). + + Written by ``cb-phase merge`` on its first invocation (the only leg + that needs the binding durably): the crash-reconcile path re-invokes + with no arguments and reads this back to query the PR's state. Also + feeds the watchdog's existing PR-activity progress signal. Each binding + appends an ``audit_log`` row in the same transaction (Stage-3). + """ + with self._immediate_transaction() as conn: + conn.execute( + "UPDATE subtask_states SET pr_number = ?, updated_at = ? " + "WHERE task_id = ? AND subtask_id = ?", + (pr_number, _now_iso(), task_id, subtask_id), + ) + self._append_audit_on_conn( + conn, + event_type="pr_number_binding", + task_id=task_id, + subtask_id=subtask_id, + payload={"pr_number": pr_number}, + ) + + def set_assigned_worker( + self, subtask_id: str, task_id: str, agent_id: str + ) -> None: + """Stamp the coder's Band agent_id onto the subtask (item-0). + + Written by ``cb-phase start`` when the assigned → in_progress edge is + driven by a coder. Advisory (no audit row): the watchdog reads it as a + chat-mention id and rehydration shows it for attribution. A plain + ``UPDATE`` (same shape as :meth:`set_pr_number`) — last-writer-wins is + truthful, and the caller only invokes it with a resolved, non-empty id. + """ + with self._transaction() as conn: + conn.execute( + "UPDATE subtask_states SET assigned_worker = ?, updated_at = ? " + "WHERE task_id = ? AND subtask_id = ?", + (agent_id, _now_iso(), task_id, subtask_id), + ) + + def set_assigned_reviewer( + self, subtask_id: str, task_id: str, agent_id: str + ) -> None: + """Stamp the reviewer's Band agent_id onto the subtask (item-0). + + Written by ``cb-phase review`` at the verdict site (approve or reject). + Advisory, same contract as :meth:`set_assigned_worker`; records who + rendered the LATEST verdict (last-writer-wins across re-review rounds). + """ + with self._transaction() as conn: + conn.execute( + "UPDATE subtask_states SET assigned_reviewer = ?, updated_at = ? " + "WHERE task_id = ? AND subtask_id = ?", + (agent_id, _now_iso(), task_id, subtask_id), + ) + + def record_merge_approval( + self, + subtask_id: str, + task_id: str, + *, + approved_by: str, + approved_sha: str, + ) -> None: + """Durably record a merge-approval grant, SHA-pinned. + + Written by ``cb approve`` (the single human-facing approval entry + point). ``approved_sha`` is the PR head SHA at approval time; + ``cb-phase merge`` executes only when it equals the SHA recorded on + the ``merge_pending`` transition, so a push after approval (or a grant + from a pre-rebase round) can never authorize a different commit. + Re-approval overwrites the previous grant on the live columns (latest + grant wins) — but grant HISTORY is append-only in ``audit_log`` (a row + per grant, Stage-3), so re-approval no longer erases the prior grant + from the record. + """ + with self._immediate_transaction() as conn: + conn.execute( + "UPDATE subtask_states " + "SET merge_approved_by = ?, merge_approved_sha = ?, " + "updated_at = ? WHERE task_id = ? AND subtask_id = ?", + (approved_by, approved_sha, _now_iso(), task_id, subtask_id), + ) + self._append_audit_on_conn( + conn, + event_type="approval_grant", + task_id=task_id, + subtask_id=subtask_id, + payload={"approved_by": approved_by, "approved_sha": approved_sha}, + ) + + def mark_merge_approval_requested( + self, subtask_id: str, task_id: str, requested_sha: str, + ) -> None: + """Burn the send-once marker for an approval request at ``requested_sha``. + + Called by ``cb-phase merge`` strictly *after* a successful request + send (marker-after-send — a failed send retries on the next + invocation). SHA-scoped: a later ``merge_pending`` round at a new SHA + compares unequal and re-requests. Appends an ``audit_log`` row for the + marker write in the same transaction (Stage-3). + """ + with self._immediate_transaction() as conn: + conn.execute( + "UPDATE subtask_states " + "SET merge_approval_requested_sha = ?, updated_at = ? " + "WHERE task_id = ? AND subtask_id = ?", + (requested_sha, _now_iso(), task_id, subtask_id), + ) + self._append_audit_on_conn( + conn, + event_type="approval_request", + task_id=task_id, + subtask_id=subtask_id, + payload={"requested_sha": requested_sha}, + ) + + def find_subtasks_by_pr(self, task_id: str, pr_number: int) -> list[SubtaskRow]: + """Return this task's subtasks bound to ``pr_number``, oldest first. + + Used by ``cb approve `` to resolve which subtask a PR-keyed grant + lands on. Only finds subtasks whose ``pr_number`` was persisted (i.e. + ones that have entered the ``cb-phase merge`` leg) — legacy chat-only + flows match nothing and record no grant. + """ + with self._transaction() as conn: + rows = conn.execute( + "SELECT * FROM subtask_states " + "WHERE task_id = ? AND pr_number = ? " + "ORDER BY created_at ASC, subtask_id ASC", + (task_id, pr_number), + ).fetchall() + return [_subtask_from_row(row) for row in rows] + + def list_active_subtasks(self, task_id: str | None = None) -> list[SubtaskRow]: + """Return non-terminal subtasks, newest first. + + "Active" means any state not in :data:`TERMINAL_STATES`. Pass + ``task_id`` to scope the query to one task. + """ + placeholders = ",".join("?" * len(TERMINAL_STATES)) + params: list[Any] = list(TERMINAL_STATES) + sql = f"SELECT * FROM subtask_states WHERE state NOT IN ({placeholders})" + if task_id is not None: + sql += " AND task_id = ?" + params.append(task_id) + sql += " ORDER BY created_at DESC, subtask_id DESC" + with self._transaction() as conn: + rows = conn.execute(sql, params).fetchall() + return [_subtask_from_row(row) for row in rows] + + def batch_latest_transitions( + self, + subtask_keys: list[tuple[str, str]], + ) -> dict[tuple[str, str], datetime | None]: + """Return the latest transition timestamp for each (task_id, subtask_id) pair. + + A single query replaces N per-subtask ``_latest_transition`` calls in the + watchdog patrol (N+1 elimination). Keys present in the result but with a + ``None`` value had no ``transition_log`` rows yet. Keys absent from the + result were not found (treat as no rows, i.e. ``None``). Raises on DB + error so the caller can decide whether to fall back. + """ + if not subtask_keys: + return {} + conditions = " OR ".join( + "(task_id = ? AND subtask_id = ?)" for _ in subtask_keys + ) + params: list[Any] = [v for (tid, sid) in subtask_keys for v in (tid, sid)] + sql = ( + f"SELECT task_id, subtask_id, MAX(timestamp) " + f"FROM transition_log WHERE {conditions} " + f"GROUP BY task_id, subtask_id" + ) + with self._transaction() as conn: + rows = conn.execute(sql, params).fetchall() + result: dict[tuple[str, str], datetime | None] = {} + for row in rows: + key = (row[0], row[1]) + try: + result[key] = datetime.fromisoformat(row[2]) if row[2] is not None else None + except (ValueError, TypeError): + result[key] = None + return result + + # ── jam durable processed-message dedupe ────────────────────────────────── + + def mark_jam_message_handled(self, scope: str, message_id: str) -> None: + """Record that a jam inbound message was successfully handled. + + Written immediately after ``adapter.on_event`` completes and BEFORE the + ack, so a failed ack followed by a ``JamAgent`` restart finds this record + and skips re-delivery. ``INSERT OR IGNORE`` makes it idempotent — a + duplicate call (e.g. a same-process re-delivery race) is a no-op. + """ + with self._transaction() as conn: + conn.execute( + "INSERT OR IGNORE INTO jam_processed_messages " + "(scope, message_id, handled_at) VALUES (?, ?, ?)", + (scope, message_id, _now_iso()), + ) + + def is_jam_message_handled(self, scope: str, message_id: str) -> bool: + """Return True if a durable handled record exists for this (scope, message_id). + + Used by ``JamAgent._RoomWorker._process`` before invoking the handler on + a re-queued message (failed-ack + restart). Returns False on any DB + error — the caller wraps this in its own try/except and fails-open so a + transient outage never blocks delivery. + """ + with self._transaction() as conn: + row = conn.execute( + "SELECT EXISTS (SELECT 1 FROM jam_processed_messages " + "WHERE scope = ? AND message_id = ?)", + (scope, message_id), + ).fetchone() + return bool(row[0]) if row is not None else False + + +def _task_from_row(row: sqlite3.Row) -> TaskRow: + # ``owner_id`` / ``owner_handle`` may be absent on rows fetched before the + # migration ran (or in a hand-built row in tests); tolerate their absence + # rather than KeyError. + keys = row.keys() + raw_verdicts = row["required_verdicts"] if "required_verdicts" in keys else None + return TaskRow( + task_id=row["task_id"], + description=row["description"], + room_id=row["room_id"], + created_at=row["created_at"], + status=row["status"], + owner_id=row["owner_id"] if "owner_id" in keys else None, + owner_handle=row["owner_handle"] if "owner_handle" in keys else None, + required_verdicts=json.loads(raw_verdicts) if raw_verdicts else None, + merge_approval=row["merge_approval"] if "merge_approval" in keys else None, + ) + + +def _subtask_from_row(row: sqlite3.Row) -> SubtaskRow: + raw_metadata = row["metadata"] + return SubtaskRow( + subtask_id=row["subtask_id"], + task_id=row["task_id"], + state=row["state"], + assigned_worker=row["assigned_worker"], + pr_number=row["pr_number"], + created_at=row["created_at"], + updated_at=row["updated_at"], + metadata=json.loads(raw_metadata) if raw_metadata else None, + review_round=row["review_round"], + verify_attempts=row["verify_attempts"], + rebase_rounds=row["rebase_rounds"], + merge_approved_by=row["merge_approved_by"], + merge_approved_sha=row["merge_approved_sha"], + merge_approval_requested_sha=row["merge_approval_requested_sha"], + assigned_reviewer=row["assigned_reviewer"], + ) diff --git a/src/codeband/transport/__init__.py b/src/codeband/transport/__init__.py new file mode 100644 index 0000000..d367ed6 --- /dev/null +++ b/src/codeband/transport/__init__.py @@ -0,0 +1,17 @@ +"""Opt-in jam delivery transport (``CODEBAND_DELIVERY=jam``). + +This package implements the wedge-immune message-delivery path: instead of the +SDK ExecutionContext's WebSocket + ``/next`` server cursor (which can pin on a +swallowed mark-processed 422), inbound messages are pulled from the local jam +daemon (``jamd``) over its wire-stable Unix-socket Control contract — a durable +per-peer queue with non-fatal acks and no head-of-line cursor. + +Everything here is **dormant unless** ``runner._resolve_delivery_mode`` resolves +to ``jam`` — the modules are imported lazily inside that branch only, so the +default ``sdk`` path never touches this code. The brain (FSM, gates, cb-phase, +StateStore, watchdog, pool, auth, preflight, doctor) and the wedge-recovery +machinery (#102/#103/watchdog heal rung) are unchanged and still cover the +``sdk`` path. +""" + +from __future__ import annotations diff --git a/src/codeband/transport/jam_control.py b/src/codeband/transport/jam_control.py new file mode 100644 index 0000000..f596078 --- /dev/null +++ b/src/codeband/transport/jam_control.py @@ -0,0 +1,173 @@ +"""Async client for jamd's Unix-socket Control contract (jam-contract, tjam 0.2.7). + +jamd serves HTTP/1.1 + JSON over a Unix socket at ``$JAM_CONFIG_DIR/jam.sock`` +(default ``$HOME/.jam/jam.sock``), ``0600``, UID-filtered. This module speaks the +small subset codeband's delivery path needs: + +* ``POST /v1/adopt`` — ``AdoptReq{opts: EnsureOpts, agent_key}`` → bring an + EXISTING Band agent online as a ``generic`` (Pull) peer (onboarding). +* ``POST /v1/inbox`` — ``{target}`` → ``{messages: [Message,…]}`` (the durable + per-peer queue; un-acked messages, FIFO within each room). +* ``POST /v1/ack`` — ``{target, msg_id}`` → ``200`` on success; on the + underlying mark-processed failure (the 422) a 5xx with the message left + **queued and retriable**. This is the non-wedge property — :meth:`ack` never + raises on a rejection; it returns an :class:`AckOutcome`. +* ``POST /v1/send`` / ``POST /v1/reply`` — outbound (not used by the default + inbound path, which keeps outbound on the SDK REST tools, but provided for + completeness/tests). +* ``GET /v1/ping`` — readiness probe. + +The wire ``Message`` carries ``sender_id`` and ``sender_type`` (verified against +tjam ``jam-domain/src/message.rs``), which is why the socket — not the lossy +``jam inbox`` CLI — is the inbound source: faithful self-message filtering needs +both fields. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Stay under jamd's 45s default unary timeout. +_DEFAULT_TIMEOUT_SECONDS = 30.0 + +# Base URL is irrelevant over a UDS transport (httpx still needs a valid host). +_BASE_URL = "http://jamd.local" + + +class JamControlError(RuntimeError): + """A Control call failed in a way the caller should treat as fatal-ish. + + Used for non-ack calls (adopt/inbox/send/reply). Ack rejections are NOT + raised — they return an :class:`AckOutcome` (non-fatal by contract). + """ + + +def socket_path() -> str: + """Resolve jamd's control socket: ``$JAM_CONFIG_DIR/jam.sock`` else ``~/.jam``.""" + cfg = os.environ.get("JAM_CONFIG_DIR") + base = Path(cfg) if cfg else Path.home() / ".jam" + return str(base / "jam.sock") + + +def agent_scope(agent_id: str) -> str: + """Deterministic per-agent jam peer scope for an adopted codeband agent.""" + return f"codeband-{agent_id}" + + +@dataclass(frozen=True) +class Target: + """Addresses which adopted peer a Control call acts as (``Target`` DTO).""" + + profile: str = "default" + scope: str = "" + handle: str = "" + + def as_dict(self) -> dict[str, str]: + return {"profile": self.profile, "scope": self.scope, "handle": self.handle} + + +@dataclass(frozen=True) +class AckOutcome: + """Result of an ack. ``ok=False`` is non-fatal — the message stays queued.""" + + ok: bool + error: str | None = None + + +class JamControlClient: + """Thin async HTTP/JSON-over-UDS client for jamd's Control contract.""" + + def __init__(self, *, socket: str | None = None, timeout: float = _DEFAULT_TIMEOUT_SECONDS): + self._socket = socket or socket_path() + self._timeout = timeout + self._client: Any = None # lazily-built httpx.AsyncClient + + def _ensure_client(self) -> Any: + if self._client is None: + import httpx + + self._client = httpx.AsyncClient( + transport=httpx.AsyncHTTPTransport(uds=self._socket), + base_url=_BASE_URL, + timeout=self._timeout, + ) + return self._client + + async def close(self) -> None: + if self._client is not None: + try: + await self._client.aclose() + finally: + self._client = None + + async def ping(self) -> bool: + """Best-effort readiness probe; never raises.""" + try: + resp = await self._ensure_client().get("/v1/ping") + return resp.status_code == 200 + except Exception as exc: # noqa: BLE001 - probe is advisory + logger.debug("jam ping failed: %s", exc) + return False + + async def adopt(self, opts: dict[str, Any], agent_key: str) -> None: + """Adopt an existing Band agent as a generic (Pull) peer. Idempotent.""" + resp = await self._ensure_client().post( + "/v1/adopt", json={"opts": opts, "agent_key": agent_key} + ) + if resp.status_code >= 400: + raise JamControlError(f"adopt failed: HTTP {resp.status_code}: {resp.text[:300]}") + + async def inbox(self, target: Target) -> list[dict[str, Any]]: + """Return the peer's un-acked messages (full wire ``Message`` dicts).""" + resp = await self._ensure_client().post("/v1/inbox", json={"target": target.as_dict()}) + if resp.status_code >= 400: + raise JamControlError(f"inbox failed: HTTP {resp.status_code}: {resp.text[:300]}") + data = resp.json() + return list(data.get("messages") or []) + + async def ack(self, target: Target, msg_id: str) -> AckOutcome: + """Mark a message processed. NEVER raises on a rejection (non-wedge). + + A 5xx (the swallowed-422 case) or a transport error leaves the message + queued and retriable; the caller logs and moves on. Other messages are + unaffected — there is no head-of-line cursor. + """ + try: + resp = await self._ensure_client().post( + "/v1/ack", json={"target": target.as_dict(), "msg_id": msg_id} + ) + except Exception as exc: # noqa: BLE001 - ack must be non-fatal + return AckOutcome(ok=False, error=f"transport: {exc}") + if resp.status_code == 200: + return AckOutcome(ok=True) + return AckOutcome(ok=False, error=f"HTTP {resp.status_code}: {resp.text[:200]}") + + async def send( + self, target: Target, chat_id: str, content: str, recipient_ids: tuple[str, ...] = () + ) -> dict[str, Any]: + resp = await self._ensure_client().post( + "/v1/send", + json={ + "target": target.as_dict(), + "chat_id": chat_id, + "content": content, + "recipient_ids": list(recipient_ids), + }, + ) + if resp.status_code >= 400: + raise JamControlError(f"send failed: HTTP {resp.status_code}: {resp.text[:300]}") + return resp.json() + + async def reply(self, target: Target, msg_id: str, text: str) -> dict[str, Any]: + resp = await self._ensure_client().post( + "/v1/reply", json={"target": target.as_dict(), "msg_id": msg_id, "text": text} + ) + if resp.status_code >= 400: + raise JamControlError(f"reply failed: HTTP {resp.status_code}: {resp.text[:300]}") + return resp.json() diff --git a/src/codeband/transport/jam_runtime.py b/src/codeband/transport/jam_runtime.py new file mode 100644 index 0000000..6a57c98 --- /dev/null +++ b/src/codeband/transport/jam_runtime.py @@ -0,0 +1,517 @@ +"""``JamAgent`` — a jam-backed agent runtime with the ``thenvoi.Agent`` lifecycle. + +Exposes the same ``.run()`` / ``.stop()`` contract the reconnect-forever loop +already depends on (``orchestration/runner.py``), but replaces the SDK's +WebSocket + ``/next`` ingestion with the jam Pull path: + +* **onboarding**: ``start()`` adopts this existing Band agent as a ``generic`` + (Pull) peer over jamd's socket (idempotent), then runs the same adapter + handshake the SDK does (``on_started`` after fetching agent metadata). +* **inbound**: a **dispatcher** polls ``inbox`` (the durable per-peer queue) and + routes each message to a **per-room worker**. Each worker owns one reused + ``ExecutionContext`` and processes its room's queue **serially** — mirroring + the SDK's one-ExecutionContext-and-loop-per-room — so workers run concurrently + across rooms (no cross-room head-of-line) while preserving per-room order. +* **per message**: the worker reproduces the SDK ``ExecutionContext`` semantics + that matter — self-message filter, ``MessageRetryTracker`` budget (attempt + recorded BEFORE processing, as the SDK does), context hydration before the + preprocessor — then runs the SAME ``DefaultPreprocessor`` + ``adapter.on_event`` + so the brain receives an identical ``AgentInput``. ``mark_processing`` is NOT + reproduced: jamd already did it (non-fatally) on enqueue. +* **ack**: on success the worker acks; a rejected ack (the swallowed-422 case) is + **cosmetic** — the message stays queued, other messages keep flowing, nothing + wedges. On handler failure the message is left un-acked (jam redelivers, + at-least-once) until the retry budget trips, then it is acked-to-drain so a + poison message cannot redeliver forever (jam has no ``mark_failed`` verb). + +**Outbound is unchanged**: the adapter's ``tools.send_message`` still posts over +the SDK REST client (``ThenvoiLink.rest``) — already transport-agnostic and +non-wedging — so ``AgentTools`` is byte-identical to the SDK path. + +──────────────────────────────────────────────────────────────────────────── +SDK-INTERNALS COUPLING — RE-VERIFY ON ANY band-sdk BUMP +──────────────────────────────────────────────────────────────────────────── +This runtime deliberately bypasses the SDK's public ``Agent.create(...).run()`` +facade (that facade IS the wedging ``/next`` path) and reassembles the pieces +underneath it. It therefore depends on band-sdk surfaces that carry NO stability +promise — a minor SDK upgrade can move/rename them and silently break this path +(the default ``sdk`` path depends on none of them). The surfaces, most-fragile +first: + +* ``thenvoi.runtime.execution.ExecutionContext._ensure_fresh_context()`` — a + PRIVATE method we call directly to hydrate participants/history. Highest risk. +* ``thenvoi.runtime.execution.ExecutionContext`` — constructed here with a no-op + ``on_execute`` and its WS/``/next`` loop never started (used only as the + ``AgentTools.from_context`` source + preprocessor input). +* adapter ``_thenvoi_agent_id`` — a private attribute the SDK's ``Agent.start`` + sets; we set it ourselves before ``adapter.on_started`` / ``adapter.on_event``. +* ``thenvoi.preprocessing.default.DefaultPreprocessor`` (+ its ``process`` shape). +* ``thenvoi.runtime.retry_tracker.MessageRetryTracker`` (record_attempt/ + is_permanently_failed/mark_success). +* ``thenvoi.client.streaming.MessageCreatedPayload`` / ``MessageMetadata`` — the + inbound payload models (the preprocessor reads ``payload.inserted_at``). +* ``thenvoi.platform.{link.ThenvoiLink, event.MessageEvent}``. + +Tripwires: (1) the tests exercise the REAL preprocessor/ExecutionContext, so a +``pip install`` against a new SDK + ``pytest`` fails loudly; (2) +``doctor.check_jam_delivery_sdk_coupling`` imports each symbol above and reports +a clear, actionable failure at ``cb doctor`` time. If you bump band-sdk and +either trips, re-verify this module against the new SDK shapes. band-sdk is +pinned ``>=0.2.8,<0.3``; the ``1.0`` ``thenvoi``→``band`` rename is a whole-repo +migration that will touch this module too. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections import OrderedDict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from codeband.transport.jam_control import ( + AckOutcome, + JamControlClient, + JamControlError, + Target, + agent_scope, +) + +logger = logging.getLogger(__name__) + +# Consecutive inbox-poll failures tolerated before run() returns (→ reconnect). +_MAX_INBOX_FAILURES = 5 +# Dedupe-set bound (mirrors the SDK's _processed_ids cap shape). +_HANDLED_MAX = 4096 + + +def _iso_from_epoch_ms(ms: Any) -> str: + """tjam ``enqueued_at`` is unix epoch milliseconds; render ISO-8601 UTC.""" + try: + if ms: + return datetime.fromtimestamp(float(ms) / 1000.0, tz=timezone.utc).isoformat() + except (TypeError, ValueError, OverflowError, OSError): + pass + return datetime.now(timezone.utc).isoformat() + + +async def _noop_handler(ctx: Any, event: Any) -> None: + """Required ``on_execute`` for the ExecutionContext — never invoked. + + JamAgent drives ``preprocessor.process`` + ``adapter.on_event`` directly and + never starts the context's own processing loop, so this is unreachable. + """ + return None + + +class _LruSet: + """Bounded insertion-ordered set for message-id dedupe (LRU eviction).""" + + def __init__(self, maxsize: int): + self._d: OrderedDict[str, bool] = OrderedDict() + self._max = maxsize + + def __contains__(self, key: str) -> bool: + if key in self._d: + self._d.move_to_end(key) + return True + return False + + def add(self, key: str) -> None: + self._d[key] = True + self._d.move_to_end(key) + while len(self._d) > self._max: + self._d.popitem(last=False) + + +class JamAgent: + """A jam-delivery-backed agent exposing the ``thenvoi.Agent`` run/stop shape.""" + + def __init__( + self, + adapter: Any, + creds: Any, + config: Any, + *, + control: JamControlClient | None = None, + link: Any = None, + preprocessor: Any = None, + store: Any = None, + ): + self._adapter = adapter + self._agent_id = creds.agent_id + self._api_key = creds.api_key + self._config = config + # Injectable seams (tests pass fakes; production builds them in start()). + self._control = control + self._link = link + self._preprocessor = preprocessor + self._store = store # durable dedupe store; built lazily in start() if None + self._session_config: Any = None + self._target = Target(scope=agent_scope(self._agent_id)) + self._poll_interval = float(config.agents.idle_resync_seconds) + + # Cross-room dedupe / in-flight tracking (single event loop → no locks). + self._handled = _LruSet(_HANDLED_MAX) + self._inflight: set[str] = set() + self._workers: dict[str, _RoomWorker] = {} + self._dispatcher_task: asyncio.Task[None] | None = None + self._stopped = asyncio.Event() + + # --- lifecycle --------------------------------------------------------- + + async def start(self) -> None: + """Build deps, adopt the peer, and run the adapter handshake.""" + if self._link is None: + from thenvoi.platform.link import ThenvoiLink + + self._link = ThenvoiLink( + self._agent_id, + self._api_key, + self._config.band.ws_url, + self._config.band.rest_url, + ) + if self._preprocessor is None: + from thenvoi.preprocessing.default import DefaultPreprocessor + + self._preprocessor = DefaultPreprocessor() + if self._control is None: + self._control = JamControlClient() + if self._session_config is None: + from thenvoi.runtime.types import SessionConfig + + self._session_config = SessionConfig( + idle_resync_seconds=self._config.agents.idle_resync_seconds, + max_message_retries=self._config.agents.max_message_retries, + ) + if self._store is None: + try: + from codeband.state import StateStore + + self._store = StateStore( + Path(self._config.workspace.path) / "state" / "orchestration.db" + ) + except Exception: + logger.warning( + "JamAgent: StateStore unavailable at %s — durable dedupe disabled", + getattr(getattr(self._config, "workspace", None), "path", "?"), + ) + self._store = None + + await self._adopt() + if not await self._control.ping(): + raise JamControlError( + "jamd control socket not reachable after adopt — is the jam " + "daemon running? (jam delivery requires jamd)" + ) + + name, description = await self._fetch_metadata() + setattr(self._adapter, "_thenvoi_agent_id", self._agent_id) + await self._adapter.on_started(name, description) + logger.info("JamAgent online: agent=%s scope=%s", self._agent_id, self._target.scope) + + async def run(self) -> None: + """Run the dispatcher until the transport dies or the task is cancelled.""" + await self.start() + self._dispatcher_task = asyncio.create_task(self._dispatch_loop()) + try: + await self._dispatcher_task + finally: + # Close on BOTH clean return (transport-fatal) and cancellation, so + # the httpx UDS client never leaks — distributed run_agent's clean + # exit doesn't route through stop(). Idempotent with _safe_stop_agent. + await self.stop() + + async def stop(self, timeout: float | None = None) -> bool: + """Cancel the dispatcher + all room workers and close transports.""" + self._stopped.set() + if self._dispatcher_task is not None: + self._dispatcher_task.cancel() + try: + await self._dispatcher_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 - teardown + pass + await self._drain_workers() + if self._control is not None: + await self._control.close() + return True + + async def close(self) -> None: + """Alias for :meth:`stop` — distributed ``run_agent`` teardown calls + ``agent.close()`` when present; route it through the same idempotent path.""" + await self.stop() + + async def _drain_workers(self) -> None: + workers = list(self._workers.values()) + self._workers.clear() + for worker in workers: + await worker.stop() + + # --- onboarding / metadata -------------------------------------------- + + async def _adopt(self) -> None: + """Idempotently adopt this existing Band agent as a generic Pull peer.""" + opts = { + "profile": "default", + "scope": self._target.scope, + "cwd": os.getcwd(), + "agent_name": "", + "host": "generic", + "team_name": "", + "teammate_name": "", + "host_pid": 0, + "agent_session_provider": "", + "agent_session_id": "", + "agent_session_path": "", + "auto_session": False, + "room": "", + "host_session": "", + } + await self._control.adopt(opts, self._api_key) + + async def _fetch_metadata(self) -> tuple[str, str]: + """Fetch this agent's name/description (mirrors runtime.initialize). + + Best-effort: a fetch failure falls back to a generic identity so the + adapter handshake still runs (some adapters require a description). + """ + try: + resp = await self._link.rest.agent_api_identity.get_agent_me() + data = getattr(resp, "data", None) + if data is not None and getattr(data, "name", None): + return data.name, (getattr(data, "description", None) or "A codeband agent") + except Exception as exc: # noqa: BLE001 - metadata is advisory for on_started + logger.warning("JamAgent metadata fetch failed for %s: %s", self._agent_id, exc) + return self._agent_id, "A codeband agent" + + # --- inbound dispatch -------------------------------------------------- + + async def _dispatch_loop(self) -> None: + """Poll the union inbox and fan messages out to per-room workers.""" + consecutive_failures = 0 + while not self._stopped.is_set(): + try: + messages = await self._control.inbox(self._target) + consecutive_failures = 0 + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - transport tolerance + consecutive_failures += 1 + logger.warning( + "jam inbox poll failed (%d/%d): %s", + consecutive_failures, + _MAX_INBOX_FAILURES, + exc, + ) + if consecutive_failures >= _MAX_INBOX_FAILURES: + logger.error("jam inbox unreachable — exiting run() to reconnect") + return + await asyncio.sleep(self._poll_interval) + continue + + for msg in messages: + self._route(msg) + await asyncio.sleep(self._poll_interval) + + def _route(self, msg: dict[str, Any]) -> None: + """Dedupe and hand a message to its room worker (spawn on first sight).""" + msg_id = msg.get("message_id") + chat_id = msg.get("chat_id") + if not msg_id or not chat_id: + return + if msg_id in self._handled or msg_id in self._inflight: + return + self._inflight.add(msg_id) + worker = self._workers.get(chat_id) + if worker is None: + worker = _RoomWorker(chat_id, self) + self._workers[chat_id] = worker + worker.start() + worker.enqueue(msg) + + # --- helpers shared with workers -------------------------------------- + + def _build_event(self, room_id: str, msg: dict[str, Any]) -> Any: + """Build the SAME ``MessageEvent`` shape the SDK backlog path builds. + + The preprocessor reads ``payload.inserted_at`` and the payload MUST be a + ``MessageCreatedPayload`` (not a ``PlatformMessage``). ``message_type`` and + structured mentions are not on the jam wire, so synthesize + ``message_type="text"`` and ``metadata{mentions:[], status:"sent"}`` — + mentions remain visible in the (already-rewritten) content text. + """ + from thenvoi.client.streaming import MessageCreatedPayload, MessageMetadata + from thenvoi.platform.event import MessageEvent + + iso = _iso_from_epoch_ms(msg.get("enqueued_at")) + payload = MessageCreatedPayload( + id=msg["message_id"], + content=msg.get("content", ""), + sender_id=msg.get("sender_id", ""), + sender_type=msg.get("sender_type", "User"), + message_type="text", + metadata=MessageMetadata(mentions=[], status="sent"), + sender_name=(msg.get("sender_name") or None), + chat_room_id=room_id, + inserted_at=iso, + updated_at=iso, + ) + return MessageEvent(room_id=room_id, payload=payload) + + async def _ack_drain(self, msg_id: str, *, reason: str) -> None: + """Mark a message handled and ack-to-drain it (non-fatal).""" + self._handled.add(msg_id) + outcome: AckOutcome = await self._control.ack(self._target, msg_id) + if not outcome.ok: + logger.debug( + "jam drain-ack rejected for %s (%s; stays queued, cosmetic): %s", + msg_id, + reason, + outcome.error, + ) + + +class _RoomWorker: + """Serial per-room processor: one reused ExecutionContext + retry tracker.""" + + def __init__(self, room_id: str, agent: JamAgent): + self._room_id = room_id + self._agent = agent + self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._task: asyncio.Task[None] | None = None + self._ctx: Any = None + self._retry: Any = None + + def start(self) -> None: + self._task = asyncio.create_task(self._run()) + + def enqueue(self, msg: dict[str, Any]) -> None: + self._queue.put_nowait(msg) + + async def stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except (asyncio.CancelledError, Exception): # noqa: BLE001 - teardown + pass + + async def _run(self) -> None: + from thenvoi.runtime.execution import ExecutionContext + from thenvoi.runtime.retry_tracker import MessageRetryTracker + + self._ctx = ExecutionContext( + self._room_id, + self._agent._link, + _noop_handler, + config=self._agent._session_config, + agent_id=self._agent._agent_id, + ) + self._retry = MessageRetryTracker( + max_retries=self._agent._config.agents.max_message_retries, + room_id=self._room_id, + ) + while True: + msg = await self._queue.get() + msg_id = msg.get("message_id", "") + try: + await self._process(msg) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - handler failure → at-least-once retry + logger.exception( + "jam room-worker %s: handler failed for %s — will retry", + self._room_id, + msg_id, + ) + finally: + # Always clear in-flight: success/drain already recorded _handled; + # a handler failure is intentionally NOT _handled so the next poll + # re-delivers it (until the retry budget trips → drain). + self._agent._inflight.discard(msg_id) + self._queue.task_done() + + async def _process(self, msg: dict[str, Any]) -> None: + agent = self._agent + msg_id = msg["message_id"] + sender_id = msg.get("sender_id", "") + sender_type = msg.get("sender_type", "") + + # Self-message filter (before retry accounting, mirroring the SDK order). + if sender_type == "Agent" and sender_id == agent._agent_id: + await agent._ack_drain(msg_id, reason="self") + return + + # Durable restart-surviving dedupe: a fresh JamAgent after a failed ack + # skips messages whose handler already completed in a prior process. + # Checked before record_attempt so a skip doesn't consume a retry slot. + # Fail-open on DB error: log and proceed as unhandled (in-memory-only + # dedupe still active; anti-wedge property preserved). + if agent._store is not None: + try: + if agent._store.is_jam_message_handled(agent._target.scope, msg_id): + logger.info( + "jam durable-dedupe skip: %s already handled (scope=%s)", + msg_id, + agent._target.scope, + ) + await agent._ack_drain(msg_id, reason="durable_handled") + return + except Exception: + logger.warning( + "jam: durable-check read failed for %s — proceeding as unhandled", + msg_id, + ) + + # Permanently-failed skip (the SDK checks this first too). + if self._retry.is_permanently_failed(msg_id): + await agent._ack_drain(msg_id, reason="permanently_failed") + return + + # Record the attempt BEFORE processing — SDK order (execution.py:1187). + _attempts, exceeded = self._retry.record_attempt(msg_id) + if exceeded: + logger.error( + "jam room-worker %s: msg %s exceeded retry budget — draining", + self._room_id, + msg_id, + ) + await agent._ack_drain(msg_id, reason="exceeded_retries") + return + + event = agent._build_event(self._room_id, msg) + # Hydrate participants/history via REST (same calls the SDK makes), so the + # preprocessor + AgentTools see identical context. + await self._ctx._ensure_fresh_context() + inp = await agent._preprocessor.process( + ctx=self._ctx, event=event, agent_id=agent._agent_id + ) + if inp is None: + # Preprocessor backstop (self/non-message) — drain it. + await agent._ack_drain(msg_id, reason="preprocessor_skip") + return + + await agent._adapter.on_event(inp) + + # Write the durable processed-message record BEFORE the ack. If the ack + # fails and the agent restarts, the fresh JamAgent reads this and skips + # re-delivery. Non-fatal: a write failure (rare) degrades to in-memory- + # only dedupe for this message but does not block other messages. + if agent._store is not None: + try: + agent._store.mark_jam_message_handled(agent._target.scope, msg_id) + except Exception: + logger.warning("jam: durable-handled write failed for %s (in-memory only)", msg_id) + + # Success → clear retry tracking, mark handled, ack (non-fatal). + self._retry.mark_success(msg_id) + agent._handled.add(msg_id) + outcome = await agent._control.ack(agent._target, msg_id) + if not outcome.ok: + logger.warning( + "jam ack rejected for %s (cosmetic — stays queued, others flow): %s", + msg_id, + outcome.error, + ) diff --git a/src/codeband/workers/pool.py b/src/codeband/workers/pool.py index 206e432..795a521 100644 --- a/src/codeband/workers/pool.py +++ b/src/codeband/workers/pool.py @@ -28,6 +28,7 @@ class WorkerRole(str, Enum): PLAN_REVIEWER = "plan_reviewer" CODER = "coder" REVIEWER = "reviewer" + VERIFIER = "verifier" @dataclass(frozen=True) @@ -176,6 +177,29 @@ def pair_for_task( reviewer_slot.current_task = task_id return coder_slot.worker_id, reviewer_slot.worker_id + def acquire_verifier_for( + self, + coder_framework: Framework, + *, + task_id: str | None = None, + ) -> WorkerId | None: + """Reserve an idle opposite-framework verifier for the given coder. + + Prefers opposite-framework; falls back to same-framework when none + is idle (documented degradation — mirrors reviewer pairing). Returns + None when no verifier slot is registered (INERT default). + """ + preferred_fw = opposite_framework(coder_framework) + with self._lock: + slot = self._find_idle(WorkerRole.VERIFIER, preferred_fw) + if slot is None: + slot = self._find_idle(WorkerRole.VERIFIER, coder_framework) + if slot is None: + return None + slot.busy = True + slot.current_task = task_id + return slot.worker_id + def _find_idle( self, role: WorkerRole, framework: Framework, ) -> WorkerSlot | None: diff --git a/src/codeband/workspace/git.py b/src/codeband/workspace/git.py index b7e6eab..f625f76 100644 --- a/src/codeband/workspace/git.py +++ b/src/codeband/workspace/git.py @@ -138,6 +138,75 @@ def create_worktree( _run_git(cmd, cwd=bare_repo) +def refresh_worktree( + bare_repo: Path, + worktree_path: Path, + branch: str, + *, + detach: bool = False, +) -> None: + """Fast-forward an existing worktree to ``origin/`` at session start. + + ``create_worktree`` deliberately no-ops on an existing worktree, and the + bare repo only fetches at clone time — so across sessions every reused + planner / plan-reviewer / mergemaster worktree silently aged (finding 16: + planning and integration-testing against a stale base). This is the + session-start refresh: + + * ``git fetch origin`` in the bare repo first. A fetch *failure* (network + down) is a WARNING and the refresh proceeds on cached refs — infra + flakiness must not block startup, matching ``_recreate_worktree``. + * A dirty worktree **fails loud** (:class:`WorkspaceError` naming the + paths): local state that prevents the fast-forward means the session + would otherwise silently plan against a stale base. + * ``detach=True`` (planner / plan-reviewer, read-only): re-detach at the + refreshed ref. Otherwise (mergemaster): ``merge --ff-only`` — a + diverged local branch fails loud the same way. + + Falls back from ``origin/`` to ```` when no remote ref + exists (offline/bare-only test repos), mirroring ``prepare_task_branch``. + Coder worktrees are deliberately NOT refreshed here: their workspace + branches are reset by ``prepare_task_branch`` at task assignment, and a + session-start reset would clobber the crash-recovery state the supervisor + rebuilds from uncommitted changes. + """ + try: + _run_git(["fetch", "origin"], cwd=bare_repo) + except WorkspaceError as exc: + logger.warning( + "fetch failed during session-start refresh of %s (%s); " + "refreshing from cached refs", worktree_path, exc, + ) + + status = _run_git(["status", "--porcelain"], cwd=worktree_path).strip() + if status: + raise WorkspaceError( + f"Worktree {worktree_path} has local changes that prevent the " + f"session-start fast-forward to origin/{branch}:\n{status}\n" + "Refusing to start on a stale base — clean or remove the " + "worktree, then restart." + ) + + try: + _run_git(["rev-parse", "--verify", f"origin/{branch}"], cwd=worktree_path) + ref = f"origin/{branch}" + except WorkspaceError: + ref = branch + + if detach: + _run_git(["checkout", "--detach", ref], cwd=worktree_path) + return + _run_git(["checkout", branch], cwd=worktree_path) + try: + _run_git(["merge", "--ff-only", ref], cwd=worktree_path) + except WorkspaceError as exc: + raise WorkspaceError( + f"Worktree {worktree_path} branch {branch!r} cannot fast-forward " + f"to {ref} (diverged?). Refusing to start on a stale base — " + f"reset or remove the worktree, then restart. {exc}" + ) from None + + def branch_exists(bare_repo: Path, branch: str) -> bool: """True if ``branch`` resolves to a ref (local head or remote-tracking).""" try: @@ -342,11 +411,6 @@ def _prepare_task_branch_inner( _run_git(["checkout", "-b", task_branch, reset_ref], cwd=worktree_path) -def fetch_latest(repo_path: Path, remote: str = "origin") -> None: - """Fetch latest from remote.""" - _run_git(["fetch", remote], cwd=repo_path) - - def commit_and_push( worktree_path: Path, message: str, diff --git a/src/codeband/workspace/init.py b/src/codeband/workspace/init.py index 3729092..6ff5653 100644 --- a/src/codeband/workspace/init.py +++ b/src/codeband/workspace/init.py @@ -25,6 +25,7 @@ create_worktree, list_known_branches, pin_gh_default_repo, + refresh_worktree, ) logger = logging.getLogger(__name__) @@ -48,6 +49,7 @@ class WorkspaceLayout: plan_reviewer_worktrees: dict[str, Path] = field(default_factory=dict) coder_worktrees: dict[str, Path] = field(default_factory=dict) reviewer_scratch: dict[str, Path] = field(default_factory=dict) + verifier_scratch: dict[str, Path] = field(default_factory=dict) # Single-instance coordinator mergemaster_worktree: Path | None = None @@ -98,6 +100,8 @@ def resolve_layout(config: CodebandConfig) -> WorkspaceLayout: layout.coder_worktrees[str(wid)] = worktrees_dir / str(wid) for wid in _iter_pool_worker_ids(WorkerRole.REVIEWER, agents.reviewers): layout.reviewer_scratch[str(wid)] = root / "scratch" / str(wid) + for wid in _iter_pool_worker_ids(WorkerRole.VERIFIER, agents.verifiers): + layout.verifier_scratch[str(wid)] = root / "scratch" / str(wid) return layout @@ -162,29 +166,48 @@ def initialize_workspace(config: CodebandConfig) -> WorkspaceLayout: ) pin_gh_default_repo(wt_path, config.repo.url) - # Planner worktrees — detached HEAD (read-only) + # Planner worktrees — detached HEAD (read-only), fast-forwarded to + # origin/ every session start (finding 16: create_worktree no-ops + # on an existing worktree, so a reused planner otherwise plans on a + # stale base; a dirty worktree fails loud instead of planning stale). for wt_path in layout.planner_worktrees.values(): create_worktree( layout.bare_repo, wt_path, config.repo.branch, detach=True, ) + refresh_worktree( + layout.bare_repo, wt_path, config.repo.branch, detach=True, + ) pin_gh_default_repo(wt_path, config.repo.url) - # Plan-reviewer worktrees — detached HEAD (read-only) + # Plan-reviewer worktrees — detached HEAD (read-only), same refresh for wt_path in layout.plan_reviewer_worktrees.values(): create_worktree( layout.bare_repo, wt_path, config.repo.branch, detach=True, ) + refresh_worktree( + layout.bare_repo, wt_path, config.repo.branch, detach=True, + ) pin_gh_default_repo(wt_path, config.repo.url) - # Reviewer scratch directories — no repo, just a workspace for gh calls + # Reviewer + verifier scratch directories — no repo, just a workspace for + # gh calls (the Verifier's acceptance gate uses gh exactly like review). for scratch_path in layout.reviewer_scratch.values(): scratch_path.mkdir(parents=True, exist_ok=True) + for scratch_path in layout.verifier_scratch.values(): + scratch_path.mkdir(parents=True, exist_ok=True) - # Mergemaster worktree on main branch + # Mergemaster worktree on main branch — ff-only refreshed at session + # start; coder worktrees are deliberately NOT refreshed here (their + # workspace branches are reset by prepare_task_branch at task + # assignment, and the supervisor's crash recovery depends on their + # uncommitted state surviving a restart). if layout.mergemaster_worktree is not None: create_worktree( layout.bare_repo, layout.mergemaster_worktree, config.repo.branch, ) + refresh_worktree( + layout.bare_repo, layout.mergemaster_worktree, config.repo.branch, + ) pin_gh_default_repo(layout.mergemaster_worktree, config.repo.url) # Write role-specific Claude Code permission settings. @@ -196,6 +219,8 @@ def initialize_workspace(config: CodebandConfig) -> WorkspaceLayout: write_claude_settings(wt_path, profile="plan_reviewer") for scratch_path in layout.reviewer_scratch.values(): write_claude_settings(scratch_path, profile="coding") + for scratch_path in layout.verifier_scratch.values(): + write_claude_settings(scratch_path, profile="coding") if layout.mergemaster_worktree is not None: write_claude_settings(layout.mergemaster_worktree, profile="coding") @@ -211,6 +236,7 @@ class AgentWorkspaceLayout: bare_repo: Path worktree: Path | None reviewer_workspace: Path | None + verifier_workspace: Path | None state_dir: Path notes_dir: Path | None @@ -228,7 +254,7 @@ def initialize_agent_workspace( `worker_id` is the full `{role}-{framework}-{index}` string for pool workers, or the plain role name ("conductor", "mergemaster") for singletons. `agent_role` is one of: planner, plan_reviewer, - coder, reviewer, conductor, mergemaster, watchdog. + coder, reviewer, verifier, conductor, mergemaster, watchdog. Each agent gets its own independent clone and worktree — no shared volumes required. @@ -242,12 +268,16 @@ def initialize_agent_workspace( notes_dir: Path | None = None worktree: Path | None = None reviewer_workspace: Path | None = None + verifier_workspace: Path | None = None if agent_role == "planner": clone_bare(config.repo.url, bare_repo) worktree = root / "worktrees" / worker_id worktree.parent.mkdir(parents=True, exist_ok=True) create_worktree(bare_repo, worktree, config.repo.branch, detach=True) + # Session-start fast-forward (finding 16) — same rationale as the + # local-mode path in initialize_workspace. + refresh_worktree(bare_repo, worktree, config.repo.branch, detach=True) pin_gh_default_repo(worktree, config.repo.url) notes_dir = root / "notes" notes_dir.mkdir(parents=True, exist_ok=True) @@ -256,6 +286,7 @@ def initialize_agent_workspace( worktree = root / "worktrees" / worker_id worktree.parent.mkdir(parents=True, exist_ok=True) create_worktree(bare_repo, worktree, config.repo.branch, detach=True) + refresh_worktree(bare_repo, worktree, config.repo.branch, detach=True) pin_gh_default_repo(worktree, config.repo.url) elif agent_role == "conductor": notes_dir = root / "notes" @@ -263,6 +294,9 @@ def initialize_agent_workspace( elif agent_role == "reviewer": reviewer_workspace = root / "scratch" / worker_id reviewer_workspace.mkdir(parents=True, exist_ok=True) + elif agent_role == "verifier": + verifier_workspace = root / "scratch" / worker_id + verifier_workspace.mkdir(parents=True, exist_ok=True) elif agent_role == "coder": clone_bare(config.repo.url, bare_repo) prefix = config.workspace.worktree_prefix @@ -276,6 +310,7 @@ def initialize_agent_workspace( worktree = root / "worktrees" / "mergemaster" worktree.parent.mkdir(parents=True, exist_ok=True) create_worktree(bare_repo, worktree, config.repo.branch) + refresh_worktree(bare_repo, worktree, config.repo.branch) pin_gh_default_repo(worktree, config.repo.url) # watchdog needs no repo or worktree @@ -285,6 +320,8 @@ def initialize_agent_workspace( write_claude_settings(worktree, profile=profile) if reviewer_workspace: write_claude_settings(reviewer_workspace, profile="coding") + if verifier_workspace: + write_claude_settings(verifier_workspace, profile="coding") logger.info("Agent workspace initialized for %s at %s", worker_id, root) return AgentWorkspaceLayout( @@ -292,6 +329,7 @@ def initialize_agent_workspace( bare_repo=bare_repo, worktree=worktree, reviewer_workspace=reviewer_workspace, + verifier_workspace=verifier_workspace, state_dir=state_dir, notes_dir=notes_dir, ) diff --git a/tests/conftest.py b/tests/conftest.py index 0fda814..16e121a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from unittest.mock import patch import pytest @@ -18,18 +19,62 @@ PoolEntry, RepoConfig, ReviewersConfig, + VerifiersConfig, WatchdogConfig, WorkspaceConfig, ) +_CONTAMINATING_VARS = ( + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "CODEBAND_CLAUDE_PREFER_API_KEY", + "CODEBAND_CODEX_PREFER_API_KEY", + "CODEBAND_FALLBACK_ANTHROPIC_API_KEY", + "CODEBAND_FALLBACK_OPENAI_API_KEY", + "CODEBAND_AGENT_SESSION", + # item-0: identity env var — scrub so dir→identity resolution tests are + # deterministic across developer shells / CI (a stale value would make + # env-first resolution win when a test expects the local map path). + "CODEBAND_AGENT_ID", +) + + +@pytest.fixture(autouse=True) +def _isolate_dotenv(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch): + """Block host .env files from leaking into the test process. + + ``_load_project_dotenv`` in cli/__init__.py calls + ``load_dotenv(find_dotenv(usecwd=True))`` which walks upward from CWD and + can pick up the project's own .env (or any ancestor's) while tests are + running. Patch ``load_dotenv`` to a no-op so CLI invocations inside tests + never load real credentials, and pre-clear the known contaminating vars so + no prior test or shell export bleeds through. + + Tests that specifically exercise real .env loading should opt out by marking + with ``@pytest.mark.real_dotenv`` (or a module-level ``pytestmark``). + """ + if request.node.get_closest_marker("real_dotenv"): + yield + return + + with patch("codeband.cli.load_dotenv"): + for var in _CONTAMINATING_VARS: + monkeypatch.delenv(var, raising=False) + yield + + @pytest.fixture def sample_config(tmp_path: Path) -> CodebandConfig: """A minimal CodebandConfig for testing. Cross-model defaults: 1 Claude coder + 1 Codex coder, 1 of each reviewer, 1 Claude planner + 1 Codex plan-reviewer. Conductor + - Mergemaster default to Claude. Total: 8 Band.ai agents. + Mergemaster default to Claude. This fixture explicitly opts verifiers out + so it stays a coherent 8-agent pair with ``sample_agent_config`` (which + carries no verifier creds); verifier wiring has its own dedicated tests. + Total: 8 Band.ai agents. """ return CodebandConfig( repo=RepoConfig(url="https://github.com/example/repo.git", branch="main"), @@ -40,16 +85,17 @@ def sample_config(tmp_path: Path) -> CodebandConfig: claude_sdk=PoolEntry(count=1, model="claude-sonnet-4-6"), ), plan_reviewers=PlanReviewersConfig( - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ), coders=FrameworkPool( claude_sdk=PoolEntry(count=1, model="claude-sonnet-4-6"), - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ), reviewers=ReviewersConfig( claude_sdk=PoolEntry(count=1, model="claude-sonnet-4-6"), - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ), + verifiers=VerifiersConfig(), watchdog=WatchdogConfig(), ), workspace=WorkspaceConfig(path=str(tmp_path / "workspace")), diff --git a/tests/test_activity_log.py b/tests/test_activity_log.py index 10b1bed..c4ca3a9 100644 --- a/tests/test_activity_log.py +++ b/tests/test_activity_log.py @@ -129,3 +129,67 @@ def test_from_dict(self): event = ActivityEvent(**data) assert event.event_type == "MERGE_COMPLETED" assert event.details["branch"] == "codeband/player-0/auth" + + +class TestTornLineRobustness: + """One malformed line must not kill `cb log` forever (S6-F8).""" + + def test_reader_skips_torn_line_with_warning(self, tmp_path: Path, caplog): + log_path = tmp_path / "activity.jsonl" + logger = ActivityLogger(log_path) + logger.log("SYSTEM_START", "codeband", "Starting") + # A torn line — a crash mid-append left half a JSON object. + with open(log_path, "a", encoding="utf-8") as f: + f.write('{"timestamp": "2026-06-11T00:00:00+00:00", "event_ty\n') + logger.log("SESSION_START", "coder-0", "Session #1") + + import logging + + with caplog.at_level(logging.WARNING): + events = ActivityReader(log_path).read() + + assert [e.event_type for e in events] == ["SYSTEM_START", "SESSION_START"] + assert any("malformed" in r.message.lower() for r in caplog.records) + + def test_reader_skips_wrong_shape_lines(self, tmp_path: Path): + """Valid JSON that isn't an event (wrong keys, non-dict) is skipped too.""" + log_path = tmp_path / "activity.jsonl" + log_path.write_text( + '{"timestamp": "2026-06-11T00:00:00+00:00", "event_type": "A", ' + '"agent": "x", "summary": "ok", "details": null}\n' + '["not", "an", "event"]\n' + '{"unexpected": "keys"}\n' + '{"timestamp": "not-a-date", "event_type": "B", "agent": "x", ' + '"summary": "bad ts", "details": null}\n', + encoding="utf-8", + ) + # The bad-timestamp line only trips the `since` filter path. + events = ActivityReader(log_path).read( + since=datetime.now(UTC) - timedelta(days=365), + ) + assert [e.event_type for e in events] == ["A"] + + def test_concurrent_appends_do_not_tear_lines(self, tmp_path: Path): + """Appends hold an exclusive flock — parallel writers never interleave.""" + import threading + + log_path = tmp_path / "activity.jsonl" + logger = ActivityLogger(log_path) + + def write_many(agent: str) -> None: + for i in range(50): + logger.log("EVENT", agent, f"line {i}", payload="x" * 256) + + threads = [ + threading.Thread(target=write_many, args=(f"agent-{n}",)) + for n in range(4) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + lines = log_path.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 200 + for line in lines: + json.loads(line) # every line parses — nothing torn diff --git a/tests/test_attempt_window.py b/tests/test_attempt_window.py new file mode 100644 index 0000000..fb49bf0 --- /dev/null +++ b/tests/test_attempt_window.py @@ -0,0 +1,406 @@ +"""Tests for the attempt-window observability instrument. + +The instrument is a *pure observer* over the SDK ack path. The central +guarantee these tests defend is behavioral equivalence: wrapping +``mark_processing`` / ``mark_processed`` must not change their return values, +their exceptions, the SDK's swallow-the-422 behavior, or their side effects. + +Construction note: tests build a REAL :class:`ThenvoiLink` and fake only the +lowest transport seam (``agent_api_messages._raw_client``), so the class-level +wraps on the real ``AsyncAgentApiMessagesClient`` methods are genuinely +exercised — not bypassed by a fake REST object. +""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +import codeband.monitoring.attempt_window as aw +from codeband.monitoring.activity_log import ActivityLogger +from thenvoi.platform.link import ThenvoiLink +from thenvoi_rest.agent_api_messages.client import AsyncAgentApiMessagesClient +from thenvoi_rest.core.api_error import ApiError + +# True SDK originals, snapshotted before any install can run. The autouse +# fixture restores these around every test so each starts from a clean, +# unpatched runtime (install() re-captures originals when the sentinel is gone). +_TRUE = { + "link_processing": ThenvoiLink.mark_processing, + "link_processed": ThenvoiLink.mark_processed, + "rest_processing": AsyncAgentApiMessagesClient.mark_agent_message_processing, + "rest_processed": AsyncAgentApiMessagesClient.mark_agent_message_processed, +} + + +def _restore_originals() -> None: + ThenvoiLink.mark_processing = _TRUE["link_processing"] + ThenvoiLink.mark_processed = _TRUE["link_processed"] + AsyncAgentApiMessagesClient.mark_agent_message_processing = _TRUE["rest_processing"] + AsyncAgentApiMessagesClient.mark_agent_message_processed = _TRUE["rest_processed"] + + +@pytest.fixture(autouse=True) +def _reset_instrument(): + _restore_originals() + aw._open_registry.clear() + aw._activity = None + yield + _restore_originals() + aw._open_registry.clear() + aw._activity = None + + +# --- transport doubles ----------------------------------------------------- + + +class _FakeRaw: + """Stand-in for ``AsyncRawAgentApiMessagesClient`` (the transport seam). + + ``processing`` / ``processed`` are zero-arg callables that either return a + response object (with a ``.data`` attribute, as the real raw client does) or + raise. Default = succeed with empty data. + """ + + def __init__(self, processing=None, processed=None): + self._processing = processing + self._processed = processed + self.processing_calls = 0 + self.processed_calls = 0 + + async def mark_agent_message_processing(self, chat_id, id, *, request_options=None): + self.processing_calls += 1 + if self._processing is not None: + return self._processing() + return SimpleNamespace(data=None) + + async def mark_agent_message_processed(self, chat_id, id, *, request_options=None): + self.processed_calls += 1 + if self._processed is not None: + return self._processed() + return SimpleNamespace(data=None) + + +def _make_link(agent_id="agent-A", processing=None, processed=None): + """Real ThenvoiLink with only the raw transport faked.""" + link = ThenvoiLink(agent_id=agent_id, api_key="test-key") + raw = _FakeRaw(processing=processing, processed=processed) + # Touch the lazy property once, then swap its transport. + link.rest.agent_api_messages._raw_client = raw + return link, raw + + +def _raises_422(): + raise ApiError(status_code=422, body="already processed") + + +def _raises_500(): + raise ApiError(status_code=500, body="boom") + + +def _read_markers(log_path): + rows = [] + if not log_path.exists(): + return rows + for line in log_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + evt = json.loads(line) + if evt.get("event_type") == aw.EVENT_TYPE: + rows.append(evt) + return rows + + +@pytest.fixture +def activity(tmp_path): + return ActivityLogger(tmp_path / "activity.jsonl"), tmp_path / "activity.jsonl" + + +# --- 1: elapsed for a short message --------------------------------------- + + +async def test_short_message_logs_elapsed(activity): + logger, path = activity + aw.install_attempt_window_instrument(logger) + link, raw = _make_link(agent_id="agent-A") + + await link.mark_processing("room-1", "msg-1") + await link.mark_processed("room-1", "msg-1") + + rows = _read_markers(path) + assert len(rows) == 1 + d = rows[0]["details"] + assert d["msg_id"] == "msg-1" + assert d["room_id"] == "room-1" + assert d["agent_id"] == "agent-A" + assert d["ack_422"] is False + assert d["elapsed_seconds"] is not None and d["elapsed_seconds"] >= 0 + assert raw.processing_calls == 1 and raw.processed_calls == 1 + + +# --- 2: elapsed reflects a long handler ----------------------------------- + + +async def test_long_handler_elapsed_reflects_clock(activity, monkeypatch): + logger, path = activity + aw.install_attempt_window_instrument(logger) + link, _ = _make_link() + + # Drive monotonic: open at t=100, ack at t=142.5 → elapsed 42.5s. + ticks = iter([100.0, 142.5]) + monkeypatch.setattr(aw, "_monotonic", lambda: next(ticks)) + + await link.mark_processing("room-1", "msg-long") + await link.mark_processed("room-1", "msg-long") + + rows = _read_markers(path) + assert len(rows) == 1 + assert rows[0]["details"]["elapsed_seconds"] == 42.5 + + +# --- 3: both receive paths emit a marker ---------------------------------- + + +async def test_both_receive_paths_emit(activity, monkeypatch): + from unittest.mock import AsyncMock + + from thenvoi.platform.event import MessageEvent + from thenvoi.client.streaming import MessageCreatedPayload + from thenvoi.runtime.execution import ExecutionContext + from thenvoi.runtime.types import PlatformMessage + from datetime import datetime, timezone + + logger, path = activity + aw.install_attempt_window_instrument(logger) + link, _ = _make_link(agent_id="agent-A") + + executed = [] + + async def _on_execute(ctx, event): + executed.append(event) + + ctx = ExecutionContext("room-1", link, _on_execute, agent_id="agent-A") + # Keep the test off the network: hydration is irrelevant to the ack path. + monkeypatch.setattr(ctx, "_ensure_fresh_context", AsyncMock()) + + # WS path. + ws_event = MessageEvent( + room_id="room-1", + payload=MessageCreatedPayload( + id="ws-msg", + content="hi", + message_type="text", + sender_id="user-1", + sender_type="User", + inserted_at="2026-01-01T00:00:00Z", + updated_at="2026-01-01T00:00:00Z", + ), + ) + await ctx._process_event(ws_event) + + # /next backlog path. + backlog_msg = PlatformMessage( + id="backlog-msg", + room_id="room-1", + content="hi", + sender_id="user-1", + sender_type="User", + sender_name=None, + message_type="text", + metadata={}, + created_at=datetime.now(timezone.utc), + ) + await ctx._process_backlog_message(backlog_msg) + + rows = _read_markers(path) + ids = {r["details"]["msg_id"] for r in rows} + assert "ws-msg" in ids, "WS receive path did not emit a marker" + assert "backlog-msg" in ids, "/next backlog path did not emit a marker" + assert len(executed) == 2 + + +# --- 4: CRITICAL — behavioral equivalence on the swallowed-422 path ------- + + +async def test_behavioral_equivalence_422_swallow(activity): + logger, path = activity + + # BEFORE install: SDK swallows the 422 — returns None, never raises. + link_b, raw_b = _make_link(processed=_raises_422) + pre_processing = await link_b.mark_processing("room-1", "msg-x") + pre_processed = await link_b.mark_processed("room-1", "msg-x") + assert pre_processing is None + assert pre_processed is None + assert raw_b.processing_calls == 1 and raw_b.processed_calls == 1 + + # AFTER install: identical observable behavior, plus the marker. + aw.install_attempt_window_instrument(logger) + link_a, raw_a = _make_link(processed=_raises_422) + post_processing = await link_a.mark_processing("room-1", "msg-x") + post_processed = await link_a.mark_processed("room-1", "msg-x") + + assert post_processing is None, "wrap changed mark_processing return" + assert post_processed is None, "wrap un-swallowed the 422 / changed return" + assert raw_a.processing_calls == 1 and raw_a.processed_calls == 1 + + rows = _read_markers(path) + assert len(rows) == 1 + assert rows[0]["details"]["ack_422"] is True + + +# --- 5: 422 flag variants -------------------------------------------------- + + +async def test_non_422_error_not_flagged_and_still_swallowed(activity): + logger, path = activity + aw.install_attempt_window_instrument(logger) + link, _ = _make_link(processed=_raises_500) + + result = await link.mark_processed("room-1", "msg-500") # no open recorded + assert result is None # 500 still swallowed by the SDK + + rows = _read_markers(path) + assert len(rows) == 1 + assert rows[0]["details"]["ack_422"] is False + assert rows[0]["details"]["elapsed_seconds"] is None + + +async def test_success_not_flagged(activity): + logger, path = activity + aw.install_attempt_window_instrument(logger) + link, _ = _make_link() + + await link.mark_processing("room-1", "ok") + await link.mark_processed("room-1", "ok") + + rows = _read_markers(path) + assert rows[0]["details"]["ack_422"] is False + + +# --- 6: watchdog isolation (direct REST calls are invisible) -------------- + + +async def test_watchdog_direct_calls_isolated(activity): + logger, path = activity + aw.install_attempt_window_instrument(logger) + + # Simulate the watchdog: call the REST methods DIRECTLY (no ThenvoiLink + # frame → no contextvar slot), with the transport-heal's intentional 422. + direct, raw = _make_link(processed=_raises_422) + rest = direct.rest.agent_api_messages + + # The unwrapped original raises ApiError(422); the wrapper must re-raise it + # identically for a direct caller (the watchdog catches it itself). + with pytest.raises(ApiError) as exc: + await rest.mark_agent_message_processed(chat_id="room-9", id="msg-heal") + assert exc.value.status_code == 422 + + # No marker emitted for the direct call, and no leaked state. + assert _read_markers(path) == [] + + # A SUBSEQUENT real ThenvoiLink ack for the SAME (room, msg) must not be + # contaminated by the watchdog's earlier 422. + link, _ = _make_link(agent_id="agent-A") + await link.mark_processing("room-9", "msg-heal") + await link.mark_processed("room-9", "msg-heal") + + rows = _read_markers(path) + assert len(rows) == 1 + assert rows[0]["details"]["ack_422"] is False, "watchdog 422 contaminated a real marker" + + +# --- 6b: original propagates (cancellation) → no marker, no registry pop --- + + +async def test_propagated_exception_emits_nothing_and_preserves_open(activity): + import asyncio + + logger, path = activity + aw.install_attempt_window_instrument(logger) + link, _ = _make_link(agent_id="agent-A") + + # Seed a confirmed open for the key. + await link.mark_processing("room-1", "msg-cancel") + key = ("agent-A", "room-1", "msg-cancel") + assert key in aw._open_registry + + # Make the captured SDK original PROPAGATE (cancellation), as the real SDK + # would on task teardown mid-ack. The wrapper resolves _orig_link_processed + # as a module global, so this swap is seen by the live wrapper. + async def _cancel(self, *a, **k): + raise asyncio.CancelledError() + + aw._orig_link_processed = _cancel + try: + with pytest.raises(asyncio.CancelledError): + await link.mark_processed("room-1", "msg-cancel") + finally: + aw._orig_link_processed = _TRUE["link_processed"] + + # The exact exception propagated; the open window survives (a retry needs + # it); no marker emitted; the contextvar is reset on the propagation path. + assert key in aw._open_registry, "propagated ack erased the open window" + assert _read_markers(path) == [], "propagated ack still emitted a marker" + assert aw._ack_slot.get() is None + + +# --- 7: swallowed processing failure → elapsed None ----------------------- + + +async def test_swallowed_processing_failure_yields_none_elapsed(activity): + logger, path = activity + aw.install_attempt_window_instrument(logger) + # mark_processing transport fails (SDK swallows); mark_processed succeeds. + link, _ = _make_link(processing=_raises_500) + + proc = await link.mark_processing("room-1", "msg-noopen") + assert proc is None # failure swallowed + await link.mark_processed("room-1", "msg-noopen") + + rows = _read_markers(path) + assert len(rows) == 1 + assert rows[0]["details"]["elapsed_seconds"] is None + + +# --- 8: leak bound --------------------------------------------------------- + + +async def test_open_registry_leak_bound(activity, monkeypatch): + logger, _ = activity + aw.install_attempt_window_instrument(logger) + monkeypatch.setattr(aw, "_MAX_OPEN", 16) + link, _ = _make_link(agent_id="agent-A") + + # Many opens with no matching processed → registry must stay capped. + for i in range(100): + await link.mark_processing("room-1", f"msg-{i}") + + assert len(aw._open_registry) <= 16 + + +# --- 9: multi-agent key isolation (round-2 regression) -------------------- + + +async def test_two_agents_same_room_message_isolated(activity, monkeypatch): + logger, path = activity + aw.install_attempt_window_instrument(logger) + + link_a, _ = _make_link(agent_id="agent-A") + link_b, _ = _make_link(agent_id="agent-B") + + # Both agents open the SAME (room, message); distinct windows by clock. + ticks = iter([10.0, 25.0, 100.0, 130.0]) # A.open, B.open, A.ack, B.ack + monkeypatch.setattr(aw, "_monotonic", lambda: next(ticks)) + + await link_a.mark_processing("room-1", "shared-msg") # t=10 + await link_b.mark_processing("room-1", "shared-msg") # t=25 + await link_a.mark_processed("room-1", "shared-msg") # t=100 → 90.0 + await link_b.mark_processed("room-1", "shared-msg") # t=130 → 105.0 + + rows = _read_markers(path) + by_agent = {r["details"]["agent_id"]: r["details"]["elapsed_seconds"] for r in rows} + assert by_agent == {"agent-A": 90.0, "agent-B": 105.0}, ( + "agents collided on (room, msg) — identity not isolating opens" + ) diff --git a/tests/test_attribution.py b/tests/test_attribution.py new file mode 100644 index 0000000..3ea9854 --- /dev/null +++ b/tests/test_attribution.py @@ -0,0 +1,264 @@ +"""Tests for Stage-3 attribution (PR2). + +CLI invocation/completion logging, the per-subcommand cb-phase role gate, and +the unchanged ``cb approve`` agent-session guard. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from codeband.cli import handoff +from codeband.cli.handoff import EXIT_ROLE_MISMATCH, _check_role +from codeband.monitoring.activity_log import ( + ActivityReader, + EventType, + record_cli_invocation, +) + + +# ── role gate matrix (2c) ──────────────────────────────────────────────────── + +# (command, allowed_role, a_disallowed_role) +_MATRIX = [ + ("start", "coder", "reviewer"), + ("start", "conductor", "mergemaster"), + ("verify", "coder", "reviewer"), + ("review", "reviewer", "coder"), + ("merge", "mergemaster", "coder"), + ("abandon", "conductor", "coder"), + ("resume", "conductor", "reviewer"), +] + + +@pytest.mark.parametrize("command,allowed,disallowed", _MATRIX) +def test_role_gate_pass_and_refuse(command, allowed, disallowed, monkeypatch): + monkeypatch.setenv("CODEBAND_ROLE", allowed) + assert _check_role(command) is None # allowed role passes + + monkeypatch.setenv("CODEBAND_ROLE", disallowed) + assert _check_role(command) == EXIT_ROLE_MISMATCH # mismatch refused + + +def test_role_gate_unset_is_operator_path(monkeypatch): + """No CODEBAND_ROLE → every command is allowed (the human operator path).""" + monkeypatch.delenv("CODEBAND_ROLE", raising=False) + for command, _allowed, _ in _MATRIX: + assert _check_role(command) is None + + +def test_role_gate_unknown_command_is_ungated(monkeypatch): + monkeypatch.setenv("CODEBAND_ROLE", "coder") + assert _check_role("not-a-command") is None + + +def test_role_mismatch_short_circuits_main(monkeypatch, capsys): + """A role mismatch returns EXIT_ROLE_MISMATCH from main without running the leg + (no store/config needed — the gate fires before dispatch).""" + monkeypatch.setenv("CODEBAND_ROLE", "reviewer") + code = handoff.main(["merge", "st-1", "--pr", "1"]) + assert code == EXIT_ROLE_MISMATCH + err = capsys.readouterr().err + assert "[role_mismatch]" in err + assert "mergemaster" in err + + +# ── CLI invocation logging (2a) ────────────────────────────────────────────── + +def _project(tmp_path: Path) -> tuple[Path, Path]: + project = tmp_path / "proj" + project.mkdir() + ws = tmp_path / "ws" + (project / "codeband.yaml").write_text( + "repo:\n" + " url: https://github.com/o/r.git\n" + " branch: main\n" + "workspace:\n" + f" path: {ws}\n" + ) + return project, ws + + +def test_invocation_and_completion_events_logged(tmp_path, monkeypatch): + monkeypatch.delenv("CODEBAND_PROJECT_DIR", raising=False) + monkeypatch.delenv("WORKSPACE", raising=False) + monkeypatch.delenv("CODEBAND_AGENT_SESSION", raising=False) + monkeypatch.setenv("CODEBAND_ROLE", "coder") + project, ws = _project(tmp_path) + + complete = record_cli_invocation("cb-phase", ["verify", "st-1", "--dir", str(project)]) + complete(4) + + reader = ActivityReader(ws / "state" / "activity.jsonl") + events = reader.read() + by_type = {e.event_type: e for e in events} + + assert EventType.CLI_INVOCATION in by_type + assert EventType.CLI_COMPLETION in by_type + + inv = by_type[EventType.CLI_INVOCATION] + assert inv.details["argv"] == ["cb-phase", "verify", "st-1", "--dir", str(project)] + assert inv.details["pid"] > 0 + assert inv.details["role"] == "coder" + assert "cwd" in inv.details + assert inv.agent == "coder" # actor label from the role marker + + comp = by_type[EventType.CLI_COMPLETION] + assert comp.details["exit_code"] == 4 + + +def test_invocation_logging_is_best_effort_without_config(tmp_path, monkeypatch): + """No codeband.yaml in scope → logging silently no-ops, never raises.""" + monkeypatch.delenv("CODEBAND_PROJECT_DIR", raising=False) + monkeypatch.delenv("WORKSPACE", raising=False) + monkeypatch.chdir(tmp_path) # empty dir, no codeband.yaml + + # Must not raise; returns a callable that also must not raise. + complete = record_cli_invocation("cb", ["status"]) + complete(0) + + +def test_actor_label_is_human_without_markers(tmp_path, monkeypatch): + monkeypatch.delenv("CODEBAND_PROJECT_DIR", raising=False) + monkeypatch.delenv("WORKSPACE", raising=False) + monkeypatch.delenv("CODEBAND_ROLE", raising=False) + monkeypatch.delenv("CODEBAND_AGENT_SESSION", raising=False) + project, ws = _project(tmp_path) + + record_cli_invocation("cb", ["status", "--dir", str(project)])(0) + + events = ActivityReader(ws / "state" / "activity.jsonl").read() + assert events[0].agent == "human" + + +# ── runner spawn seam exports CODEBAND_ROLE (2b) ───────────────────────────── + +def test_spawn_seam_exports_role_when_given(tmp_path, monkeypatch): + import os + + from codeband.orchestration.runner import _export_project_dir_env + + # Track the keys with monkeypatch so its teardown restores them even though + # the function under test mutates os.environ directly (the spawn seam). + monkeypatch.setenv("CODEBAND_ROLE", "") + monkeypatch.setenv("CODEBAND_AGENT_SESSION", "") + monkeypatch.setenv("CODEBAND_PROJECT_DIR", "") + _export_project_dir_env(tmp_path, role="coder") + + assert os.environ["CODEBAND_ROLE"] == "coder" + assert os.environ["CODEBAND_AGENT_SESSION"] == "1" + + +def test_spawn_seam_leaves_role_unset_in_local_mode(tmp_path, monkeypatch): + """run_local passes no role (one process, many roles) → CODEBAND_ROLE unset.""" + import os + + from codeband.orchestration.runner import _export_project_dir_env + + monkeypatch.delenv("CODEBAND_ROLE", raising=False) + monkeypatch.setenv("CODEBAND_AGENT_SESSION", "") + monkeypatch.setenv("CODEBAND_PROJECT_DIR", "") + _export_project_dir_env(tmp_path) + + assert "CODEBAND_ROLE" not in os.environ + + +# ── cb approve agent-session guard unchanged (#46) ─────────────────────────── + +def test_cb_approve_still_refuses_in_agent_session(tmp_path, monkeypatch): + from codeband.cli import cli + + project, _ = _project(tmp_path) + monkeypatch.setenv("CODEBAND_AGENT_SESSION", "1") + result = CliRunner().invoke(cli, ["approve", "1", "--dir", str(project)]) + assert result.exit_code != 0 + assert "human-approval primitive" in result.output + + +# ── A1: no ghost approval when no grant is recorded (F13) ──────────────────── + +def test_cb_approve_sends_no_notification_when_no_grant_recorded(tmp_path, monkeypatch): + """A1 (F13): when record_approval_grant returns [], cb approve must exit + nonzero and must NOT call send_room_message — no ghost APPROVED post.""" + import codeband.cli.merge as merge_mod + import codeband.orchestration.kickoff as kickoff_mod + from codeband.cli import approve as approve_cmd + + notified: list = [] + + monkeypatch.setattr(merge_mod, "record_approval_grant", lambda _dir, _n: []) + + async def _should_not_be_called(*a, **kw): # pragma: no cover + notified.append("sent") + + monkeypatch.setattr(kickoff_mod, "send_room_message", _should_not_be_called) + (tmp_path / "codeband.yaml").write_text( + "repo:\n url: https://github.com/acme/widgets\n", encoding="utf-8", + ) + + import click + with pytest.raises(click.ClickException) as exc_info: + approve_cmd.callback(number=42, project_dir=str(tmp_path)) + + assert "no durable grant recorded" in str(exc_info.value) + assert notified == [], "send_room_message must not be called when no grant was recorded" + + +# ── A2: --no-notify skips the room message (F14 mechanism) ─────────────────── + +def test_cb_approve_no_notify_records_grant_but_skips_room_message(tmp_path, monkeypatch): + """A2 (F14): --no-notify records the grant (exits 0) but does NOT call + send_room_message — the caller (e.g. /codeband) posts via jam send --as.""" + import codeband.cli.merge as merge_mod + import codeband.orchestration.kickoff as kickoff_mod + from codeband.cli import approve as approve_cmd + + notified: list = [] + monkeypatch.setattr( + merge_mod, "record_approval_grant", + lambda _dir, _n: ["Merge approval recorded for subtask st-1 at sha-1 (approver: owner)."], + ) + + async def _should_not_be_called(*a, **kw): # pragma: no cover + notified.append("sent") + + monkeypatch.setattr(kickoff_mod, "send_room_message", _should_not_be_called) + (tmp_path / "codeband.yaml").write_text( + "repo:\n url: https://github.com/acme/widgets\n", encoding="utf-8", + ) + + # Must not raise — exits 0 with grant recorded, notification suppressed. + approve_cmd.callback(number=42, project_dir=str(tmp_path), no_notify=True) + + assert notified == [], "send_room_message must not be called with --no-notify" + + +def test_cb_approve_with_grant_and_notify_calls_send_room_message(tmp_path, monkeypatch): + """Positive path: when a grant IS recorded and --no-notify is not set, + send_room_message is called exactly once.""" + import codeband.cli.merge as merge_mod + import codeband.orchestration.kickoff as kickoff_mod + from codeband.cli import approve as approve_cmd + + notified: list = [] + monkeypatch.setattr( + merge_mod, "record_approval_grant", + lambda _dir, _n: ["Merge approval recorded for subtask st-1 at sha-1 (approver: owner)."], + ) + + async def _fake_send(config, project, message, command_style="cli"): + notified.append(message) + + monkeypatch.setattr(kickoff_mod, "send_room_message", _fake_send) + (tmp_path / "codeband.yaml").write_text( + "repo:\n url: https://github.com/acme/widgets\n", encoding="utf-8", + ) + + approve_cmd.callback(number=42, project_dir=str(tmp_path)) + + assert len(notified) == 1 + assert "APPROVED" in notified[0] + assert "42" in notified[0] diff --git a/tests/test_cli_dotenv.py b/tests/test_cli_dotenv.py index b5c1504..1007333 100644 --- a/tests/test_cli_dotenv.py +++ b/tests/test_cli_dotenv.py @@ -10,8 +10,13 @@ import os from pathlib import Path +import pytest + from codeband.cli import _load_project_dotenv +# These tests exercise real .env loading — opt out of the _isolate_dotenv fixture. +pytestmark = pytest.mark.real_dotenv + def test_loads_env_from_project_dir(tmp_path: Path, monkeypatch): project = tmp_path / "p1" @@ -112,7 +117,7 @@ def test_clirunner_subcommand_dir_loads_env(tmp_path: Path, monkeypatch): # `cb log` is cheap (read-only) and project-aware. It will load # `--dir`'s .env via @_project_aware before any other side effect. - runner = CliRunner(mix_stderr=False) + runner = CliRunner() runner.invoke(cli, ["log", "--dir", str(project)]) assert os.environ.get("CODEBAND_E2E_KEY") == "from_clirunner_test" diff --git a/tests/test_cli_github_auth.py b/tests/test_cli_github_auth.py index 51e615e..2db24c2 100644 --- a/tests/test_cli_github_auth.py +++ b/tests/test_cli_github_auth.py @@ -8,6 +8,7 @@ from click.testing import CliRunner from codeband.cli import ( + _clear_auth_fallbacks, _detect_codex_auth, _detect_github_auth, _has_codex_subscription_auth, @@ -109,6 +110,45 @@ def test_subscription_probe_only_runs_when_api_key_set(self): _resolve_claude_auth() mock_probe.assert_not_called() + def test_prefer_api_key_flag_retains_key_when_oauth_env_present(self): + """CODEBAND_CLAUDE_PREFER_API_KEY=1 keeps ANTHROPIC_API_KEY even with OAuth.""" + env = { + "ANTHROPIC_API_KEY": "sk-ant-test", + "CLAUDE_CODE_OAUTH_TOKEN": "tok-test", + "CODEBAND_CLAUDE_PREFER_API_KEY": "1", + } + with patch.dict(os.environ, env, clear=False), patch( + "codeband.cli._has_claude_subscription_oauth", return_value=False, + ): + _resolve_claude_auth() + assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-test" + assert "CODEBAND_FALLBACK_ANTHROPIC_API_KEY" not in os.environ + + def test_prefer_api_key_flag_retains_key_when_subscription_oauth_present(self): + """CODEBAND_CLAUDE_PREFER_API_KEY=1 keeps ANTHROPIC_API_KEY with subscription OAuth.""" + env = { + "ANTHROPIC_API_KEY": "sk-ant-test", + "CODEBAND_CLAUDE_PREFER_API_KEY": "true", + } + with patch.dict(os.environ, env, clear=False), patch( + "codeband.cli._has_claude_subscription_oauth", return_value=True, + ): + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + _resolve_claude_auth() + assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-test" + assert "CODEBAND_FALLBACK_ANTHROPIC_API_KEY" not in os.environ + + def test_prefer_api_key_flag_absent_does_not_change_existing_behavior(self): + """Without the flag, OAuth still strips the API key (default path unchanged).""" + env = {"ANTHROPIC_API_KEY": "sk-ant-test", "CLAUDE_CODE_OAUTH_TOKEN": "tok-test"} + with patch.dict(os.environ, env, clear=False), patch( + "codeband.cli._has_claude_subscription_oauth", return_value=False, + ): + os.environ.pop("CODEBAND_CLAUDE_PREFER_API_KEY", None) + _resolve_claude_auth() + assert "ANTHROPIC_API_KEY" not in os.environ + assert os.environ["CODEBAND_FALLBACK_ANTHROPIC_API_KEY"] == "sk-ant-test" + class TestResolveCodexAuth: """Codex subscription auth wins at startup; API key is fallback only.""" @@ -129,7 +169,7 @@ def test_subscription_auth_strips_api_key_and_keeps_fallback( codex_home = tmp_path / ".codex" codex_home.mkdir() (codex_home / "auth.json").write_text( - '{"auth_mode": "ChatGPT", "tokens": {}}', + '{"auth_mode": "chatgpt", "tokens": {}}', encoding="utf-8", ) monkeypatch.setenv("CODEX_HOME", str(codex_home)) @@ -141,6 +181,44 @@ def test_subscription_auth_strips_api_key_and_keeps_fallback( assert "OPENAI_API_KEY" not in os.environ assert os.environ["CODEBAND_FALLBACK_OPENAI_API_KEY"] == "sk-test" + def test_prefer_api_key_flag_retains_key_when_subscription_auth_present( + self, monkeypatch, tmp_path + ): + codex_home = tmp_path / ".codex" + codex_home.mkdir() + (codex_home / "auth.json").write_text( + '{"auth_mode": "chatgpt", "tokens": {}}', + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setenv("CODEBAND_CODEX_PREFER_API_KEY", "true") + monkeypatch.delenv("CODEBAND_FALLBACK_OPENAI_API_KEY", raising=False) + + _resolve_codex_auth() + + assert os.environ["OPENAI_API_KEY"] == "sk-test" + assert "CODEBAND_FALLBACK_OPENAI_API_KEY" not in os.environ + + def test_prefer_api_key_flag_absent_does_not_change_existing_behavior( + self, monkeypatch, tmp_path + ): + codex_home = tmp_path / ".codex" + codex_home.mkdir() + (codex_home / "auth.json").write_text( + '{"auth_mode": "chatgpt", "tokens": {}}', + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("CODEBAND_CODEX_PREFER_API_KEY", raising=False) + monkeypatch.delenv("CODEBAND_FALLBACK_OPENAI_API_KEY", raising=False) + + _resolve_codex_auth() + + assert "OPENAI_API_KEY" not in os.environ + assert os.environ["CODEBAND_FALLBACK_OPENAI_API_KEY"] == "sk-test" + def test_non_chatgpt_auth_file_does_not_strip_api_key(self, monkeypatch, tmp_path): codex_home = tmp_path / ".codex" codex_home.mkdir() @@ -157,17 +235,81 @@ def test_non_chatgpt_auth_file_does_not_strip_api_key(self, monkeypatch, tmp_pat assert os.environ["OPENAI_API_KEY"] == "sk-test" assert "CODEBAND_FALLBACK_OPENAI_API_KEY" not in os.environ - def test_detects_codex_subscription_auth(self, monkeypatch, tmp_path): + def test_detects_codex_subscription_auth_lowercase(self, monkeypatch, tmp_path): + # The real Codex CLI writes lowercase "chatgpt" — this is the load-bearing case. codex_home = tmp_path / ".codex" codex_home.mkdir() (codex_home / "auth.json").write_text( - '{"auth_mode": "ChatGPT"}', + '{"auth_mode": "chatgpt"}', encoding="utf-8", ) monkeypatch.setenv("CODEX_HOME", str(codex_home)) assert _has_codex_subscription_auth() is True + def test_detects_codex_subscription_auth_mixed_case(self, monkeypatch, tmp_path): + # Robustness: accept any capitalisation variation. + codex_home = tmp_path / ".codex" + codex_home.mkdir() + for variant in ("ChatGPT", "CHATGPT"): + (codex_home / "auth.json").write_text( + f'{{"auth_mode": "{variant}"}}', + encoding="utf-8", + ) + assert _has_codex_subscription_auth() is True + + def test_apikey_auth_mode_does_not_match(self, monkeypatch, tmp_path): + codex_home = tmp_path / ".codex" + codex_home.mkdir() + (codex_home / "auth.json").write_text( + '{"auth_mode": "apikey"}', + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + assert _has_codex_subscription_auth() is False + + def test_missing_auth_json_returns_false(self, monkeypatch, tmp_path): + codex_home = tmp_path / ".codex" + codex_home.mkdir() + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + assert _has_codex_subscription_auth() is False + + def test_malformed_json_returns_false(self, monkeypatch, tmp_path): + codex_home = tmp_path / ".codex" + codex_home.mkdir() + (codex_home / "auth.json").write_text("not json {{", encoding="utf-8") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + assert _has_codex_subscription_auth() is False + + def test_json_array_root_returns_false(self, monkeypatch, tmp_path): + # JSON is valid but not a dict — should not match. + codex_home = tmp_path / ".codex" + codex_home.mkdir() + (codex_home / "auth.json").write_text('["chatgpt"]', encoding="utf-8") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + assert _has_codex_subscription_auth() is False + + def test_integration_resolve_codex_auth_lowercase(self, monkeypatch, tmp_path): + # Integration: OPENAI_API_KEY must be stripped when auth.json has lowercase "chatgpt". + codex_home = tmp_path / ".codex" + codex_home.mkdir() + (codex_home / "auth.json").write_text( + '{"auth_mode": "chatgpt", "tokens": {}}', + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("CODEBAND_FALLBACK_OPENAI_API_KEY", raising=False) + + _resolve_codex_auth() + + assert "OPENAI_API_KEY" not in os.environ + assert os.environ["CODEBAND_FALLBACK_OPENAI_API_KEY"] == "sk-test" + class TestHasClaudeSubscriptionOAuth: """Subscription credential probe covers keychain (macOS) and the @@ -278,3 +420,142 @@ def test_idempotent_when_dir_already_exists(self, tmp_path): _detect_codex_auth(env) assert env["CODEX_HOME"] == str(tmp_path / ".codex") + + +class TestClearAuthFallbacks: + """CODEBAND_FALLBACK_* keys must be absent from os.environ before agents start. + + Child processes (Codex CLI subprocess, bash tool calls from agents with + shell access) inherit os.environ. If the fallback keys survive past + preflight they carry the raw API-key value into every spawned process. + _clear_auth_fallbacks() is called in `cb run` immediately before the first + agent is spawned, so this boundary is tested here. + """ + + def test_clears_both_fallback_keys(self): + with patch.dict( + os.environ, + { + "CODEBAND_FALLBACK_ANTHROPIC_API_KEY": "sk-ant-secret", + "CODEBAND_FALLBACK_OPENAI_API_KEY": "sk-openai-secret", + }, + clear=False, + ): + _clear_auth_fallbacks() + assert "CODEBAND_FALLBACK_ANTHROPIC_API_KEY" not in os.environ + assert "CODEBAND_FALLBACK_OPENAI_API_KEY" not in os.environ + + def test_noop_when_no_fallbacks_present(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("CODEBAND_FALLBACK_ANTHROPIC_API_KEY", None) + os.environ.pop("CODEBAND_FALLBACK_OPENAI_API_KEY", None) + _clear_auth_fallbacks() # must not raise + + def test_child_process_does_not_inherit_fallback_key(self): + """Simulate the boundary: fallback set, then cleared, then subprocess spawned. + + Uses a real subprocess to verify the env variable is absent in the + child, which is the actual security boundary (not just os.environ state). + """ + import subprocess + import sys + + with patch.dict( + os.environ, + {"CODEBAND_FALLBACK_ANTHROPIC_API_KEY": "sk-ant-should-not-leak"}, + clear=False, + ): + _clear_auth_fallbacks() + # Spawn a subprocess that prints the value of the env var (empty if absent). + result = subprocess.run( + [sys.executable, "-c", + "import os, sys; " + "sys.stdout.write(os.environ.get('CODEBAND_FALLBACK_ANTHROPIC_API_KEY', ''))"], + capture_output=True, + text=True, + ) + assert result.stdout == "", ( + f"Child process inherited CODEBAND_FALLBACK_ANTHROPIC_API_KEY: {result.stdout!r}" + ) + + def test_resolve_then_clear_leaves_no_fallback_in_subprocess(self): + """Full resolve→clear→spawn path: raw API key must not reach child.""" + import subprocess + import sys + + env_patch = { + "ANTHROPIC_API_KEY": "sk-ant-test-secret", + "CLAUDE_CODE_OAUTH_TOKEN": "oauth-tok", + } + with patch.dict(os.environ, env_patch, clear=False), patch( + "codeband.cli._has_claude_subscription_oauth", return_value=False, + ): + _resolve_claude_auth() + # Fallback is now set in os.environ. + assert "CODEBAND_FALLBACK_ANTHROPIC_API_KEY" in os.environ + _clear_auth_fallbacks() + # Fallback is gone; spawn a real child to confirm. + result = subprocess.run( + [sys.executable, "-c", + "import os, sys; " + "sys.stdout.write(os.environ.get('CODEBAND_FALLBACK_ANTHROPIC_API_KEY', ''))"], + capture_output=True, + text=True, + ) + assert result.stdout == "", ( + f"Child process inherited CODEBAND_FALLBACK_ANTHROPIC_API_KEY " + f"after _clear_auth_fallbacks(): {result.stdout!r}" + ) + + +class TestDockerEntrypointPrecedence: + """docker/entrypoint.sh must implement subscription-first auth (matching cb run). + + When both OPENAI_API_KEY and a ChatGPT subscription auth.json are present, + the entrypoint must prefer the subscription — mirroring _resolve_codex_auth() + in cli/__init__.py — to avoid silently burning API credits in Docker. + + These tests parse the entrypoint script to assert the condition order rather + than executing Docker, following the same meta-test pattern as + test_dependency_pins.py and test_prompt_invariants.py. + """ + + @staticmethod + def _entrypoint_text() -> str: + from pathlib import Path + entrypoint = Path(__file__).parent.parent / "docker" / "entrypoint.sh" + return entrypoint.read_text(encoding="utf-8") + + def test_chatgpt_subscription_check_precedes_openai_api_key_check(self): + """Subscription branch must appear BEFORE the OPENAI_API_KEY branch.""" + text = self._entrypoint_text() + # Find the line positions of the two sentinel strings. + sub_pos = text.find('"auth_mode": *"ChatGPT"') + key_pos = text.find('OPENAI_API_KEY (API key') + assert sub_pos != -1, "ChatGPT subscription sentinel not found in entrypoint.sh" + assert key_pos != -1, "OPENAI_API_KEY sentinel not found in entrypoint.sh" + assert sub_pos < key_pos, ( + "entrypoint.sh checks OPENAI_API_KEY before ChatGPT subscription — " + "this contradicts cb run's subscription-first policy and can silently " + "burn API credits when both credentials exist in Docker." + ) + + def test_subscription_branch_does_not_invoke_codex_login(self): + """The ChatGPT subscription branch must NOT run `codex login --with-api-key`. + + Running codex login in the subscription branch would overwrite the auth + file with an API key, defeating subscription-first. + """ + text = self._entrypoint_text() + lines = text.splitlines() + in_sub_branch = False + for line in lines: + if '"auth_mode": *"ChatGPT"' in line: + in_sub_branch = True + if in_sub_branch: + # The subscription branch ends at the next elif/else/fi. + if line.strip().startswith(("elif", "else", "fi")) and '"auth_mode"' not in line: + break + assert "codex login --with-api-key" not in line, ( + f"ChatGPT subscription branch invokes 'codex login --with-api-key': {line!r}" + ) diff --git a/tests/test_cli_hardening.py b/tests/test_cli_hardening.py new file mode 100644 index 0000000..3d11134 --- /dev/null +++ b/tests/test_cli_hardening.py @@ -0,0 +1,76 @@ +"""Tests for CLI input hardening: clean error paths instead of raw tracebacks.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from codeband.cli import cli as cb_cli + + +def _write_config(path: Path) -> None: + path.write_text( + "repo:\n url: https://github.com/example/repo.git\n", + encoding="utf-8", + ) + + +def _make_backend_patch(): + """Return a context manager that stubs make_backend so tests reach --since parsing.""" + reader = MagicMock() + reader.read.return_value = [] + backend = MagicMock() + backend.make_activity_reader.return_value = reader + return patch("codeband.shell.fs.make_backend", return_value=backend) + + +class TestSinceCleanErrors: + """Bad --since values must produce a one-line Error: message, not a traceback.""" + + @pytest.fixture + def project(self, tmp_path: Path) -> Path: + _write_config(tmp_path / "codeband.yaml") + return tmp_path + + def _invoke_usage(self, project: Path, since: str): + with _make_backend_patch(): + return CliRunner().invoke( + cb_cli, + ["usage", "--since", since, "--dir", str(project)], + catch_exceptions=False, + ) + + def _invoke_log(self, project: Path, since: str): + with _make_backend_patch(): + return CliRunner().invoke( + cb_cli, + ["log", "--since", since, "--dir", str(project)], + catch_exceptions=False, + ) + + def test_usage_bad_since_exits_nonzero_no_traceback(self, project: Path) -> None: + result = self._invoke_usage(project, "notadate") + combined = result.output or "" + assert result.exit_code != 0 + assert "Error:" in combined + assert "Traceback" not in combined + + def test_usage_bad_since_mentions_value(self, project: Path) -> None: + result = self._invoke_usage(project, "zz99") + combined = result.output or "" + assert "zz99" in combined + + def test_log_bad_since_exits_nonzero_no_traceback(self, project: Path) -> None: + result = self._invoke_log(project, "not-a-duration") + combined = result.output or "" + assert result.exit_code != 0 + assert "Error:" in combined + assert "Traceback" not in combined + + def test_log_bad_since_mentions_value(self, project: Path) -> None: + result = self._invoke_log(project, "xyz") + combined = result.output or "" + assert "xyz" in combined diff --git a/tests/test_cli_planner_profile.py b/tests/test_cli_planner_profile.py index 9fee77d..ddade2e 100644 --- a/tests/test_cli_planner_profile.py +++ b/tests/test_cli_planner_profile.py @@ -69,7 +69,7 @@ def test_codex_only_config_selects_codex_planner(self, tmp_path: Path): tmp_path, FrameworkPool( claude_sdk=PoolEntry(count=0), - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ), ) assert _pool_compose_profiles(tmp_path, "planners", "planner") == ["codex-planner"] @@ -110,7 +110,7 @@ def test_existing_planner_profile_is_replaced_by_config(self, tmp_path: Path): tmp_path, FrameworkPool( claude_sdk=PoolEntry(count=0), - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ), ) @@ -203,7 +203,7 @@ def test_inverted_pair_activates_claude_plan_reviewer(self, tmp_path: Path): tmp_path, planners=FrameworkPool( claude_sdk=PoolEntry(count=0), - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ), plan_reviewers=PlanReviewersConfig( claude_sdk=PoolEntry(count=1), diff --git a/tests/test_cli_room_log.py b/tests/test_cli_room_log.py new file mode 100644 index 0000000..f6d0850 --- /dev/null +++ b/tests/test_cli_room_log.py @@ -0,0 +1,358 @@ +"""Tests for ``cb room-log`` command.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from click.testing import CliRunner + +from codeband.cli import _fetch_room_messages, _room_msg_to_dict, cli + + +# --------------------------------------------------------------------------- +# Unit: _room_msg_to_dict +# --------------------------------------------------------------------------- + + +def _make_msg(**kw) -> SimpleNamespace: + defaults = { + "sender_id": "agent-uuid", + "message_type": "text", + "content": "hello world", + "inserted_at": "2026-06-13T10:00:00+00:00", + "created_at": None, + "createdAt": None, + } + defaults.update(kw) + return SimpleNamespace(**defaults) + + +class TestMsgToDict: + def test_maps_known_sender(self): + m = _make_msg(sender_id="abc") + d = _room_msg_to_dict(m, {"abc": "conductor"}) + assert d["sender_id"] == "abc" + assert d["sender_name"] == "conductor" + + def test_unknown_sender_falls_back_to_id(self): + m = _make_msg(sender_id="xyz") + d = _room_msg_to_dict(m, {}) + assert d["sender_name"] == "xyz" + + def test_prefers_created_at_over_inserted_at(self): + m = _make_msg(created_at="2026-06-13T09:00:00+00:00", inserted_at="2026-06-13T10:00:00+00:00") + d = _room_msg_to_dict(m, {}) + assert d["inserted_at"] == "2026-06-13T09:00:00+00:00" + + def test_falls_back_to_inserted_at(self): + m = _make_msg(created_at=None, createdAt=None, inserted_at="2026-06-13T10:00:00+00:00") + d = _room_msg_to_dict(m, {}) + assert d["inserted_at"] == "2026-06-13T10:00:00+00:00" + + def test_message_type_lowercased(self): + m = _make_msg(message_type="TEXT") + d = _room_msg_to_dict(m, {}) + assert d["message_type"] == "text" + + def test_none_content_becomes_empty_string(self): + m = _make_msg(content=None) + d = _room_msg_to_dict(m, {}) + assert d["content"] == "" + + +# --------------------------------------------------------------------------- +# Unit: _fetch_room_messages — ordering and pagination +# --------------------------------------------------------------------------- + + +def _make_page(*contents: tuple[str, str]) -> list: + """Build a page of message objects from (sender_id, inserted_at) pairs.""" + return [ + _make_msg(sender_id=sid, inserted_at=ts, content=f"msg-{ts}") + for sid, ts in contents + ] + + +class TestFetchRoomMessages: + @pytest.mark.asyncio + async def test_returns_messages_sorted_by_timestamp(self): + page = _make_page( + ("a", "2026-06-13T10:02:00+00:00"), + ("b", "2026-06-13T10:01:00+00:00"), + ("c", "2026-06-13T10:03:00+00:00"), + ) + rest = MagicMock() + rest.human_api_messages.list_my_chat_messages = AsyncMock( + return_value=SimpleNamespace(data=page) + ) + + result = await _fetch_room_messages(rest, "room-1", page_size=50) + + timestamps = [getattr(m, "inserted_at") for m in result] + assert timestamps == sorted(timestamps) + + @pytest.mark.asyncio + async def test_stops_pagination_when_empty_page(self): + # Use page_size=1 so a full first page triggers a second fetch, which + # returns empty — verifying that we stop on the empty-page signal. + call_count = 0 + + async def _paged(room_id, page=1, page_size=1): + nonlocal call_count + call_count += 1 + if page == 1: + return SimpleNamespace(data=_make_page(("a", "2026-01-01T00:00:00+00:00"))) + return SimpleNamespace(data=[]) + + rest = MagicMock() + rest.human_api_messages.list_my_chat_messages = _paged + + result = await _fetch_room_messages(rest, "room-2", page_size=1) + assert call_count == 2 # page 1 (full) + page 2 (empty → stop) + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_stops_when_page_is_smaller_than_page_size(self): + call_count = 0 + + async def _paged(room_id, page=1, page_size=50): + nonlocal call_count + call_count += 1 + if page == 1: + # Return 3 < page_size=5 — last page signal + return SimpleNamespace(data=_make_page( + ("a", "2026-01-01T00:01:00+00:00"), + ("b", "2026-01-01T00:02:00+00:00"), + ("c", "2026-01-01T00:03:00+00:00"), + )) + return SimpleNamespace(data=[]) # should never be reached + + rest = MagicMock() + rest.human_api_messages.list_my_chat_messages = _paged + + result = await _fetch_room_messages(rest, "room-3", page_size=5) + assert call_count == 1 + assert len(result) == 3 + + @pytest.mark.asyncio + async def test_concatenates_across_pages(self): + async def _paged(room_id, page=1, page_size=2): + if page == 1: + return SimpleNamespace(data=_make_page( + ("a", "2026-01-01T00:01:00+00:00"), + ("b", "2026-01-01T00:02:00+00:00"), + )) + if page == 2: + return SimpleNamespace(data=_make_page( + ("c", "2026-01-01T00:03:00+00:00"), + )) + return SimpleNamespace(data=[]) + + rest = MagicMock() + rest.human_api_messages.list_my_chat_messages = _paged + + result = await _fetch_room_messages(rest, "room-4", page_size=2) + assert len(result) == 3 + + +# --------------------------------------------------------------------------- +# Integration: CLI command +# --------------------------------------------------------------------------- + + +def _mock_config(): + cfg = MagicMock() + cfg.band.rest_url = "https://band.example.com" + return cfg + + +def _mock_agent_config(): + ac = MagicMock() + creds = MagicMock() + creds.agent_id = "agent-uuid" + ac.agents.items.return_value = [("conductor", creds)] + return ac + + +def _two_msg_rest(): + """REST mock returning two messages in reverse order (to test sort).""" + msgs = _make_page( + ("agent-uuid", "2026-06-13T10:02:00+00:00"), + ("agent-uuid", "2026-06-13T10:01:00+00:00"), + ) + rest = MagicMock() + rest.human_api_messages.list_my_chat_messages = AsyncMock( + return_value=SimpleNamespace(data=msgs) + ) + return rest + + +def _make_msg_dicts(count: int = 2) -> list[dict]: + """Return a list of formatted message dicts (as produced by _room_msg_to_dict).""" + return [ + { + "sender_id": "agent-uuid", + "sender_name": "conductor", + "message_type": "text", + "content": f"message {i}", + "inserted_at": f"2026-06-13T10:0{i}:00+00:00", + } + for i in range(count) + ] + + +class TestRoomLogCliCommand: + """CLI-level tests for ``cb room-log``.""" + + # AsyncRestClient is a deferred local import — patch at its source module. + # load_agent_config is also a local import — patch at its source too. + # _fetch_room_messages is patched to avoid actual network calls. + + @patch("codeband.cli.load_config") + @patch("codeband.config.load_agent_config") + @patch("thenvoi.client.rest.AsyncRestClient") + @patch("codeband.cli._fetch_room_messages", new_callable=AsyncMock) + def test_explicit_room_id_skips_pointer( + self, mock_fetch, mock_rest_cls, mock_agent_cfg, mock_load_cfg, tmp_path + ): + mock_load_cfg.return_value = _mock_config() + mock_agent_cfg.return_value = _mock_agent_config() + mock_fetch.return_value = [ + SimpleNamespace( + sender_id="agent-uuid", message_type="text", + content="hello", inserted_at="2026-06-13T10:01:00+00:00", + created_at=None, createdAt=None, + ) + ] + + runner = CliRunner() + result = runner.invoke( + cli, + ["room-log", "room-explicit-id", "--dir", str(tmp_path)], + env={"BAND_API_KEY": "test-key"}, + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "room-explicit-id" in result.output + assert "1 message" in result.output + + @patch("codeband.cli.load_config") + @patch("codeband.config.load_agent_config") + @patch("thenvoi.client.rest.AsyncRestClient") + @patch("codeband.cli._fetch_room_messages", new_callable=AsyncMock) + @patch("codeband.state.registration.read_room_pointer", return_value="room-from-pointer") + @patch("codeband.state.registration.resolve_state_dir") + def test_default_room_uses_pointer( + self, mock_state_dir, mock_read_ptr, mock_fetch, mock_rest_cls, + mock_agent_cfg, mock_load_cfg, tmp_path, + ): + mock_load_cfg.return_value = _mock_config() + mock_agent_cfg.return_value = _mock_agent_config() + mock_fetch.return_value = [] + mock_state_dir.return_value = tmp_path + + runner = CliRunner() + result = runner.invoke( + cli, + ["room-log", "--dir", str(tmp_path)], + env={"BAND_API_KEY": "test-key"}, + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "room-from-pointer" in result.output + + @patch("codeband.cli.load_config") + @patch("codeband.config.load_agent_config") + @patch("thenvoi.client.rest.AsyncRestClient") + @patch("codeband.cli._fetch_room_messages", new_callable=AsyncMock) + def test_json_flag_outputs_json_lines( + self, mock_fetch, mock_rest_cls, mock_agent_cfg, mock_load_cfg, tmp_path + ): + mock_load_cfg.return_value = _mock_config() + mock_agent_cfg.return_value = _mock_agent_config() + mock_fetch.return_value = [ + SimpleNamespace( + sender_id="agent-uuid", message_type="text", + content=f"msg {i}", inserted_at=f"2026-06-13T10:0{i}:00+00:00", + created_at=None, createdAt=None, + ) + for i in range(2) + ] + + runner = CliRunner() + result = runner.invoke( + cli, + ["room-log", "room-json-test", "--json", "--dir", str(tmp_path)], + env={"BAND_API_KEY": "test-key"}, + catch_exceptions=False, + ) + + assert result.exit_code == 0 + lines = [line for line in result.output.strip().splitlines() if line] + assert len(lines) == 2 + for line in lines: + obj = json.loads(line) + assert "sender_id" in obj + assert "content" in obj + assert "message_type" in obj + + @patch("codeband.cli.load_config") + @patch("codeband.config.load_agent_config") + @patch("thenvoi.client.rest.AsyncRestClient") + @patch("codeband.cli._fetch_room_messages", new_callable=AsyncMock) + def test_json_output_is_sorted_by_timestamp( + self, mock_fetch, mock_rest_cls, mock_agent_cfg, mock_load_cfg, tmp_path + ): + mock_load_cfg.return_value = _mock_config() + mock_agent_cfg.return_value = _mock_agent_config() + # Return msgs in descending order; fetch helper sorts ascending + # _fetch_room_messages sorts ascending — reflect that in the mock return + mock_fetch.return_value = [ + SimpleNamespace( + sender_id="agent-uuid", message_type="text", + content="earlier", inserted_at="2026-06-13T10:01:00+00:00", + created_at=None, createdAt=None, + ), + SimpleNamespace( + sender_id="agent-uuid", message_type="text", + content="later", inserted_at="2026-06-13T10:02:00+00:00", + created_at=None, createdAt=None, + ), + ] + + runner = CliRunner() + result = runner.invoke( + cli, + ["room-log", "room-sort-test", "--json", "--dir", str(tmp_path)], + env={"BAND_API_KEY": "test-key"}, + catch_exceptions=False, + ) + + assert result.exit_code == 0 + lines = [line for line in result.output.strip().splitlines() if line] + timestamps = [json.loads(line)["inserted_at"] for line in lines] + assert timestamps == sorted(timestamps) + + @patch("codeband.cli.load_config") + @patch("codeband.state.registration.read_room_pointer", return_value=None) + @patch("codeband.state.registration.resolve_state_dir") + def test_no_room_pointer_exits_with_error( + self, mock_state_dir, mock_read_ptr, mock_load_cfg, tmp_path + ): + mock_load_cfg.return_value = _mock_config() + mock_state_dir.return_value = tmp_path + + runner = CliRunner() + result = runner.invoke( + cli, + ["room-log", "--dir", str(tmp_path)], + env={"BAND_API_KEY": "test-key"}, + ) + + assert result.exit_code != 0 + assert "No active room" in result.output diff --git a/tests/test_cli_run_output.py b/tests/test_cli_run_output.py index 0a53f96..73129df 100644 --- a/tests/test_cli_run_output.py +++ b/tests/test_cli_run_output.py @@ -47,6 +47,37 @@ def test_run_prints_shutdown_message(self, mock_run_local, mock_load_config, tmp assert result.exit_code == 0 assert "stopped" in result.output.lower() + @patch("codeband.cli.load_config") + @patch("codeband.orchestration.runner.run_local", new_callable=AsyncMock) + def test_run_fresh_flag_reaches_run_local( + self, mock_run_local, mock_load_config, tmp_path, + ): + """--fresh threads through to run_local(fresh=True).""" + mock_load_config.return_value = _make_mock_config() + mock_run_local.return_value = None + + runner = CliRunner() + result = runner.invoke( + cli, ["run", "--skip-preflight", "--fresh", "--dir", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert mock_run_local.call_args.kwargs.get("fresh") is True + + @patch("codeband.cli.load_config") + @patch("codeband.orchestration.runner.run_local", new_callable=AsyncMock) + def test_run_default_is_not_fresh( + self, mock_run_local, mock_load_config, tmp_path, + ): + mock_load_config.return_value = _make_mock_config() + mock_run_local.return_value = None + + runner = CliRunner() + result = runner.invoke(cli, ["run", "--skip-preflight", "--dir", str(tmp_path)]) + + assert result.exit_code == 0 + assert mock_run_local.call_args.kwargs.get("fresh") is False + class TestPreflightErrorOutput: """Verify 'cb run' prints a clean preflight error: just the actionable diff --git a/tests/test_codex_mitigations.py b/tests/test_codex_mitigations.py new file mode 100644 index 0000000..bf0f1e0 --- /dev/null +++ b/tests/test_codex_mitigations.py @@ -0,0 +1,316 @@ +"""Tests for the finding-22 mitigations (Batch 4 addendum, PR 4). + +Three layers: the config knobs (turn budget + retry budget), their plumbing +(role-constructor seam / SessionConfig at Agent.create), and the Codex +adapter-seam resilience patch (dead-client reset + visible-error wrap). +""" + +from __future__ import annotations + +import inspect +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.config import ( + AgentsConfig, + BandConfig, + CodebandConfig, + Framework, + RepoConfig, + WorkspaceConfig, +) + + +def _make_config(tmp_path: Path) -> CodebandConfig: + return CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git"), + workspace=WorkspaceConfig(path=str(tmp_path / "workspace")), + band=BandConfig(), + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Config knobs (4a / 4b) +# ───────────────────────────────────────────────────────────────────────────── + + +class TestCodexTurnTimeoutKnob: + def test_default_is_one_hour(self): + assert AgentsConfig().codex_turn_timeout_seconds == 3600 + + def test_floor_is_60(self): + with pytest.raises(ValueError) as excinfo: + AgentsConfig(codex_turn_timeout_seconds=59) + assert "codex_turn_timeout_seconds" in str(excinfo.value) + assert AgentsConfig( + codex_turn_timeout_seconds=60, + ).codex_turn_timeout_seconds == 60 + + def test_zero_is_rejected_not_unlimited(self): + """band-sdk 0.2.11 has NO 0-means-unlimited special case: the event + loop feeds max(0.0, remaining) straight to asyncio.wait_for + (codex.py:683), so 0 would be an instant turn abandon. The floor + keeps it unrepresentable until the upstream fix lands.""" + with pytest.raises(ValueError): + AgentsConfig(codex_turn_timeout_seconds=0) + + +class TestMaxMessageRetriesKnob: + def test_default_is_3(self): + assert AgentsConfig().max_message_retries == 3 + + def test_floor_is_1(self): + with pytest.raises(ValueError) as excinfo: + AgentsConfig(max_message_retries=0) + assert "max_message_retries" in str(excinfo.value) + assert AgentsConfig(max_message_retries=1).max_message_retries == 1 + + +# ───────────────────────────────────────────────────────────────────────────── +# Plumbing (4a: role-constructor seam; 4b: SessionConfig) +# ───────────────────────────────────────────────────────────────────────────── + + +def test_create_band_agent_passes_max_message_retries(tmp_path, monkeypatch): + import thenvoi + + from codeband.config import AgentCredentials + from codeband.orchestration.runner import _create_band_agent + + config = _make_config(tmp_path) + config.agents.max_message_retries = 5 + captured = {} + + def fake_create(cls, **kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr(thenvoi.Agent, "create", classmethod(fake_create)) + _create_band_agent( + adapter=MagicMock(), + creds=AgentCredentials(agent_id="agent-1", api_key="key-1"), + config=config, + ) + + assert captured["session_config"].max_message_retries == 5 + + +def test_codex_runner_passes_turn_timeout_to_adapter_config(tmp_path): + from codeband.agents.player_codex import CodexPlayerRunner + + runner = CodexPlayerRunner(workspace=str(tmp_path), turn_timeout_seconds=1234) + assert runner.adapter.config.turn_timeout_s == 1234.0 + + +def test_codex_runner_default_matches_config_default(tmp_path): + from codeband.agents.player_codex import CodexPlayerRunner + + runner = CodexPlayerRunner(workspace=str(tmp_path)) + assert runner.adapter.config.turn_timeout_s == 3600.0 + + +def test_every_codex_role_factory_wires_the_knob(tmp_path): + """All six Codex roles get the configured turn budget — the 180s SDK + default was the primary desync trigger for every long-running role.""" + from codeband.orchestration.runner import ( + _create_code_reviewer, + _create_coder, + _create_conductor, + _create_mergemaster, + _create_plan_reviewer, + _create_planner, + ) + + config = _make_config(tmp_path) + config.agents.codex_turn_timeout_seconds = 777 + config.agents.conductor.framework = Framework.CODEX + config.agents.mergemaster.framework = Framework.CODEX + ws = str(tmp_path) + + adapters = [ + _create_coder(Framework.CODEX, config, ws), + _create_planner(config, ws, framework=Framework.CODEX), + _create_plan_reviewer(config, ws, framework=Framework.CODEX), + _create_code_reviewer(config, ws, framework=Framework.CODEX), + _create_conductor(config), + _create_mergemaster(config, ws), + ] + assert [a.config.turn_timeout_s for a in adapters] == [777.0] * 6 + + +# ───────────────────────────────────────────────────────────────────────────── +# Adapter-seam resilience patch (4c) +# ───────────────────────────────────────────────────────────────────────────── + + +class _FakeQueue: + """Mimics asyncio.Queue's internal ``_queue`` deque shape.""" + + def __init__(self, events): + self._queue = list(events) + + +def _fake_adapter(*, returncode=None, queued_events=(), client=True): + adapter = SimpleNamespace( + _initialized=True, + _room_threads={"room-1": "thread-1"}, + ) + if client: + adapter._client = SimpleNamespace( + _proc=SimpleNamespace(returncode=returncode), + _events=_FakeQueue(queued_events), + close=MagicMock(), + ) + else: + adapter._client = None + return adapter + + +class TestResetDeadCodexClient: + def test_dead_subprocess_resets_adapter_state(self): + from codeband.orchestration.runner import _reset_dead_codex_client + + adapter = _fake_adapter(returncode=1) + _reset_dead_codex_client(adapter) + + assert adapter._client is None + assert adapter._initialized is False + assert adapter._room_threads == {} + + def test_queued_transport_closed_sentinel_counts_as_dead(self): + from codeband.orchestration.runner import _reset_dead_codex_client + + sentinel = SimpleNamespace(method="transport/closed") + adapter = _fake_adapter(returncode=None, queued_events=[sentinel]) + _reset_dead_codex_client(adapter) + + assert adapter._client is None + assert adapter._initialized is False + + def test_live_client_is_untouched(self): + from codeband.orchestration.runner import _reset_dead_codex_client + + other = SimpleNamespace(method="item/completed") + adapter = _fake_adapter(returncode=None, queued_events=[other]) + client = adapter._client + _reset_dead_codex_client(adapter) + + assert adapter._client is client + assert adapter._initialized is True + assert adapter._room_threads == {"room-1": "thread-1"} + + def test_no_client_is_a_noop(self): + from codeband.orchestration.runner import _reset_dead_codex_client + + adapter = _fake_adapter(client=False) + _reset_dead_codex_client(adapter) # must not raise + assert adapter._client is None + + def test_unknown_shapes_do_nothing(self): + from codeband.orchestration.runner import _reset_dead_codex_client + + adapter = SimpleNamespace(_client=object()) # no _proc, no _events + _reset_dead_codex_client(adapter) # shape-tolerant: no raise + + +class TestVisibleErrorWrap: + @pytest.mark.asyncio + async def test_pre_output_exception_posts_chat_error_and_reraises(self): + from codeband.orchestration.runner import _wrap_codex_on_message + + async def boom(self, msg, tools, *args, **kwargs): + raise RuntimeError("turn/start rejected") + + wrapped = _wrap_codex_on_message(boom) + adapter = _fake_adapter(returncode=None) + tools = SimpleNamespace(send_message=AsyncMock()) + + with pytest.raises(RuntimeError, match="turn/start rejected"): + await wrapped(adapter, MagicMock(), tools, is_session_bootstrap=False, + room_id="room-1") + + tools.send_message.assert_awaited_once() + content = tools.send_message.call_args.kwargs["content"] + assert "Codex turn could not start" in content + assert "turn/start rejected" in content + assert "NOT processed" in content + + @pytest.mark.asyncio + async def test_success_passes_through_without_noise(self): + from codeband.orchestration.runner import _wrap_codex_on_message + + async def fine(self, msg, tools, *args, **kwargs): + return "ok" + + wrapped = _wrap_codex_on_message(fine) + tools = SimpleNamespace(send_message=AsyncMock()) + + result = await wrapped( + _fake_adapter(), MagicMock(), tools, + is_session_bootstrap=False, room_id="room-1", + ) + assert result == "ok" + tools.send_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_dead_client_is_reset_before_the_turn(self): + from codeband.orchestration.runner import _wrap_codex_on_message + + seen = {} + + async def record(self, msg, tools, *args, **kwargs): + seen["client_at_turn"] = self._client + + wrapped = _wrap_codex_on_message(record) + adapter = _fake_adapter(returncode=137) # dead between turns + await wrapped(adapter, MagicMock(), SimpleNamespace(), + is_session_bootstrap=False, room_id="room-1") + + assert seen["client_at_turn"] is None # reset BEFORE the turn ran + + @pytest.mark.asyncio + async def test_send_failure_never_masks_the_original_error(self): + from codeband.orchestration.runner import _wrap_codex_on_message + + async def boom(self, msg, tools, *args, **kwargs): + raise RuntimeError("original") + + wrapped = _wrap_codex_on_message(boom) + tools = SimpleNamespace( + send_message=AsyncMock(side_effect=ConnectionError("chat down")), + ) + + with pytest.raises(RuntimeError, match="original"): + await wrapped(_fake_adapter(), MagicMock(), tools, + is_session_bootstrap=False, room_id="room-1") + + +def test_patch_is_idempotent_and_targets_the_real_adapter(): + from thenvoi.adapters import CodexAdapter + + from codeband.orchestration.runner import _patch_codex_adapter_resilience + + original = CodexAdapter.on_message + try: + _patch_codex_adapter_resilience() + wrapped_once = CodexAdapter.on_message + assert wrapped_once is not original + assert getattr(wrapped_once, "_codeband_codex_resilience", False) + + _patch_codex_adapter_resilience() # second call must not re-wrap + assert CodexAdapter.on_message is wrapped_once + finally: + CodexAdapter.on_message = original + + +def test_wrapped_on_message_keeps_event_loop_semantics(): + """The wrapper must be a coroutine function (the SDK awaits it).""" + from codeband.orchestration.runner import _wrap_codex_on_message + + async def original(self, msg, tools, *args, **kwargs): + return None + + assert inspect.iscoroutinefunction(_wrap_codex_on_message(original)) diff --git a/tests/test_config.py b/tests/test_config.py index ef9b8c4..c97337a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,6 +20,7 @@ PoolEntry, RepoConfig, ReviewersConfig, + VerifiersConfig, WorkspaceConfig, load_config, scale_pool, @@ -50,7 +51,7 @@ def test_default_model_split_coders_opus_rest_sonnet(self): """ config = CodebandConfig(repo=RepoConfig(url="https://github.com/a/b.git")) assert config.agents.coders.claude_sdk.model == "claude-opus-4-7" - assert config.agents.coders.codex.model == "gpt-5.4" + assert config.agents.coders.codex.model == "gpt-5.5" assert config.agents.reviewers.claude_sdk.model == "claude-sonnet-4-6" assert config.agents.planners.claude_sdk.model == "claude-sonnet-4-6" assert config.agents.conductor.model == "claude-sonnet-4-6" @@ -92,6 +93,27 @@ def test_load_config_missing_file(self, tmp_path: Path): with pytest.raises(FileNotFoundError, match="codeband.yaml not found"): load_config(tmp_path) + def test_from_yaml_empty_file_reports_missing_repo(self, tmp_path: Path): + """[F7-10] A zero-byte codeband.yaml normalizes to {} so the error is + the actionable 'repo: Field required', not 'Input should be a valid + dictionary'.""" + from pydantic import ValidationError + + yaml_path = tmp_path / "codeband.yaml" + yaml_path.write_text("", encoding="utf-8") + with pytest.raises(ValidationError, match="repo") as excinfo: + CodebandConfig.from_yaml(yaml_path) + assert "Field required" in str(excinfo.value) + + def test_from_yaml_comments_only_file_reports_missing_repo(self, tmp_path: Path): + """A comments-only file also safe_loads to None — same normalization.""" + from pydantic import ValidationError + + yaml_path = tmp_path / "codeband.yaml" + yaml_path.write_text("# nothing here yet\n", encoding="utf-8") + with pytest.raises(ValidationError, match="repo"): + CodebandConfig.from_yaml(yaml_path) + class TestAgentConfigFile: """Tests for agent credentials file.""" @@ -109,6 +131,33 @@ def test_yaml_roundtrip(self, tmp_path: Path): assert loaded.agents["conductor"].agent_id == "abc" assert loaded.agents["coder-claude_sdk-0"].api_key == "key-2" + def test_to_yaml_is_private(self, tmp_path: Path): + """Written file must be owner-read/write only (0o600).""" + import stat + + config = AgentConfigFile(agents={ + "conductor": AgentCredentials(agent_id="abc", api_key="key-1"), + }) + yaml_path = tmp_path / "agent_config.yaml" + config.to_yaml(yaml_path) + mode = stat.S_IMODE(yaml_path.stat().st_mode) + assert mode == 0o600, f"expected 0o600, got {oct(mode)}" + + def test_to_yaml_restricts_preexisting_file(self, tmp_path: Path): + """An existing world-readable file must be tightened before overwriting.""" + import stat + + yaml_path = tmp_path / "agent_config.yaml" + yaml_path.write_text("agents: {}\n") + yaml_path.chmod(0o644) + + config = AgentConfigFile(agents={ + "conductor": AgentCredentials(agent_id="abc", api_key="key-1"), + }) + config.to_yaml(yaml_path) + mode = stat.S_IMODE(yaml_path.stat().st_mode) + assert mode == 0o600, f"expected 0o600, got {oct(mode)}" + def test_get_missing_key_raises(self): """Getting a missing key raises with helpful message.""" config = AgentConfigFile(agents={ @@ -244,10 +293,11 @@ def test_review_guidelines_roundtrip(self, tmp_path: Path): class TestTotalAgentCount: """Tests for AgentsConfig.total_agent_count — drives tier-cap warnings.""" - def test_default_is_eight(self): - """Default cross-model config uses exactly 8 agents (fits free-tier 10 cap).""" + def test_default_is_ten(self): + """Default cross-model config uses exactly 10 agents (the free-tier cap): + the 8 coordination/coder/reviewer seats plus a verifier per vendor.""" config = CodebandConfig(repo=RepoConfig(url="https://github.com/a/b.git")) - assert config.agents.total_agent_count() == 8 + assert config.agents.total_agent_count() == 10 def test_scales_with_pool_counts(self): agents = AgentsConfig( @@ -260,8 +310,9 @@ def test_scales_with_pool_counts(self): codex=PoolEntry(count=2), ), ) - # 2 singletons + 1 Claude planner + 1 Codex plan-reviewer + 6 coders + 4 reviewers - assert agents.total_agent_count() == 2 + 1 + 1 + 6 + 4 + # 2 singletons + 1 Claude planner + 1 Codex plan-reviewer + 6 coders + # + 4 reviewers + 2 default verifiers (one per vendor) + assert agents.total_agent_count() == 2 + 1 + 1 + 6 + 4 + 2 def test_zero_count_reduces_total(self): agents = AgentsConfig( @@ -277,9 +328,13 @@ def test_zero_count_reduces_total(self): claude_sdk=PoolEntry(count=1), codex=PoolEntry(count=0), ), + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=1), + codex=PoolEntry(count=0), + ), ) - # 2 singletons + 1 planner + 1 plan_reviewer + 1 coder + 1 reviewer - assert agents.total_agent_count() == 6 + # 2 singletons + 1 planner + 1 plan_reviewer + 1 coder + 1 reviewer + 1 verifier + assert agents.total_agent_count() == 7 class TestScalePool: @@ -307,6 +362,16 @@ def test_scale_to_zero_opts_out(self, tmp_path: Path): assert updated.agents.coders.codex.count == 0 assert updated.agents.coders.active_frameworks() == [Framework.CLAUDE_SDK] + def test_scale_verifiers_opts_out_of_codex(self, tmp_path: Path): + """Verifiers are a first-class scalable pool — `cb scale verifiers.codex=0` + opts the default Codex verifier out, dropping acceptance to single-vendor.""" + path = self._fresh(tmp_path) + updated = scale_pool(path, "verifiers", Framework.CODEX, 0) + assert updated.agents.verifiers.codex.count == 0 + assert updated.agents.verifiers.active_frameworks() == [Framework.CLAUDE_SDK] + reloaded = CodebandConfig.from_yaml(path) + assert reloaded.agents.verifiers.codex.count == 0 + def test_preserves_model(self, tmp_path: Path): """Scaling keeps the existing entry's model setting.""" config = CodebandConfig( @@ -398,11 +463,11 @@ def test_planners_codex_accepted(self): cfg = AgentsConfig( planners=FrameworkPool( claude_sdk=PoolEntry(count=0), - codex=PoolEntry(count=1, model="gpt-5.4"), + codex=PoolEntry(count=1, model="gpt-5.5"), ), ) assert cfg.planners.codex.count == 1 - assert cfg.planners.codex.model == "gpt-5.4" + assert cfg.planners.codex.model == "gpt-5.5" def test_planners_claude_only_still_fine(self): """The default shape (Claude planner, Codex plan-reviewer) keeps working.""" @@ -459,3 +524,197 @@ def test_review_guidelines_roundtrip(self, tmp_path: Path): config.to_yaml(yaml_path) loaded = CodebandConfig.from_yaml(yaml_path) assert loaded.agents.mergemaster.review_guidelines == "Must have tests" + + +class TestNumericConstraints: + """Garbage caps/intervals must fail loud at config load (F7-9). + + A zero here doesn't disable the feature — it bricks the swarm silently + (``max_verify_attempts: 0`` blocks every subtask on its first verify; + ``check_interval_seconds: 0`` hot-loops the Band API). + """ + + WATCHDOG_GE1_FIELDS = [ + "check_interval_seconds", + "stale_threshold_seconds", + "nudge_grace_seconds", + "nudge_suppression_seconds", + "swarm_idle_grace_seconds", + "max_phase_visits", + ] + AGENTS_GE1_FIELDS = ["max_review_rounds", "max_verify_attempts"] + + @pytest.mark.parametrize("field", WATCHDOG_GE1_FIELDS) + @pytest.mark.parametrize("bad", [0, -1]) + def test_watchdog_rejects_nonpositive(self, field: str, bad: int): + from codeband.config import WatchdogConfig + + with pytest.raises(ValueError) as excinfo: + WatchdogConfig(**{field: bad}) + assert field in str(excinfo.value) + + @pytest.mark.parametrize("field", WATCHDOG_GE1_FIELDS) + def test_watchdog_accepts_one(self, field: str): + from codeband.config import WatchdogConfig + + assert getattr(WatchdogConfig(**{field: 1}), field) == 1 + + @pytest.mark.parametrize("field", AGENTS_GE1_FIELDS) + @pytest.mark.parametrize("bad", [0, -1]) + def test_agents_rejects_nonpositive(self, field: str, bad: int): + with pytest.raises(ValueError) as excinfo: + AgentsConfig(**{field: bad}) + assert field in str(excinfo.value) + + @pytest.mark.parametrize("field", AGENTS_GE1_FIELDS) + def test_agents_accepts_one(self, field: str): + assert getattr(AgentsConfig(**{field: 1}), field) == 1 + + def test_restart_delay_rejects_negative(self): + with pytest.raises(ValueError) as excinfo: + PoolEntry(restart_delay_seconds=-0.1) + assert "restart_delay_seconds" in str(excinfo.value) + + def test_restart_delay_accepts_zero(self): + assert PoolEntry(restart_delay_seconds=0.0).restart_delay_seconds == 0.0 + + def test_bad_value_fails_full_yaml_load(self, tmp_path: Path): + """The constraint fires through CodebandConfig.from_yaml, naming the key.""" + yaml_path = tmp_path / "codeband.yaml" + yaml_path.write_text( + "repo:\n url: https://github.com/a/b.git\n" + "agents:\n watchdog:\n check_interval_seconds: 0\n", + encoding="utf-8", + ) + with pytest.raises(ValueError) as excinfo: + CodebandConfig.from_yaml(yaml_path) + assert "check_interval_seconds" in str(excinfo.value) + + +class TestRoleStaleThresholdKeys: + """role_stale_thresholds keys must name real roles. + + A typo'd key was previously silently ignored at threshold lookup, + defeating the override's purpose. + """ + + def test_valid_keys_accepted(self): + from codeband.config import WatchdogConfig + + cfg = WatchdogConfig( + role_stale_thresholds={ + "coder": 900, + "reviewer": 300, + "planner": 300, + "plan_reviewer": 300, + "conductor": 300, + "mergemaster": 900, + "watchdog": 300, + }, + ) + assert cfg.role_stale_thresholds["coder"] == 900 + + def test_unknown_key_rejected_and_named(self): + from codeband.config import WatchdogConfig + + with pytest.raises(ValueError) as excinfo: + WatchdogConfig(role_stale_thresholds={"codr": 900}) + msg = str(excinfo.value) + assert "codr" in msg + assert "mergemaster" in msg # lists the valid roles + + def test_unknown_key_fails_yaml_load(self, tmp_path: Path): + yaml_path = tmp_path / "codeband.yaml" + yaml_path.write_text( + "repo:\n url: https://github.com/a/b.git\n" + "agents:\n watchdog:\n role_stale_thresholds:\n merge_master: 900\n", + encoding="utf-8", + ) + with pytest.raises(ValueError) as excinfo: + CodebandConfig.from_yaml(yaml_path) + assert "merge_master" in str(excinfo.value) + + +class TestEnvVarDocsCanary: + """docs/CONFIGURATION.md documents every recovery-critical env var (S9-4).""" + + # CODEBAND_LOCAL_SUBSCRIBE_EXISTING is deliberately absent: deprecated + # and ignored since subscribe-existing became the default (use + # ``cb run --fresh`` to opt out); the doc keeps only a deprecation note. + DOCUMENTED_ENV_VARS = [ + "WORKSPACE", + "CODEBAND_PROJECT_DIR", + "WATCHDOG_LIVENESS_MODE", + "CODEBAND_CLAUDE_PREFER_API_KEY", + "CODEBAND_CODEX_PREFER_API_KEY", + "CODEBAND_FALLBACK_ANTHROPIC_API_KEY", + "CODEBAND_FALLBACK_OPENAI_API_KEY", + ] + + @pytest.mark.parametrize("var", DOCUMENTED_ENV_VARS) + def test_env_var_documented(self, var: str): + doc = Path(__file__).parent.parent / "docs" / "CONFIGURATION.md" + assert var in doc.read_text(encoding="utf-8"), ( + f"{var} missing from docs/CONFIGURATION.md Environment Variables section" + ) + + +class TestMaxRebaseRounds: + """agents.max_rebase_rounds — the S2-1 rebase-round cap knob.""" + + def test_default_matches_fsm(self): + from codeband.state.fsm import MAX_REBASE_ROUNDS + + assert AgentsConfig().max_rebase_rounds == MAX_REBASE_ROUNDS == 3 + + def test_zero_rejected(self): + """A zero cap would block every subtask on its first send-back.""" + with pytest.raises(ValueError) as excinfo: + AgentsConfig(max_rebase_rounds=0) + assert "max_rebase_rounds" in str(excinfo.value) + + +class TestIdleResyncSeconds: + """agents.idle_resync_seconds — the SDK idle-resync delivery backstop.""" + + def test_default_is_30(self): + assert AgentsConfig().idle_resync_seconds == 30 + + def test_zero_rejected(self): + """The SDK rejects <= 0 — it would turn the resync into a REST hot loop.""" + with pytest.raises(ValueError) as excinfo: + AgentsConfig(idle_resync_seconds=0) + assert "idle_resync_seconds" in str(excinfo.value) + + def test_one_is_the_floor(self): + assert AgentsConfig(idle_resync_seconds=1).idle_resync_seconds == 1 + + + +class TestVerifyInfraExitCodes: + """agents.verify_infra_exit_codes — infra-exit-code set for no-burn bypass.""" + + def test_default_is_none(self): + """None signals 'use module-level default' in cli/handoff.py.""" + assert AgentsConfig().verify_infra_exit_codes is None + + def test_accepts_list_of_ints(self): + cfg = AgentsConfig(verify_infra_exit_codes=[124, 127]) + assert cfg.verify_infra_exit_codes == [124, 127] + + def test_empty_list_accepted(self): + """Empty list disables the feature (no exit code is treated as infra).""" + cfg = AgentsConfig(verify_infra_exit_codes=[]) + assert cfg.verify_infra_exit_codes == [] + + def test_roundtrip_via_yaml(self, tmp_path: Path): + import yaml as _yaml + + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/a/b.git"), + agents=AgentsConfig(verify_infra_exit_codes=[124, 127]), + ) + data = config.model_dump(mode="json") + dumped = _yaml.safe_dump(data) + loaded = CodebandConfig(**_yaml.safe_load(dumped)) + assert loaded.agents.verify_infra_exit_codes == [124, 127] diff --git a/tests/test_dependency_pins.py b/tests/test_dependency_pins.py new file mode 100644 index 0000000..bee2547 --- /dev/null +++ b/tests/test_dependency_pins.py @@ -0,0 +1,35 @@ +"""Canary tests for critical dependency pins in pyproject.toml. + +Pip resolution itself is untestable in-suite, but the pin strings are not: +band-sdk 1.0.0 renamed the ``thenvoi.*`` module namespace to ``band.*`` with +no compatibility shim, so an uncapped pin breaks every fresh install. These +asserts make loosening the cap a deliberate, reviewed act. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +_PYPROJECT = Path(__file__).parent.parent / "pyproject.toml" + + +def _dependencies() -> list[str]: + with _PYPROJECT.open("rb") as fh: + return tomllib.load(fh)["project"]["dependencies"] + + +def test_band_sdk_pin_is_capped_below_0_3(): + deps = [d for d in _dependencies() if d.startswith("band-sdk")] + assert deps == ["band-sdk[codex,claude-sdk]>=0.2.8,<0.3"], ( + "band-sdk must stay capped <0.3 until the thenvoi.*→band.* rename " + f"is migrated; got {deps}" + ) + + +def test_thenvoi_client_rest_is_declared_and_capped(): + deps = [d for d in _dependencies() if d.startswith("thenvoi-client-rest")] + assert deps == ["thenvoi-client-rest>=0.0.7,<0.1"], ( + "thenvoi-client-rest is imported directly (thenvoi.client.rest) and " + f"must be declared with a 0.x cap; got {deps}" + ) diff --git a/tests/test_df5_observability.py b/tests/test_df5_observability.py new file mode 100644 index 0000000..4820631 --- /dev/null +++ b/tests/test_df5_observability.py @@ -0,0 +1,497 @@ +"""Tests for df#5 recovery-observability markers. + +Covers three items: +- AGENT_PIN_HEALED enriched with branch + pin_class at both heal sites +- AGENT_PIN_DEFER event at both #86 discriminator defer-decision points +- AGENT_RECONNECTED fires on reconnect cycles (attempt > 1), not on initial start +""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.config import WatchdogConfig + + +# ─── shared helpers ────────────────────────────────────────────────────────── + +def _make_message(message_id: str, inserted_at: datetime) -> MagicMock: + msg = MagicMock() + msg.id = message_id + msg.inserted_at = inserted_at + return msg + + +def _make_messages_response(messages: list) -> MagicMock: + resp = MagicMock() + resp.data = messages + return resp + + +def _make_activity() -> MagicMock: + activity = MagicMock() + activity.log = MagicMock() + return activity + + +def _make_heal_daemon( + config: WatchdogConfig, + activity: MagicMock | None = None, +): + """Minimal WatchdogDaemon for _attempt_pin_heal tests (no patrol plumbing).""" + from codeband.agents.watchdog import WatchdogDaemon + + conductor = MagicMock() + conductor.agent_api_messages = MagicMock() + conductor.agent_api_messages.list_agent_messages = AsyncMock( + return_value=_make_messages_response([]), + ) + return WatchdogDaemon( + config=config, + rest_client=conductor, + agent_id="agent-cond", + conductor_id="agent-cond", + activity=activity, + ) + + +def _make_agent_client_for_pending_heal(*, verify_messages: list) -> MagicMock: + """Agent REST client for the 2-step pending-bucket heal path. + + step-a (mark_processing) and step-b (mark_processed) succeed; the verify + re-list returns ``verify_messages``. + """ + client = MagicMock() + client.agent_api_messages = MagicMock() + client.agent_api_messages.mark_agent_message_processing = AsyncMock() + client.agent_api_messages.mark_agent_message_processed = AsyncMock() + client.agent_api_messages.list_agent_messages = AsyncMock( + return_value=_make_messages_response(verify_messages), + ) + return client + + +def _make_agent_client_for_processing_heal() -> MagicMock: + """Agent REST client for the 1-step processing-bucket heal path.""" + client = MagicMock() + client.agent_api_messages = MagicMock() + client.agent_api_messages.mark_agent_message_processed = AsyncMock() + return client + + +@pytest.fixture +def heal_config() -> WatchdogConfig: + return WatchdogConfig( + transport_pin_threshold_seconds=600, + transport_heal_max_attempts=3, + ) + + +# ─── Item 1: AGENT_PIN_HEALED enrichment ───────────────────────────────────── + +class TestPinHealedEnrichment: + """AGENT_PIN_HEALED carries branch + pin_class at both heal sites.""" + + @pytest.mark.asyncio + async def test_pending_2step_emits_correct_branch_and_pin_class(self, heal_config): + """Pending-bucket success → branch='pending_2step', pin_class='pending'.""" + activity = _make_activity() + # Verify re-list empty → cursor advanced → HEALED + agent_client = _make_agent_client_for_pending_heal(verify_messages=[]) + daemon = _make_heal_daemon(heal_config, activity) + + await daemon._attempt_pin_heal( + "agent-1", "room-1", "msg-1", agent_client, bucket="pending", + ) + + healed = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_PIN_HEALED"] + assert len(healed) == 1, f"expected 1 AGENT_PIN_HEALED, got {healed}" + assert healed[0].kwargs["branch"] == "pending_2step" + assert healed[0].kwargs["pin_class"] == "pending" + + @pytest.mark.asyncio + async def test_processing_1step_emits_correct_branch_and_pin_class(self, heal_config): + """Processing-bucket success → branch='processing_1step', pin_class='processing'.""" + activity = _make_activity() + agent_client = _make_agent_client_for_processing_heal() + daemon = _make_heal_daemon(heal_config, activity) + + await daemon._attempt_pin_heal( + "agent-1", "room-1", "msg-1", agent_client, bucket="processing", + ) + + healed = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_PIN_HEALED"] + assert len(healed) == 1, f"expected 1 AGENT_PIN_HEALED, got {healed}" + assert healed[0].kwargs["branch"] == "processing_1step" + assert healed[0].kwargs["pin_class"] == "processing" + + @pytest.mark.asyncio + async def test_enrichment_is_additive_no_field_removed(self, heal_config): + """New fields are additive — existing positional args (event_type, agent, summary) + are unchanged; branch and pin_class are new kwargs only.""" + activity = _make_activity() + agent_client = _make_agent_client_for_pending_heal(verify_messages=[]) + daemon = _make_heal_daemon(heal_config, activity) + + await daemon._attempt_pin_heal( + "agent-coder", "room-1", "msg-additive", agent_client, bucket="pending", + ) + + healed = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_PIN_HEALED"] + assert len(healed) == 1 + call = healed[0] + # Positional: (event_type, agent, summary) + assert call.args[0] == "AGENT_PIN_HEALED" + assert call.args[1] == "watchdog" + assert "agent-coder" in call.args[2] + assert "msg-additive" in call.args[2] + # New kwargs present + assert "branch" in call.kwargs + assert "pin_class" in call.kwargs + + @pytest.mark.asyncio + async def test_no_healed_event_when_cursor_did_not_advance(self, heal_config): + """Verify re-list still shows same head → no AGENT_PIN_HEALED (not a success).""" + now = datetime.now(UTC) + same_head = _make_message("msg-stubborn", now - timedelta(seconds=900)) + activity = _make_activity() + agent_client = _make_agent_client_for_pending_heal(verify_messages=[same_head]) + daemon = _make_heal_daemon(heal_config, activity) + + await daemon._attempt_pin_heal( + "agent-1", "room-1", "msg-stubborn", agent_client, bucket="pending", + ) + + healed = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_PIN_HEALED"] + assert len(healed) == 0 + + +# ─── Item 2: AGENT_PIN_DEFER ────────────────────────────────────────────────── + +_SUBTASK_ID = "sub-1" +_TASK_ID = "task-1" +_ROOM_ID = "room-1" + + +def _seed_store(tmp_path, *, state: str = "in_progress"): + from codeband.state import StateStore + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(_TASK_ID, "demo task", _ROOM_ID, owner_id="owner-1") + store.ensure_subtask(_SUBTASK_ID, _TASK_ID, state=state) + return store + + +def _make_disc_rest() -> MagicMock: + rest = MagicMock() + rest.agent_api_messages = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + return rest + + +def _make_disc_agent_rest( + *, + pending: list | None = None, + processing: list | None = None, + raise_on_processing: Exception | None = None, +) -> MagicMock: + client = MagicMock() + client.agent_api_messages = MagicMock() + + async def _list(*args, **kwargs): + status = kwargs.get("status", "") + if status == "processing": + if raise_on_processing is not None: + raise raise_on_processing + return _make_messages_response(processing or []) + if status == "pending": + return _make_messages_response(pending or []) + return _make_messages_response([]) + + client.agent_api_messages.list_agent_messages = AsyncMock(side_effect=_list) + return client + + +def _make_disc_daemon(store, rest, *, role_map, agent_rest_clients, activity=None): + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=WatchdogConfig(transport_pin_threshold_seconds=600), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + owner_id="owner-1", + owner_handle="Owner", + agent_id_to_role=role_map, + agent_rest_clients=agent_rest_clients, + activity=activity, + ) + + +class TestAgentPinDefer: + """AGENT_PIN_DEFER fires at both #86 discriminator defer-decision points.""" + + @pytest.mark.asyncio + async def test_defer_fires_when_expected_actor_pending_pinned(self, tmp_path): + """Coder's pending head is old → defer fires with correct subtask/role/agent.""" + store = _seed_store(tmp_path, state="in_progress") + now = datetime.now(UTC) + pinned_head = _make_message("msg-pinned", now - timedelta(seconds=900)) + coder_client = _make_disc_agent_rest(pending=[pinned_head]) + activity = _make_activity() + + daemon = _make_disc_daemon( + store, _make_disc_rest(), + role_map={"coder-1": "coder"}, + agent_rest_clients={"coder-1": coder_client}, + activity=activity, + ) + sub = store.get_subtask(_SUBTASK_ID, _TASK_ID) + result = await daemon._send_blocked_escalation(sub) + + assert result is False + defer = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_PIN_DEFER"] + assert len(defer) == 1, f"expected 1 AGENT_PIN_DEFER, got {defer}" + kwargs = defer[0].kwargs + assert kwargs["subtask_id"] == _SUBTASK_ID + assert kwargs["expected_role"] == "coder" + assert kwargs["pinned_agent"] == "coder-1" + + @pytest.mark.asyncio + async def test_defer_fires_on_probe_exception(self, tmp_path): + """Probe raises → fail-toward-defer path → AGENT_PIN_DEFER with correct fields.""" + from thenvoi_rest.core.api_error import ApiError + + store = _seed_store(tmp_path, state="review_pending") + reviewer_client = _make_disc_agent_rest( + raise_on_processing=ApiError(status_code=429, headers={}, body="rate limited"), + ) + activity = _make_activity() + + daemon = _make_disc_daemon( + store, _make_disc_rest(), + role_map={"reviewer-1": "reviewer"}, + agent_rest_clients={"reviewer-1": reviewer_client}, + activity=activity, + ) + sub = store.get_subtask(_SUBTASK_ID, _TASK_ID) + result = await daemon._send_blocked_escalation(sub) + + assert result is False + defer = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_PIN_DEFER"] + assert len(defer) == 1, f"expected 1 AGENT_PIN_DEFER, got {defer}" + kwargs = defer[0].kwargs + assert kwargs["subtask_id"] == _SUBTASK_ID + assert kwargs["expected_role"] == "reviewer" + assert kwargs["pinned_agent"] == "reviewer-1" + + @pytest.mark.asyncio + async def test_no_defer_event_when_block_proceeds(self, tmp_path): + """Clear mailbox → block proceeds, no AGENT_PIN_DEFER emitted.""" + store = _seed_store(tmp_path, state="in_progress") + coder_client = _make_disc_agent_rest(pending=[], processing=[]) + activity = _make_activity() + + daemon = _make_disc_daemon( + store, _make_disc_rest(), + role_map={"coder-1": "coder"}, + agent_rest_clients={"coder-1": coder_client}, + activity=activity, + ) + sub = store.get_subtask(_SUBTASK_ID, _TASK_ID) + await daemon._send_blocked_escalation(sub) + + defer = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_PIN_DEFER"] + assert len(defer) == 0 + + +# ─── Item 4: AGENT_RECONNECTED ──────────────────────────────────────────────── + +class TestAgentReconnected: + """AGENT_RECONNECTED fires on first successful turn after reconnect, not on attempt.""" + + @staticmethod + def _agent(coro_factory) -> MagicMock: + agent = MagicMock() + agent.run = coro_factory + agent.stop = AsyncMock(return_value=True) + return agent + + @pytest.mark.asyncio + async def test_not_emitted_on_initial_start(self, monkeypatch) -> None: + """Attempt 1 (initial start) must NOT emit AGENT_RECONNECTED.""" + from codeband.orchestration.runner import _run_agent_forever + + monkeypatch.setattr( + "codeband.orchestration.runner._RECONNECT_BASE_DELAY_SECONDS", 0.0, + ) + monkeypatch.setattr( + "codeband.orchestration.runner._RECONNECT_MAX_DELAY_SECONDS", 0.0, + ) + + started = asyncio.Event() + + async def run_once(): + started.set() + await asyncio.sleep(60) + + activity = _make_activity() + task = asyncio.create_task( + _run_agent_forever( + lambda _ctx=None: self._agent(run_once), + "test-agent", activity, + ), + ) + try: + await asyncio.wait_for(started.wait(), timeout=2.0) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + reconnected = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_RECONNECTED"] + assert len(reconnected) == 0, ( + f"AGENT_RECONNECTED must not fire on initial start, got {reconnected}" + ) + + @pytest.mark.asyncio + async def test_emitted_once_per_reconnect_cycle(self, monkeypatch) -> None: + """Each reconnect cycle emits AGENT_RECONNECTED after its first successful run.""" + from codeband.orchestration.runner import _run_agent_forever + + monkeypatch.setattr( + "codeband.orchestration.runner._RECONNECT_BASE_DELAY_SECONDS", 0.0, + ) + monkeypatch.setattr( + "codeband.orchestration.runner._RECONNECT_MAX_DELAY_SECONDS", 0.0, + ) + + call_count = 0 + target = asyncio.Event() + + async def run_and_exit(): + nonlocal call_count + call_count += 1 + if call_count == 3: + target.set() + # calls 1–3 return cleanly; call 4 will be cancelled before it matters + + activity = _make_activity() + task = asyncio.create_task( + _run_agent_forever( + lambda _ctx=None: self._agent(run_and_exit), + "test-agent", activity, + ), + ) + try: + await asyncio.wait_for(target.wait(), timeout=2.0) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + reconnected = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_RECONNECTED"] + # Attempt 1: no marker. Attempts 2 and 3 each succeed → one marker each. + assert len(reconnected) == 2, ( + f"expected 2 AGENT_RECONNECTED (attempts 2+3 succeeded), got {reconnected}" + ) + + @pytest.mark.asyncio + async def test_emitted_after_crash_reconnect(self, monkeypatch) -> None: + """Reconnect after a crash emits AGENT_RECONNECTED once the next run succeeds.""" + from codeband.orchestration.runner import _run_agent_forever + + monkeypatch.setattr( + "codeband.orchestration.runner._RECONNECT_BASE_DELAY_SECONDS", 0.0, + ) + monkeypatch.setattr( + "codeband.orchestration.runner._RECONNECT_MAX_DELAY_SECONDS", 0.0, + ) + + call_count = 0 + target = asyncio.Event() + + async def crash_then_succeed(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("simulated crash") + # attempt 2: return cleanly → AGENT_RECONNECTED fires after this + target.set() + + activity = _make_activity() + task = asyncio.create_task( + _run_agent_forever( + lambda _ctx=None: self._agent(crash_then_succeed), + "test-agent", activity, + ), + ) + try: + await asyncio.wait_for(target.wait(), timeout=2.0) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + reconnected = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_RECONNECTED"] + assert len(reconnected) == 1, ( + f"expected 1 AGENT_RECONNECTED after crash→successful reconnect, got {reconnected}" + ) + + @pytest.mark.asyncio + async def test_not_emitted_when_first_post_reconnect_run_fails(self, monkeypatch) -> None: + """A failed first post-reconnect run must NOT fire the marker; the next success does.""" + from codeband.orchestration.runner import _run_agent_forever + + monkeypatch.setattr( + "codeband.orchestration.runner._RECONNECT_BASE_DELAY_SECONDS", 0.0, + ) + monkeypatch.setattr( + "codeband.orchestration.runner._RECONNECT_MAX_DELAY_SECONDS", 0.0, + ) + + call_count = 0 + target = asyncio.Event() + + async def clean_then_crash_then_succeed(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return # attempt 1: clean exit, no marker + if call_count == 2: + raise RuntimeError("post-reconnect crash") # attempt 2: must NOT emit + # attempt 3: succeeds → marker fires now + target.set() + + activity = _make_activity() + task = asyncio.create_task( + _run_agent_forever( + lambda _ctx=None: self._agent(clean_then_crash_then_succeed), + "test-agent", activity, + ), + ) + try: + await asyncio.wait_for(target.wait(), timeout=2.0) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + reconnected = [c for c in activity.log.call_args_list if c.args[0] == "AGENT_RECONNECTED"] + assert len(reconnected) == 1, ( + f"expected 1 AGENT_RECONNECTED (only on attempt 3 success, not attempt 2 crash), " + f"got {reconnected}" + ) diff --git a/tests/test_diff.py b/tests/test_diff.py index 7a7d39d..a97af05 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -164,7 +164,7 @@ def test_cli_diff_missing_arg_lists_workers(diff_setup, tmp_path: Path, monkeypa project = _write_project_yaml(tmp_path, diff_setup["worktree"]) monkeypatch.chdir(project) - runner = CliRunner(mix_stderr=False) + runner = CliRunner() result = runner.invoke(cli, ["diff"]) assert result.exit_code == 1 assert "Available workers" in result.stderr @@ -179,7 +179,7 @@ def test_cli_diff_resolves_and_renders(diff_setup, tmp_path: Path, monkeypatch): project = _write_project_yaml(tmp_path, wt) monkeypatch.chdir(project) - runner = CliRunner(mix_stderr=False) + runner = CliRunner() result = runner.invoke(cli, ["diff", "claude"]) # substring match assert result.exit_code == 0, result.output + result.stderr assert "coder-claude_sdk-0" in result.output @@ -238,7 +238,7 @@ def _write_project_yaml(tmp_path: Path, worktree: Path) -> Path: " plan_reviewers:\n" " codex:\n" " count: 1\n" - " model: gpt-5.4\n" + " model: gpt-5.5\n" " coders:\n" " claude_sdk:\n" " count: 1\n" diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4c25b27..f977f5b 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -18,11 +18,13 @@ PoolEntry, RepoConfig, ReviewersConfig, + VerifiersConfig, WorkspaceConfig, ) from codeband.doctor import ( Context, Status, + check_agent_platform_existence, check_agent_config_yaml, check_band_api_key, check_claude_auth, @@ -34,6 +36,7 @@ check_gh, check_gh_auth, check_git, + check_handoff_verify_command, check_active_room_membership, check_memory_mode, check_python_version, @@ -75,6 +78,10 @@ def _make_config(tmp_path: Path, *, use_codex: bool = False) -> CodebandConfig: reviewers=ReviewersConfig(claude_sdk=PoolEntry(count=1)), plan_reviewers=PlanReviewersConfig(claude_sdk=PoolEntry(count=1)), planners=FrameworkPool(claude_sdk=PoolEntry(count=1)), + # Verifiers pinned INERT: these doctor tests build a controlled + # agent_config without verifier creds. Verifier-specific doctor + # checks live in test_verifier.py (TestDoctorVerifierPairing). + verifiers=VerifiersConfig(), ), ) @@ -175,6 +182,34 @@ def test_codex_login_with_auth_json_ok(self, monkeypatch, tmp_path): result = check_codex_auth(Context(project_dir=tmp_path)) assert result.status == Status.OK + def test_openai_key_plus_codex_login_warns(self, monkeypatch, tmp_path): + home = tmp_path / "home" + codex_dir = home / ".codex" + codex_dir.mkdir(parents=True) + (codex_dir / "auth.json").write_text('{"auth_mode": "chatgpt"}') + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + + result = check_codex_auth(Context(project_dir=tmp_path)) + + assert result.status == Status.WARN + assert "subscription" in result.message.lower() + assert "CODEBAND_CODEX_PREFER_API_KEY" in result.remediation + + def test_prefer_api_key_flag_with_codex_login_is_ok(self, monkeypatch, tmp_path): + home = tmp_path / "home" + codex_dir = home / ".codex" + codex_dir.mkdir(parents=True) + (codex_dir / "auth.json").write_text('{"auth_mode": "chatgpt"}') + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("OPENAI_API_KEY", "sk-x") + monkeypatch.setenv("CODEBAND_CODEX_PREFER_API_KEY", "1") + + result = check_codex_auth(Context(project_dir=tmp_path)) + + assert result.status == Status.OK + assert "CODEBAND_CODEX_PREFER_API_KEY" in result.message + def test_empty_codex_dir_does_not_pass(self, monkeypatch, tmp_path): """`cb up` creates ~/.codex as a bind-mount target, so an empty directory is not evidence of a completed `codex login`. The check @@ -242,6 +277,95 @@ def test_all_present_ok(self, tmp_path): result = check_agent_config_yaml(ctx) assert result.status == Status.OK + async def test_platform_agents_all_present_ok(self, tmp_path, monkeypatch): + cfg = _make_config(tmp_path) + acfg = AgentConfigFile(agents={ + key: AgentCredentials(agent_id=key, api_key="k") + for key in ( + "conductor", "mergemaster", + "planner-claude_sdk-0", "plan_reviewer-claude_sdk-0", + "coder-claude_sdk-0", "reviewer-claude_sdk-0", + ) + }) + ctx = Context(project_dir=tmp_path, config=cfg, agent_config=acfg) + monkeypatch.setenv("BAND_API_KEY", "band_u_x") + + fake_agents = [ + type("A", (), {"id": creds.agent_id, "name": key})() + for key, creds in acfg.agents.items() + ] + fake_resp = type("R", (), {"data": fake_agents})() + + async def fake_list_my_agents(): + return fake_resp + + def fake_client(**_): + c = type("C", (), {})() + c.human_api_agents = type("H", (), {})() + c.human_api_agents.list_my_agents = fake_list_my_agents + return c + + with patch("thenvoi_rest.AsyncRestClient", side_effect=fake_client): + result = await check_agent_platform_existence(ctx) + + assert result.status == Status.OK + assert "6 configured IDs exist" in result.message + + async def test_platform_agent_missing_fails_with_name(self, tmp_path, monkeypatch): + cfg = _make_config(tmp_path) + acfg = AgentConfigFile(agents={ + "conductor": AgentCredentials(agent_id="cond-0", api_key="k"), + "mergemaster": AgentCredentials(agent_id="mm-0", api_key="k"), + }) + ctx = Context(project_dir=tmp_path, config=cfg, agent_config=acfg) + monkeypatch.setenv("BAND_API_KEY", "band_u_x") + + fake_agent = type("A", (), {"id": "cond-0", "name": "Conductor"})() + fake_resp = type("R", (), {"data": [fake_agent]})() + + async def fake_list_my_agents(): + return fake_resp + + def fake_client(**_): + c = type("C", (), {})() + c.human_api_agents = type("H", (), {})() + c.human_api_agents.list_my_agents = fake_list_my_agents + return c + + with patch("thenvoi_rest.AsyncRestClient", side_effect=fake_client): + result = await check_agent_platform_existence(ctx) + + assert result.status == Status.FAIL + assert "mergemaster" in result.message + assert "Mergemaster" in result.message + assert "mm-0" in result.message + + async def test_platform_lookup_error_warns_not_false_red_or_green( + self, tmp_path, monkeypatch, + ): + cfg = _make_config(tmp_path) + acfg = AgentConfigFile(agents={ + "conductor": AgentCredentials(agent_id="cond-0", api_key="k"), + }) + ctx = Context(project_dir=tmp_path, config=cfg, agent_config=acfg) + monkeypatch.setenv("BAND_API_KEY", "band_u_x") + + async def fake_list_my_agents(): + raise RuntimeError("auth exploded") + + def fake_client(**_): + c = type("C", (), {})() + c.human_api_agents = type("H", (), {})() + c.human_api_agents.list_my_agents = fake_list_my_agents + return c + + with patch("thenvoi_rest.AsyncRestClient", side_effect=fake_client): + result = await check_agent_platform_existence(ctx) + + assert result.status == Status.WARN + assert "Could not verify platform agents" in result.message + assert "auth exploded" in result.message + class TestCrossModelPairing: def test_warns_when_reviewer_capacity_below_coder_capacity(self, tmp_path): @@ -283,6 +407,27 @@ def test_skip_without_config(self, tmp_path): assert result.status == Status.SKIP +class TestHandoffVerifyCommand: + def test_missing_verify_command_is_info(self, tmp_path): + cfg = _make_config(tmp_path) + ctx = Context(project_dir=tmp_path, config=cfg) + result = check_handoff_verify_command(ctx) + assert result.status == Status.INFO + assert "not active" in result.message + assert "agents.handoff_verify_command" in result.remediation + + def test_configured_verify_command_is_ok(self, tmp_path): + cfg = _make_config(tmp_path) + cfg.agents.handoff_verify_command = "make test" + ctx = Context(project_dir=tmp_path, config=cfg) + result = check_handoff_verify_command(ctx) + assert result.status == Status.OK + + def test_skips_without_config(self, tmp_path): + result = check_handoff_verify_command(Context(project_dir=tmp_path)) + assert result.status == Status.SKIP + + class TestToolChecks: def test_git_missing_fails(self, monkeypatch): monkeypatch.setattr("shutil.which", lambda name: None) @@ -374,6 +519,41 @@ async def test_skips_without_room_pointer(self, tmp_path): assert result.status == Status.SKIP assert ".codeband_room not found" in result.message + async def test_reads_canonical_state_dir_pointer(self, tmp_path): + """Post-relocation registrations write only {workspace}/state/ + .codeband_room — the check must read it via the same dual-location + helper as cb-phase, not SKIP. (The sibling tests below write the + legacy / location and exercise the fallback.)""" + cfg = _make_config(tmp_path) + acfg = AgentConfigFile( + agents={"conductor": AgentCredentials(agent_id="cond-id", api_key="k")} + ) + state_dir = Path(cfg.workspace.path) / "state" + state_dir.mkdir(parents=True) + (state_dir / ".codeband_room").write_text( + "deadbeef-1234-5678-9abc-def012345678", encoding="utf-8", + ) + ctx = Context(project_dir=tmp_path, config=cfg, agent_config=acfg) + + fake_participant = type("P", (), {"id": "cond-id"})() + fake_resp = type("R", (), {"data": [fake_participant]})() + + async def fake_list(chat_id): + assert chat_id == "deadbeef-1234-5678-9abc-def012345678" + return fake_resp + + def fake_client(**_): + c = type("C", (), {})() + c.agent_api_participants = type("A", (), {})() + c.agent_api_participants.list_agent_chat_participants = fake_list + return c + + with patch("thenvoi_rest.AsyncRestClient", side_effect=fake_client): + result = await check_active_room_membership(ctx) + + assert result.status == Status.INFO + assert "conductor" in result.message + async def test_reports_present_and_pending_agents(self, tmp_path): """Conductor present, others pending — the expected fresh-room state.""" cfg = _make_config(tmp_path) @@ -493,12 +673,23 @@ async def fake_get_me(): async def fake_list(*a, **kw): return object() + fake_agents = [ + type("A", (), {"id": creds.agent_id, "name": key})() + for key, creds in acfg.agents.items() + ] + fake_agents_resp = type("R", (), {"data": fake_agents})() + + async def fake_list_my_agents(): + return fake_agents_resp + def fake_client(**_): c = type("C", (), {})() c.agent_api_identity = type("A", (), {})() c.agent_api_identity.get_agent_me = fake_get_me c.agent_api_memories = type("M", (), {})() c.agent_api_memories.list_agent_memories = fake_list + c.human_api_agents = type("H", (), {})() + c.human_api_agents.list_my_agents = fake_list_my_agents return c with patch("thenvoi_rest.AsyncRestClient", side_effect=fake_client): diff --git a/tests/test_evidence_integrity.py b/tests/test_evidence_integrity.py new file mode 100644 index 0000000..ea4d981 --- /dev/null +++ b/tests/test_evidence_integrity.py @@ -0,0 +1,348 @@ +"""Tests for Stage-3 evidence integrity (PR1). + +The transition_log and audit_log hash chains, the ``cb verify-log`` command, +and the migration backfill over a populated legacy DB. The watchdog's +incremental integrity rung is exercised in ``test_watchdog_upgrade.py`` (it +reuses the owner-escalation scaffolding there). +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from codeband.state import ( + AUDIT_HASH_COLS, + GENESIS_PREV_HASH, + StateStore, + TRANSITION_HASH_COLS, + verify_chain, +) +from codeband.state.fsm import transition +from codeband.state.store import write_chained_transition + +TASK_ID = "task-1" +ROOM_ID = "room-1" +SUBTASK_ID = "st-1" + + +@pytest.fixture +def store(tmp_path: Path) -> StateStore: + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(TASK_ID, "demo", ROOM_ID, owner_id="owner-1") + return s + + +def _open(store: StateStore) -> sqlite3.Connection: + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + return conn + + +def _walk(store: StateStore) -> None: + """Drive one subtask through a realistic FSM lifecycle (several rows).""" + transition(SUBTASK_ID, TASK_ID, "assigned", caller_role="conductor", store=store) + transition(SUBTASK_ID, TASK_ID, "in_progress", caller_role="coder", store=store) + transition(SUBTASK_ID, TASK_ID, "verify_pending", caller_role="coder", store=store) + transition(SUBTASK_ID, TASK_ID, "review_pending", caller_role="coder", + store=store, head_sha="deadbeef") + transition(SUBTASK_ID, TASK_ID, "review_failed", caller_role="reviewer", + store=store, head_sha="deadbeef") + + +# ── transition_log chain ──────────────────────────────────────────────────── + +def test_chain_continuous_across_real_fsm_walk(store: StateStore) -> None: + _walk(store) + conn = _open(store) + try: + result = verify_chain(conn, "transition_log", TRANSITION_HASH_COLS) + finally: + conn.close() + assert result.ok + assert result.row_count == 5 + assert result.head_hash is not None + + +def test_first_row_links_to_genesis(store: StateStore) -> None: + _walk(store) + conn = _open(store) + try: + first = conn.execute( + "SELECT prev_hash FROM transition_log ORDER BY id ASC LIMIT 1" + ).fetchone() + finally: + conn.close() + assert first["prev_hash"] == GENESIS_PREV_HASH + + +def test_in_place_edit_breaks_chain_at_exact_row(store: StateStore) -> None: + _walk(store) + # Tamper with the third row's business column (reason) WITHOUT recomputing + # its hash — exactly what an out-of-band sqlite3 edit looks like. + conn = _open(store) + try: + ids = [r["id"] for r in conn.execute( + "SELECT id FROM transition_log ORDER BY id ASC" + ).fetchall()] + target = ids[2] + conn.execute( + "UPDATE transition_log SET reason = 'tampered' WHERE id = ?", + (target,), + ) + conn.commit() + result = verify_chain(conn, "transition_log", TRANSITION_HASH_COLS) + finally: + conn.close() + assert not result.ok + assert result.broken_id == target + assert result.expected_hash != result.actual_hash + + +def test_empty_chain_is_vacuously_ok(store: StateStore) -> None: + conn = _open(store) + try: + result = verify_chain(conn, "transition_log", TRANSITION_HASH_COLS) + finally: + conn.close() + assert result.ok + assert result.row_count == 0 + + +def test_head_sha_participates_in_row_hash(tmp_path: Path) -> None: + """Two rows identical in every column EXCEPT head_sha hash differently. + + Proves head_sha is inside the hashed business set: same id (1), same genesis + prev_hash, same everything else — only head_sha varies, so an equal row_hash + would mean head_sha was not being hashed. Uses fresh isolated stores so each + is a genuine first/genesis row. + """ + def _genesis_row_hash(head_sha: str, sub: str) -> str: + s = StateStore(tmp_path / sub / "orchestration.db") + conn = _open(s) + try: + write_chained_transition( + conn, + subtask_id=SUBTASK_ID, + task_id=TASK_ID, + from_state="review_pending", + to_state="merge_pending", + caller_role="coder", + timestamp="2026-01-01T00:00:00+00:00", + reason="ready to merge", + head_sha=head_sha, + ) + conn.commit() + row = conn.execute( + "SELECT row_hash FROM transition_log ORDER BY id ASC LIMIT 1" + ).fetchone() + finally: + conn.close() + return row["row_hash"] + + assert _genesis_row_hash("sha-aaaa", "a") != _genesis_row_hash("sha-bbbb", "b") + + +def test_in_place_head_sha_edit_breaks_chain(store: StateStore) -> None: + """Editing only head_sha out-of-band is tamper-evident via verify_chain. + + The merge gate pins on head_sha, so a forged SHA must break the chain. _walk + writes head_sha='deadbeef' on the review_pending row; we mutate it in place + WITHOUT recomputing the hash (an out-of-band sqlite3 edit) and assert the + chain breaks at exactly that row. + """ + _walk(store) + conn = _open(store) + try: + target = conn.execute( + "SELECT id FROM transition_log WHERE to_state = 'review_pending'" + ).fetchone()["id"] + conn.execute( + "UPDATE transition_log SET head_sha = 'forgedsha' WHERE id = ?", + (target,), + ) + conn.commit() + result = verify_chain(conn, "transition_log", TRANSITION_HASH_COLS) + finally: + conn.close() + assert not result.ok + assert result.broken_id == target + assert result.expected_hash != result.actual_hash + + +# ── audit_log chain + appends ──────────────────────────────────────────────── + +def test_pr_number_binding_appends_audit_row(store: StateStore) -> None: + store.ensure_subtask(SUBTASK_ID, TASK_ID) + store.set_pr_number(SUBTASK_ID, TASK_ID, 42) + conn = _open(store) + try: + rows = conn.execute( + "SELECT event_type, payload FROM audit_log" + ).fetchall() + result = verify_chain(conn, "audit_log", AUDIT_HASH_COLS) + finally: + conn.close() + assert [r["event_type"] for r in rows] == ["pr_number_binding"] + assert "42" in rows[0]["payload"] + assert result.ok and result.row_count == 1 + + +def test_marker_write_appends_audit_row(store: StateStore) -> None: + store.ensure_subtask(SUBTASK_ID, TASK_ID) + store.mark_merge_approval_requested(SUBTASK_ID, TASK_ID, "abc123") + conn = _open(store) + try: + rows = conn.execute("SELECT event_type FROM audit_log").fetchall() + finally: + conn.close() + assert [r["event_type"] for r in rows] == ["approval_request"] + + +def test_grant_appends_audit_row_and_reapproval_is_append_only(store: StateStore) -> None: + """Re-approval appends a SECOND audit row but leaves ONE current grant.""" + store.ensure_subtask(SUBTASK_ID, TASK_ID) + store.record_merge_approval(SUBTASK_ID, TASK_ID, approved_by="owner", approved_sha="sha-1") + store.record_merge_approval(SUBTASK_ID, TASK_ID, approved_by="owner", approved_sha="sha-2") + + conn = _open(store) + try: + audit = conn.execute( + "SELECT payload FROM audit_log WHERE event_type = 'approval_grant' " + "ORDER BY id ASC" + ).fetchall() + result = verify_chain(conn, "audit_log", AUDIT_HASH_COLS) + finally: + conn.close() + + # Two audit rows (history is append-only)... + assert len(audit) == 2 + assert "sha-1" in audit[0]["payload"] + assert "sha-2" in audit[1]["payload"] + # ...one current grant on the live columns (latest wins). + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.merge_approved_sha == "sha-2" + assert result.ok + + +def test_audit_chain_break_detected(store: StateStore) -> None: + store.ensure_subtask(SUBTASK_ID, TASK_ID) + store.set_pr_number(SUBTASK_ID, TASK_ID, 1) + store.set_pr_number(SUBTASK_ID, TASK_ID, 2) + conn = _open(store) + try: + first = conn.execute("SELECT id FROM audit_log ORDER BY id ASC LIMIT 1").fetchone()["id"] + conn.execute("UPDATE audit_log SET payload = '{\"pr_number\": 999}' WHERE id = ?", (first,)) + conn.commit() + result = verify_chain(conn, "audit_log", AUDIT_HASH_COLS) + finally: + conn.close() + assert not result.ok + assert result.broken_id == first + + +# ── migration backfill over a populated legacy DB ──────────────────────────── + +def test_migration_backfills_chain_over_legacy_rows(tmp_path: Path) -> None: + """A pre-Stage-3 DB (no chain columns) gets a valid chain on next open.""" + db_path = tmp_path / "state" / "orchestration.db" + db_path.parent.mkdir(parents=True) + + # Hand-build a legacy transition_log WITHOUT the chain columns. + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE transition_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subtask_id TEXT NOT NULL, + task_id TEXT NOT NULL, + from_state TEXT NOT NULL, + to_state TEXT NOT NULL, + caller_role TEXT NOT NULL, + timestamp TEXT NOT NULL, + reason TEXT + ); + """ + ) + for i in range(3): + conn.execute( + "INSERT INTO transition_log " + "(subtask_id, task_id, from_state, to_state, caller_role, timestamp, reason) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ("st-1", TASK_ID, "a", "b", "coder", f"2026-01-0{i+1}T00:00:00+00:00", f"r{i}"), + ) + conn.commit() + conn.close() + + # Opening the store runs the guarded migration + backfill. + StateStore(db_path) + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + cols = {r[1] for r in conn.execute("PRAGMA table_info(transition_log)").fetchall()} + assert {"prev_hash", "row_hash"} <= cols + result = verify_chain(conn, "transition_log", TRANSITION_HASH_COLS) + first = conn.execute( + "SELECT prev_hash FROM transition_log ORDER BY id ASC LIMIT 1" + ).fetchone() + finally: + conn.close() + assert result.ok and result.row_count == 3 + assert first["prev_hash"] == GENESIS_PREV_HASH + + +# ── cb verify-log command ──────────────────────────────────────────────────── + +def _project_with_db(tmp_path: Path) -> tuple[Path, StateStore]: + project = tmp_path / "proj" + project.mkdir() + ws = tmp_path / "ws" + (project / "codeband.yaml").write_text( + "repo:\n" + " url: https://github.com/o/r.git\n" + " branch: main\n" + "workspace:\n" + f" path: {ws}\n" + ) + store = StateStore(ws / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo", ROOM_ID, owner_id="owner-1") + return project, store + + +def test_verify_log_ok_exit_zero(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("CODEBAND_PROJECT_DIR", raising=False) + monkeypatch.delenv("WORKSPACE", raising=False) + project, store = _project_with_db(tmp_path) + _walk(store) + + from codeband.cli import cli + + result = CliRunner().invoke(cli, ["verify-log", "--dir", str(project)]) + assert result.exit_code == 0, result.output + assert "OK" in result.output + assert "transition_log" in result.output + assert "audit_log" in result.output + + +def test_verify_log_break_names_row_and_exits_nonzero(tmp_path: Path, monkeypatch) -> None: + monkeypatch.delenv("CODEBAND_PROJECT_DIR", raising=False) + monkeypatch.delenv("WORKSPACE", raising=False) + project, store = _project_with_db(tmp_path) + _walk(store) + + conn = sqlite3.connect(store.db_path) + ids = [r[0] for r in conn.execute("SELECT id FROM transition_log ORDER BY id ASC").fetchall()] + target = ids[1] + conn.execute("UPDATE transition_log SET to_state = 'forged' WHERE id = ?", (target,)) + conn.commit() + conn.close() + + from codeband.cli import cli + + result = CliRunner().invoke(cli, ["verify-log", "--dir", str(project)]) + assert result.exit_code != 0 + assert f"id={target}" in result.output diff --git a/tests/test_fsm.py b/tests/test_fsm.py new file mode 100644 index 0000000..2694a27 --- /dev/null +++ b/tests/test_fsm.py @@ -0,0 +1,290 @@ +"""Tests for the per-subtask FSM (RFC Workstream 2).""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from codeband.state.fsm import ( + VALID_TRANSITIONS, + InvalidTransitionError, + state_to_roles, + transition, +) +from codeband.state.store import StateStore + + +@pytest.fixture +def store(tmp_path) -> StateStore: + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(task_id="room-1", description="demo", room_id="room-1") + return s + + +def _log_rows(store: StateStore, subtask_id: str) -> list[sqlite3.Row]: + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + try: + return conn.execute( + "SELECT * FROM transition_log WHERE subtask_id = ? ORDER BY id", + (subtask_id,), + ).fetchall() + finally: + conn.close() + + +def test_valid_transitions_matches_rfc_table(): + assert VALID_TRANSITIONS == { + ("planned", "conductor"): frozenset({"assigned"}), + ("assigned", "coder"): frozenset({"in_progress"}), + ("in_progress", "coder"): frozenset({"verify_pending", "blocked"}), + # ``blocked`` is the coder's escalation escape once the verify-attempt + # cap is hit (the ``cb-phase`` CLI drives it; the ``review_pending`` + # advance is gated by the verify gates at runtime). + ("verify_pending", "coder"): frozenset({"review_pending", "blocked"}), + ("review_pending", "reviewer"): frozenset({"review_passed", "review_failed"}), + # ``blocked`` is the coder's escalation escape once the review-round cap + # is hit (the ``in_progress`` rework is then gated at runtime). + ("review_failed", "coder"): frozenset({"in_progress", "blocked"}), + # Stage-2 merge edge: queue for integration (gated at runtime by the + # SHA-pinned eligibility check), send back for a rebase, or — at the + # rebase-round cap — escalate (blocked), mirroring merge_pending. + ("review_passed", "mergemaster"): frozenset( + {"merge_pending", "needs_rebase", "blocked"} + ), + # Verifier acceptance gate (PR2): from review_passed the Verifier + # accepts (acceptance_passed, the verify_acceptance verdict pass-state) + # or rejects (review_failed, riding the review-round cap). + ("review_passed", "verifier"): frozenset( + {"acceptance_passed", "review_failed"} + ), + # From acceptance_passed the Mergemaster has the same three moves it has + # from review_passed — queue (gated), rebase send-back, or escalate. + ("acceptance_passed", "mergemaster"): frozenset( + {"merge_pending", "needs_rebase", "blocked"} + ), + # Stage-2 merge execution (cb-phase merge): land the PR, send it back + # on execution-time SHA drift / conflict, or record a residual merge + # failure (blocked → owner escalation via the watchdog). + ("merge_pending", "mergemaster"): frozenset( + {"merged", "needs_rebase", "blocked"} + ), + ("needs_rebase", "coder"): frozenset({"in_progress"}), + # Conductor recovery (cb-phase resume): a spurious block returns to + # the same worker, counters preserved — NOT a cap reset. + ("blocked", "conductor"): frozenset({"in_progress"}), + } + + +def test_state_to_roles_matches_rfc_table(): + # Derived from VALID_TRANSITIONS — sanity-check the canonical lookups the + # watchdog stall discriminator uses to resolve "who is expected to act + # next on this state?". Terminal states have no expected actor. + assert state_to_roles("planned") == {"conductor"} + assert state_to_roles("assigned") == {"coder"} + assert state_to_roles("in_progress") == {"coder"} + assert state_to_roles("verify_pending") == {"coder"} + assert state_to_roles("review_pending") == {"reviewer"} + assert state_to_roles("review_passed") == {"verifier", "mergemaster"} + assert state_to_roles("review_failed") == {"coder"} + assert state_to_roles("acceptance_passed") == {"mergemaster"} + assert state_to_roles("needs_rebase") == {"coder"} + assert state_to_roles("merge_pending") == {"mergemaster"} + assert state_to_roles("blocked") == {"conductor"} + assert state_to_roles("merged") == set() + assert state_to_roles("abandoned") == set() + + +def test_ensure_subtask_auto_creates_row(store): + assert store.get_subtask("st-1", "room-1") is None + transition("st-1", "room-1", "assigned", caller_role="conductor", store=store) + row = store.get_subtask("st-1", "room-1") + assert row is not None + assert row.task_id == "room-1" + + +def test_legal_transition_writes_state_and_one_log_row(store): + transition("st-1", "room-1", "assigned", caller_role="conductor", store=store) + + assert store.get_subtask("st-1", "room-1").state == "assigned" + rows = _log_rows(store, "st-1") + assert len(rows) == 1 + assert rows[0]["from_state"] == "planned" + assert rows[0]["to_state"] == "assigned" + assert rows[0]["caller_role"] == "conductor" + + +def test_illegal_target_raises_and_leaves_state_unchanged(store): + # planned → merged is not a legal edge. + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "merged", caller_role="conductor", store=store) + + assert store.get_subtask("st-1", "room-1").state == "planned" + assert _log_rows(store, "st-1") == [] + + +def test_wrong_caller_role_is_rejected(store): + # planned → assigned is legal for conductor, not for coder. + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "assigned", caller_role="coder", store=store) + + assert store.get_subtask("st-1", "room-1").state == "planned" + assert _log_rows(store, "st-1") == [] + + +def test_full_happy_path(store): + # The verify and review outcomes pin head_sha (as cb-phase does); the + # merge_pending step must then pass the eligibility gate at the same SHA. + steps = [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-1"), + ("review_passed", "reviewer", "sha-1"), + ("merge_pending", "mergemaster", "sha-1"), + ("merged", "mergemaster", None), + ] + for new_state, role, sha in steps: + transition("st-1", "room-1", new_state, caller_role=role, store=store, + head_sha=sha) + + assert store.get_subtask("st-1", "room-1").state == "merged" + assert len(_log_rows(store, "st-1")) == len(steps) + + +def test_review_failed_loops_back_to_in_progress(store): + for new_state, role in [ + ("assigned", "conductor"), + ("in_progress", "coder"), + ("verify_pending", "coder"), + ("review_pending", "coder"), + ("review_failed", "reviewer"), + ("in_progress", "coder"), + ]: + transition("st-1", "room-1", new_state, caller_role=role, store=store) + assert store.get_subtask("st-1", "room-1").state == "in_progress" + + +def test_conductor_can_abandon_any_non_terminal_state(store): + transition("st-1", "room-1", "assigned", caller_role="conductor", store=store) + transition("st-1", "room-1", "in_progress", caller_role="coder", store=store) + transition("st-1", "room-1", "abandoned", caller_role="conductor", store=store) + assert store.get_subtask("st-1", "room-1").state == "abandoned" + + +def test_no_transition_out_of_terminal_state(store): + for new_state, role, sha in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-1"), + ("review_passed", "reviewer", "sha-1"), + ("merge_pending", "mergemaster", "sha-1"), + ("merged", "mergemaster", None), + ]: + transition("st-1", "room-1", new_state, caller_role=role, store=store, + head_sha=sha) + + # merged is terminal — even the conductor cannot abandon it. + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "abandoned", caller_role="conductor", store=store) + assert store.get_subtask("st-1", "room-1").state == "merged" + + +# ── rebase-round counting + cap (S2-1) ─────────────────────────────────────── + +def _walk_to_review_passed(store, sid="st-1", sha="sha-1"): + for new_state, role, head in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", sha), + ("review_passed", "reviewer", sha), + ]: + transition(sid, "room-1", new_state, caller_role=role, store=store, + head_sha=head) + + +def _rework_to_review_passed(store, sid="st-1", sha="sha-1"): + """Walk a needs_rebase subtask back to review_passed (legal edges only).""" + for new_state, role, head in [ + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", sha), + ("review_passed", "reviewer", sha), + ]: + transition(sid, "room-1", new_state, caller_role=role, store=store, + head_sha=head) + + +def test_entering_needs_rebase_increments_rebase_rounds(store): + """One merge-gate send-back = one durable rebase round, counted by the FSM.""" + _walk_to_review_passed(store) + assert store.get_subtask("st-1", "room-1").rebase_rounds == 0 + + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", + store=store) + assert store.get_subtask("st-1", "room-1").rebase_rounds == 1 + + _rework_to_review_passed(store) + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", + store=store) + assert store.get_subtask("st-1", "room-1").rebase_rounds == 2 + + +def test_rebase_rounds_survive_process_restart(store): + """The counter is durable — a reopened store reads the committed count.""" + _walk_to_review_passed(store) + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", + store=store) + + reopened = StateStore(store.db_path) # fresh instance, same file + assert reopened.get_subtask("st-1", "room-1").rebase_rounds == 1 + + +def test_needs_rebase_rejected_at_cap(store): + """The FSM refuses another send-back at the cap; blocked stays legal.""" + _walk_to_review_passed(store) + for _ in range(2): + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", + store=store, max_rebase_rounds=2) + _rework_to_review_passed(store) + + with pytest.raises(InvalidTransitionError, match="Rebase-round cap"): + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", + store=store, max_rebase_rounds=2) + # The rejection wrote nothing; the escalation escape remains legal. + assert store.get_subtask("st-1", "room-1").state == "review_passed" + assert store.get_subtask("st-1", "room-1").rebase_rounds == 2 + transition("st-1", "room-1", "blocked", caller_role="watchdog", store=store) + assert store.get_subtask("st-1", "room-1").state == "blocked" + + +def test_review_round_and_rebase_rounds_are_independent(store): + """A reviewer rejection and a merge-gate send-back advance separate counters.""" + for new_state, role, head in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-1"), + ("review_failed", "reviewer", None), + ]: + transition("st-1", "room-1", new_state, caller_role=role, store=store, + head_sha=head) + sub = store.get_subtask("st-1", "room-1") + assert sub.review_round == 1 + assert sub.rebase_rounds == 0 + + for new_state, role, head in [ + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-1"), + ("review_passed", "reviewer", "sha-1"), + ("needs_rebase", "mergemaster", None), + ]: + transition("st-1", "room-1", new_state, caller_role=role, store=store, + head_sha=head) + sub = store.get_subtask("st-1", "room-1") + assert sub.review_round == 1 + assert sub.rebase_rounds == 1 diff --git a/tests/test_handoff.py b/tests/test_handoff.py new file mode 100644 index 0000000..5f12b0e --- /dev/null +++ b/tests/test_handoff.py @@ -0,0 +1,1114 @@ +"""Tests for the ``cb-phase`` verify-gated handoff CLI (RFC Workstream 3).""" + +from __future__ import annotations + +import pytest + +from codeband.cli import handoff +from codeband.state.fsm import transition +from codeband.state.store import StateStore + + +@pytest.fixture +def store(tmp_path) -> StateStore: + """A store with a subtask already advanced to ``verify_pending``.""" + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(task_id="room-1", description="demo", room_id="room-1") + transition("st-1", "room-1", "assigned", caller_role="conductor", store=s) + transition("st-1", "room-1", "in_progress", caller_role="coder", store=s) + transition("st-1", "room-1", "verify_pending", caller_role="coder", store=s) + return s + + +@pytest.fixture +def patch_gates(monkeypatch, store): + """Wire the handoff helpers to controllable defaults (all gates pass).""" + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + # task_id resolution is its own seam (tested for real below); here the active + # room is always the fixture's ``room-1`` regardless of the ``--task`` label. + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda project_dir, store, task_arg: ("room-1", None), + ) + monkeypatch.setattr(handoff, "_verify_command", lambda project_dir, worktree: "verify-cmd") + monkeypatch.setattr(handoff, "_max_verify_attempts", lambda project_dir: 20) + monkeypatch.setattr(handoff, "_max_review_rounds", lambda project_dir: 3) + monkeypatch.setattr(handoff, "_uncommitted_files", lambda worktree: []) + monkeypatch.setattr(handoff, "_current_branch", lambda worktree: "feat-x") + monkeypatch.setattr(handoff, "_run_verify_command", lambda cmd, cwd: (0, "")) + monkeypatch.setattr(handoff, "_git_head", lambda worktree: "cafe1234") + monkeypatch.setattr( + handoff, "_verify_infra_exit_codes", + lambda project_dir, worktree: handoff._DEFAULT_INFRA_EXIT_CODES, + ) + # Verify's ONE gh snapshot: OPEN, on the worktree's branch, with the PR + # head matching the worktree HEAD (the coder pushed) by default. + monkeypatch.setattr( + handoff, "_verify_pr_snapshot", + lambda project_dir, pr: { + "state": "OPEN", "headRefName": "feat-x", "headRefOid": "cafe1234", + }, + ) + return store + + +def _snapshot(monkeypatch, **overrides): + """Re-stub the verify snapshot with specific fields overridden.""" + base = {"state": "OPEN", "headRefName": "feat-x", "headRefOid": "cafe1234"} + base.update(overrides) + monkeypatch.setattr( + handoff, "_verify_pr_snapshot", lambda project_dir, pr: dict(base), + ) + + +def _run(): + return handoff.main(["verify", "st-1", "--task", "room-1", "--pr", "42"]) + + +def test_verify_success_advances_to_review_pending(patch_gates): + store = patch_gates + assert _run() == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + +def test_verify_fails_on_dirty_tree(patch_gates, monkeypatch): + store = patch_gates + monkeypatch.setattr(handoff, "_uncommitted_files", lambda worktree: ["M a.py"]) + assert _run() != 0 + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + +def test_verify_fails_on_non_open_pr(patch_gates, monkeypatch): + store = patch_gates + _snapshot(monkeypatch, state="CLOSED") + assert _run() != 0 + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + +def test_verify_fails_on_failing_verify_command(patch_gates, monkeypatch): + store = patch_gates + monkeypatch.setattr(handoff, "_run_verify_command", lambda cmd, cwd: (1, "boom")) + assert _run() != 0 + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + +def test_inrepo_config_present_takes_priority(tmp_path, monkeypatch): + """In-repo .codeband.yaml verify_command wins over home config.""" + from types import SimpleNamespace + + worktree = tmp_path / "target" + worktree.mkdir() + (worktree / ".codeband.yaml").write_text("verify_command: make test\n") + monkeypatch.setattr( + handoff, "load_config", + lambda p: SimpleNamespace( + agents=SimpleNamespace(handoff_verify_command="home-cmd"), + ), + ) + assert handoff._verify_command(tmp_path, worktree) == "make test" + + +def test_inrepo_absent_home_config_used(tmp_path, monkeypatch): + """When no in-repo .codeband.yaml, falls back to home handoff_verify_command.""" + from types import SimpleNamespace + + worktree = tmp_path / "target" + worktree.mkdir() + monkeypatch.setattr( + handoff, "load_config", + lambda p: SimpleNamespace( + agents=SimpleNamespace(handoff_verify_command="home-cmd"), + ), + ) + assert handoff._verify_command(tmp_path, worktree) == "home-cmd" + + +def test_neither_source_resolves_returns_none(tmp_path, monkeypatch): + """When neither in-repo nor home config has a command, returns None.""" + from types import SimpleNamespace + + worktree = tmp_path / "target" + worktree.mkdir() + monkeypatch.setattr( + handoff, "load_config", + lambda p: SimpleNamespace( + agents=SimpleNamespace(handoff_verify_command=None), + ), + ) + assert handoff._verify_command(tmp_path, worktree) is None + + +def test_no_verify_command_fails_loud_and_does_not_pass(patch_gates, monkeypatch, capsys): + """When no verify command resolves, gate refuses with EXIT_NO_VERIFY_COMMAND.""" + store = patch_gates + monkeypatch.setattr(handoff, "_verify_command", lambda project_dir, worktree: None) + + def _boom(cmd, cwd): # pragma: no cover - must not be called + raise AssertionError("verify command must not run when unresolved") + + monkeypatch.setattr(handoff, "_run_verify_command", _boom) + assert _run() == handoff.EXIT_NO_VERIFY_COMMAND + err = capsys.readouterr().err + assert "REJECTED [no_verify_command]" in err + assert ".codeband.yaml" in err + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + +# ── rebase rework: verify re-entry from the merge gate's send-back ────────── + +def _send_back_for_rebase(store): + """Drive the fixture subtask to ``needs_rebase`` via legal FSM edges.""" + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + transition("st-1", "room-1", "review_passed", caller_role="reviewer", store=store) + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", store=store) + + +def test_verify_from_needs_rebase_walks_to_review_pending(patch_gates): + """The rebased commit re-enters the normal verify walk — the coder's only + exit from the merge gate's send-back is ``cb-phase verify``.""" + store = patch_gates + _send_back_for_rebase(store) + assert _run() == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + +def test_verify_from_needs_rebase_ignores_review_round_cap(patch_gates, monkeypatch): + """A merge-gate send-back is not a review round: the walk must succeed even + when the review-round cap would reject a ``review_failed`` rework.""" + store = patch_gates + monkeypatch.setattr(handoff, "_max_review_rounds", lambda project_dir: 0) + _send_back_for_rebase(store) + assert _run() == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + +def test_verify_from_needs_rebase_does_not_increment_review_round(patch_gates): + store = patch_gates + _send_back_for_rebase(store) + before = store.get_subtask("st-1", "room-1").review_round + assert _run() == 0 + assert store.get_subtask("st-1", "room-1").review_round == before + + +# ── structured, actionable rejections (one stable tag + exit code per mode) ── + +def test_dirty_tree_emits_tag_and_exit_code(patch_gates, monkeypatch, capsys): + monkeypatch.setattr( + handoff, "_uncommitted_files", lambda worktree: ["M a.py", "?? b.py"], + ) + assert _run() == handoff.EXIT_DIRTY_TREE + err = capsys.readouterr().err + assert "REJECTED [dirty_tree]: 2 uncommitted files." in err + assert "Commit or stash, then re-run cb-phase verify." in err + + +def test_no_pr_emits_tag_branch_and_exit_code(patch_gates, monkeypatch, capsys): + _snapshot(monkeypatch, state="CLOSED") + monkeypatch.setattr(handoff, "_current_branch", lambda worktree: "feat/login") + assert _run() == handoff.EXIT_NO_PR + err = capsys.readouterr().err + assert "REJECTED [no_pr]: no open PR for branch feat/login." in err + assert "Push and open a PR, then re-run." in err + + +def test_verify_failed_emits_tag_exitcode_and_tail(patch_gates, monkeypatch, capsys): + tail = "line-a\nline-b\nFAILED: assertion" + monkeypatch.setattr(handoff, "_run_verify_command", lambda cmd, cwd: (7, tail)) + assert _run() == handoff.EXIT_VERIFY_FAILED + err = capsys.readouterr().err + assert "REJECTED [verify_failed] (exit 7):" in err + assert "FAILED: assertion" in err + assert "Fix and re-run." in err + + +def test_verify_failed_tail_is_truncated(patch_gates, monkeypatch, capsys): + big = "\n".join(f"row-{i}" for i in range(100)) + monkeypatch.setattr(handoff, "_run_verify_command", lambda cmd, cwd: (1, big)) + assert _run() == handoff.EXIT_VERIFY_FAILED + err = capsys.readouterr().err + assert "row-99" in err # the tail is kept + assert "row-0\n" not in err # the head is dropped + # Only the last N lines of the command output are surfaced. + assert err.count("row-") <= handoff._VERIFY_OUTPUT_TAIL_LINES + + +# ── infra exit-code bypass ──────────────────────────────────────────────────── + +def test_verify_infra_exit_does_not_burn_attempt(patch_gates, monkeypatch, capsys): + """An infra exit code (127 = command not found) must NOT burn a verify attempt.""" + store = patch_gates + monkeypatch.setattr(handoff, "_run_verify_command", lambda cmd, cwd: (127, "command not found")) + monkeypatch.setattr(handoff, "_verify_infra_exit_codes", lambda p, w: frozenset({127})) + attempts_before = store.get_subtask("st-1", "room-1").verify_attempts + + assert _run() == handoff.EXIT_VERIFY_INFRA_FAILED + err = capsys.readouterr().err + assert "REJECTED [verify_infra_failed]" in err + assert "no attempt burned" in err + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "verify_pending" + assert sub.verify_attempts == attempts_before + + +@pytest.mark.parametrize("code", sorted(handoff._DEFAULT_INFRA_EXIT_CODES)) +def test_verify_default_infra_codes_bypass_budget(patch_gates, monkeypatch, code): + """Every default infra exit code routes to EXIT_VERIFY_INFRA_FAILED, no burn.""" + store = patch_gates + monkeypatch.setattr(handoff, "_run_verify_command", lambda cmd, cwd: (code, "")) + attempts_before = store.get_subtask("st-1", "room-1").verify_attempts + + assert _run() == handoff.EXIT_VERIFY_INFRA_FAILED + assert store.get_subtask("st-1", "room-1").verify_attempts == attempts_before + + +def test_verify_exit_1_still_burns_attempt(patch_gates, monkeypatch): + """Exit 1 (real test failure) is NOT in the infra set — must burn one attempt.""" + store = patch_gates + monkeypatch.setattr(handoff, "_run_verify_command", lambda cmd, cwd: (1, "tests failed")) + attempts_before = store.get_subtask("st-1", "room-1").verify_attempts + + assert _run() == handoff.EXIT_VERIFY_FAILED + assert store.get_subtask("st-1", "room-1").verify_attempts == attempts_before + 1 + + +def test_verify_infra_codes_inrepo_overrides_default(tmp_path): + """An in-repo .codeband.yaml with verify_infra_exit_codes overrides the default.""" + import unittest.mock as mock + + inrepo = tmp_path / ".codeband.yaml" + inrepo.write_text("verify_infra_exit_codes:\n - 42\n") + with mock.patch.object( + handoff, "load_config", + return_value=mock.MagicMock(agents=mock.MagicMock(verify_infra_exit_codes=None)), + ): + codes = handoff._verify_infra_exit_codes(tmp_path, tmp_path) + assert 42 in codes + assert 127 not in codes # overrides the default entirely + + +def test_verify_infra_codes_home_config_used_when_no_inrepo(tmp_path): + """Home config verify_infra_exit_codes is used when the worktree has no override.""" + import unittest.mock as mock + + with mock.patch.object( + handoff, "load_config", + return_value=mock.MagicMock(agents=mock.MagicMock(verify_infra_exit_codes=[99])), + ): + codes = handoff._verify_infra_exit_codes(tmp_path, tmp_path) + assert codes == frozenset({99}) + + +def test_verify_infra_codes_invalid_inrepo_falls_through(tmp_path): + """A malformed verify_infra_exit_codes in .codeband.yaml falls through to default.""" + import unittest.mock as mock + + inrepo = tmp_path / ".codeband.yaml" + inrepo.write_text("verify_infra_exit_codes: not-a-list\n") + with mock.patch.object( + handoff, "load_config", + return_value=mock.MagicMock(agents=mock.MagicMock(verify_infra_exit_codes=None)), + ): + codes = handoff._verify_infra_exit_codes(tmp_path, tmp_path) + assert codes == handoff._DEFAULT_INFRA_EXIT_CODES + + +def test_cap_reached_emits_blocked_tag_and_exit_code(patch_gates, monkeypatch, capsys): + store = patch_gates + # Force the subtask to the cap so the next call escalates. + monkeypatch.setattr(handoff, "_max_verify_attempts", lambda project_dir: 3) + for _ in range(3): + store.increment_verify_attempts("st-1", "room-1") + assert _run() == handoff.EXIT_CAP_REACHED + err = capsys.readouterr().err + assert "BLOCKED [cap_reached]: 3 verify attempts." in err + assert "Escalated to human; stop and await." in err + assert store.get_subtask("st-1", "room-1").state == "blocked" + + +def test_each_failure_mode_has_a_distinct_exit_code(): + codes = { + handoff.EXIT_DIRTY_TREE, + handoff.EXIT_NO_PR, + handoff.EXIT_VERIFY_FAILED, + handoff.EXIT_CAP_REACHED, + handoff.EXIT_NO_ACTIVE_TASK, + handoff.EXIT_HEAD_UNRESOLVED, + handoff.EXIT_HEAD_MISMATCH, + handoff.EXIT_PR_QUERY_FAILED, + handoff.EXIT_WRONG_PR, + handoff.EXIT_INVALID_SUBTASK_ID, + handoff.EXIT_NO_VERIFY_COMMAND, + handoff.EXIT_VERIFY_INFRA_FAILED, + } + assert len(codes) == 12 # all distinct + assert 0 not in codes # never collide with success + # 7–12 belong to the merge leg (cli/merge.py) — never reuse them here. + assert codes.isdisjoint(range(7, 13)) + + +def test_uncommitted_files_reads_porcelain(monkeypatch, tmp_path): + calls = {} + + class _Result: + returncode = 0 + stdout = " M a.py\n?? b.py\n" + + def _fake_run(cmd, **kwargs): + calls["cmd"] = cmd + return _Result() + + monkeypatch.setattr(handoff.subprocess, "run", _fake_run) + files = handoff._uncommitted_files(tmp_path) + assert files == [" M a.py", "?? b.py"] + assert calls["cmd"][:2] == ["git", "-C"] + + +def test_uncommitted_files_clean_tree_is_empty(monkeypatch, tmp_path): + class _Result: + returncode = 0 + stdout = " \n" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Result()) + assert handoff._uncommitted_files(tmp_path) == [] + + +def test_uncommitted_files_treats_git_failure_as_dirty(monkeypatch, tmp_path): + class _Result: + returncode = 128 + stdout = "" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Result()) + assert handoff._uncommitted_files(tmp_path) != [] # non-empty → gate rejects + + +def test_verify_pr_snapshot_queries_gh_once_with_repo_slug(monkeypatch, tmp_path): + """ONE query, cwd-independent by construction: --repo from config repo.url, + and all three decision fields requested together.""" + from types import SimpleNamespace + + calls = [] + + class _Result: + returncode = 0 + stdout = '{"state": "OPEN", "headRefName": "feat-x", "headRefOid": "cafe1234"}' + stderr = "" + + def _fake_run(cmd, **kwargs): + calls.append(cmd) + return _Result() + + monkeypatch.setattr( + handoff, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + ), + ) + monkeypatch.setattr(handoff.subprocess, "run", _fake_run) + snap = handoff._verify_pr_snapshot(tmp_path, 7) + assert snap == { + "state": "OPEN", "headRefName": "feat-x", "headRefOid": "cafe1234", + } + assert calls == [[ + "gh", "pr", "view", "7", + "--json", "state,headRefName,headRefOid", "--repo", "acme/widgets", + ]] + + +def test_verify_pr_snapshot_returns_none_on_failure(monkeypatch, tmp_path): + from types import SimpleNamespace + + monkeypatch.setattr( + handoff, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + ), + ) + + class _Fail: + returncode = 1 + stdout = "" + stderr = "no such PR" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Fail()) + assert handoff._verify_pr_snapshot(tmp_path, 7) is None + + class _Garbage: + returncode = 0 + stdout = "not json" + stderr = "" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Garbage()) + assert handoff._verify_pr_snapshot(tmp_path, 7) is None + + +def test_verify_pr_snapshot_returns_none_on_non_github_url(monkeypatch, tmp_path): + from types import SimpleNamespace + + monkeypatch.setattr( + handoff, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://gitlab.example.com/g/p.git"), + ), + ) + + def _boom(cmd, **kw): # pragma: no cover - must not be called + raise AssertionError("gh must not run without a resolvable slug") + + monkeypatch.setattr(handoff.subprocess, "run", _boom) + assert handoff._verify_pr_snapshot(tmp_path, 7) is None + + +# ── cb-phase start — seed the subtask lifecycle into in_progress ───────────── + +def _start(store, monkeypatch, subtask_id): + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda project_dir, store, task_arg: ("room-1", None), + ) + return handoff.main(["start", subtask_id, "--task", "room-1"]) + + +def test_start_creates_nonexistent_subtask_in_progress(store, monkeypatch, capsys): + # st-2 does not exist yet — start must create it and land it in_progress. + assert store.get_subtask("st-2", "room-1") is None + assert _start(store, monkeypatch, "st-2") == 0 + assert store.get_subtask("st-2", "room-1").state == "in_progress" + out = capsys.readouterr().out + assert "subtask st-2 → in_progress (task room-1)." in out + + +def test_start_is_idempotent(store, monkeypatch, capsys): + # Starting twice is a no-op the second time — never moves backward. + assert _start(store, monkeypatch, "st-2") == 0 + assert _start(store, monkeypatch, "st-2") == 0 + assert store.get_subtask("st-2", "room-1").state == "in_progress" + assert "already at in_progress" in capsys.readouterr().out + + +def test_start_non_regressing_on_verify_pending(store, monkeypatch, capsys): + # The `store` fixture leaves st-1 at verify_pending — start must not rewind. + assert _start(store, monkeypatch, "st-1") == 0 + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + assert "already at verify_pending" in capsys.readouterr().out + + +def test_start_non_regressing_on_review_failed(store, monkeypatch, capsys): + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + transition("st-1", "room-1", "review_failed", caller_role="reviewer", store=store) + assert _start(store, monkeypatch, "st-1") == 0 + assert store.get_subtask("st-1", "room-1").state == "review_failed" + assert "already at review_failed" in capsys.readouterr().out + + +def test_start_from_assigned_walks_to_in_progress(monkeypatch, tmp_path, capsys): + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(task_id="room-1", description="demo", room_id="room-1") + transition("st-1", "room-1", "assigned", caller_role="conductor", store=s) + assert _start(s, monkeypatch, "st-1") == 0 + assert s.get_subtask("st-1", "room-1").state == "in_progress" + + +def test_start_task_label_is_optional(store, monkeypatch): + # ``--task`` is now an optional, non-authoritative label: omitting it must + # not error at the parser. The active room is resolved from .codeband_room + # (stubbed here), so start still seeds the subtask. + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda project_dir, s, task_arg: ("room-1", None), + ) + assert handoff.main(["start", "st-2"]) == 0 + assert store.get_subtask("st-2", "room-1").state == "in_progress" + + +# ── cb-phase review — reviewer verdict routed through the FSM ──────────────── + +def _review(monkeypatch, store, verdict: str): + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda project_dir, s, task_arg: ("room-1", None), + ) + # The verdict SHA comes from the PR head — never the invoker's cwd HEAD + # (the shipped reviewer runs in a repo-less scratch dir). + monkeypatch.setattr(handoff, "_pr_head_sha", lambda project_dir, pr: "beef5678") + monkeypatch.setattr(handoff, "_git_head", lambda worktree: "cwd-head-must-not-be-used") + return handoff.main(["review", "st-1", "--task", "room-1", "--pr", "42", verdict]) + + +def test_review_approve_advances_to_review_passed(store, monkeypatch): + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + assert _review(monkeypatch, store, "--approve") == 0 + assert store.get_subtask("st-1", "room-1").state == "review_passed" + + +def test_review_reject_advances_to_review_failed(store, monkeypatch): + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + assert _review(monkeypatch, store, "--reject") == 0 + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_failed" + assert sub.review_round == 1 # a reject is one failed review round + + +def test_review_illegal_from_verify_pending_writes_nothing(store, monkeypatch, capsys): + # The `store` fixture leaves st-1 at verify_pending (no review yet). + assert _review(monkeypatch, store, "--approve") == 1 + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + assert "review verdict rejected" in capsys.readouterr().err + + +def test_review_requires_an_explicit_verdict(): + # Mutually-exclusive --approve/--reject is required → argparse exits. + with pytest.raises(SystemExit): + handoff.main(["review", "st-1", "--task", "room-1"]) + + +# ── head_sha — SHA-pinned verify / review outcome records (additive) ───────── + +def _last_transition_row(store, to_state: str): + """Fetch the newest transition_log row landing in ``to_state``.""" + import sqlite3 + + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + try: + return conn.execute( + "SELECT * FROM transition_log WHERE to_state = ? ORDER BY id DESC LIMIT 1", + (to_state,), + ).fetchone() + finally: + conn.close() + + +def test_verify_outcome_records_head_sha(patch_gates): + store = patch_gates + assert _run() == 0 + row = _last_transition_row(store, "review_pending") + assert row is not None + assert row["head_sha"] == "cafe1234" + + +def test_review_outcome_records_head_sha_on_approve(store, monkeypatch): + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + assert _review(monkeypatch, store, "--approve") == 0 + row = _last_transition_row(store, "review_passed") + assert row is not None + assert row["head_sha"] == "beef5678" + + +def test_review_outcome_records_head_sha_on_reject(store, monkeypatch): + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + assert _review(monkeypatch, store, "--reject") == 0 + row = _last_transition_row(store, "review_failed") + assert row is not None + assert row["head_sha"] == "beef5678" + + +def test_transitions_without_head_sha_store_null(store): + # Every non-outcome transition (and any legacy caller) leaves head_sha + # NULL — the field is additive and the read path is untouched. + row = _last_transition_row(store, "verify_pending") # from the fixture walk + assert row is not None + assert row["head_sha"] is None + + +def test_git_head_returns_none_outside_a_repo(monkeypatch, tmp_path): + class _Result: + returncode = 128 + stdout = "" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Result()) + # Best-effort by design: an unresolvable HEAD pins nothing (NULL), it + # never blocks the transition. + assert handoff._git_head(tmp_path) is None + + +def test_git_head_parses_rev_parse_output(monkeypatch, tmp_path): + class _Result: + returncode = 0 + stdout = "a3f9c2e8b1d4567890abcdef12345678deadbeef\n" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Result()) + assert handoff._git_head(tmp_path) == "a3f9c2e8b1d4567890abcdef12345678deadbeef" + + +# ── verdict SHA from the PR head — fail loud on unresolvable, never NULL ────── +# +# Root cause pinned here: the verdict head_sha used to be `git rev-parse HEAD` +# of the invoker's cwd. The shipped Code Reviewer has NO git repo (scratch +# dir), so the prompted review flow could only ever record NULL — and every +# gated merge rejected not_eligible (the 2026-06-10 Scenario A incident). + + +def _count_transition_rows(store) -> int: + import sqlite3 + + conn = sqlite3.connect(store.db_path) + try: + return conn.execute("SELECT COUNT(*) FROM transition_log").fetchone()[0] + finally: + conn.close() + + +def test_review_pr_argument_is_required(store, monkeypatch): + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + with pytest.raises(SystemExit): + handoff.main(["review", "st-1", "--task", "room-1", "--approve"]) + + +def test_review_verdict_pins_pr_head_not_cwd_head(store, monkeypatch): + """The recorded SHA is the PR head from gh — the invoker's cwd HEAD (which + the repo-less reviewer cannot even produce) must play no part.""" + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + assert _review(monkeypatch, store, "--approve") == 0 + row = _last_transition_row(store, "review_passed") + assert row["head_sha"] == "beef5678" # the PR head… + assert row["head_sha"] != "cwd-head-must-not-be-used" # …not the cwd HEAD + + +def test_review_head_unresolved_records_nothing_and_fails_loud( + store, monkeypatch, capsys, +): + """A verdict that pins nothing must never report success: gh failure → + loud rejection, non-zero exit, NO transition row (today's silent NULL + poisoned the merge gate invisibly).""" + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda project_dir, s, task_arg: ("room-1", None), + ) + monkeypatch.setattr(handoff, "_pr_head_sha", lambda project_dir, pr: None) + rows_before = _count_transition_rows(store) + + code = handoff.main(["review", "st-1", "--task", "room-1", "--pr", "42", "--approve"]) + + assert code == handoff.EXIT_HEAD_UNRESOLVED + err = capsys.readouterr().err + assert "REJECTED [head_unresolved]" in err + assert "verdict NOT recorded" in err + assert _count_transition_rows(store) == rows_before # nothing written + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + +def test_verify_head_mismatch_burns_attempt_and_records_nothing( + patch_gates, monkeypatch, capsys, +): + """Worktree HEAD ≠ PR head: the coder forgot to push — a legitimate coder + error that counts as one verify attempt and writes no review_pending row.""" + store = patch_gates + _snapshot(monkeypatch, headRefOid="feed0042") + attempts_before = store.get_subtask("st-1", "room-1").verify_attempts + + assert _run() == handoff.EXIT_HEAD_MISMATCH + err = capsys.readouterr().err + assert "REJECTED [head_mismatch]" in err + assert "cafe1234" in err and "feed0042" in err # names both SHAs + assert "push your commits" in err + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "verify_pending" # no review_pending row + assert sub.verify_attempts == attempts_before + 1 # one attempt burned + + +def test_verify_pr_head_unresolved_fails_loud_without_burning_attempt( + patch_gates, monkeypatch, capsys, +): + """The snapshot resolved (OPEN, right branch) but carries no head SHA — + still an infra failure: loud, nothing recorded, no attempt burned.""" + store = patch_gates + _snapshot(monkeypatch, headRefOid=None) + attempts_before = store.get_subtask("st-1", "room-1").verify_attempts + + assert _run() == handoff.EXIT_HEAD_UNRESOLVED + err = capsys.readouterr().err + assert "REJECTED [head_unresolved]" in err + assert "verify outcome NOT recorded" in err + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "verify_pending" + # Infra failure, not a coder error — the attempt budget is untouched. + assert sub.verify_attempts == attempts_before + + +def test_verify_worktree_head_unresolved_fails_loud_instead_of_null( + patch_gates, monkeypatch, capsys, +): + """Previously an unresolvable worktree HEAD silently recorded NULL — now + it is a loud head_unresolved rejection that records nothing.""" + store = patch_gates + monkeypatch.setattr(handoff, "_git_head", lambda worktree: None) + + assert _run() == handoff.EXIT_HEAD_UNRESOLVED + assert "REJECTED [head_unresolved]" in capsys.readouterr().err + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + +# ── one-snapshot verify matrix (C1): infra/no-burn, closed, wrong-PR, bind ── + +def test_verify_pr_query_failed_does_not_burn_attempt( + patch_gates, monkeypatch, capsys, +): + """gh infra failure (snapshot is None): loud tagged rejection, nothing + recorded, no verify attempt burned — infra never burns durable budget.""" + store = patch_gates + monkeypatch.setattr(handoff, "_verify_pr_snapshot", lambda project_dir, pr: None) + attempts_before = store.get_subtask("st-1", "room-1").verify_attempts + + assert _run() == handoff.EXIT_PR_QUERY_FAILED + err = capsys.readouterr().err + assert "REJECTED [pr_query_failed]" in err + assert "no attempt burned" in err + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "verify_pending" + assert sub.verify_attempts == attempts_before + + +def test_verify_wrong_pr_burns_attempt_and_names_both_branches( + patch_gates, monkeypatch, capsys, +): + """An OPEN PR whose head branch is not the worktree's branch is some + OTHER PR's number — a coder error that burns one attempt and writes no + transition. Closes the any-open-PR-number gate hole.""" + store = patch_gates + _snapshot(monkeypatch, headRefName="feat-other") + attempts_before = store.get_subtask("st-1", "room-1").verify_attempts + + assert _run() == handoff.EXIT_WRONG_PR + err = capsys.readouterr().err + assert "REJECTED [wrong_pr]" in err + assert "feat-other" in err and "feat-x" in err # names both branches + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "verify_pending" + assert sub.verify_attempts == attempts_before + 1 + + +def test_verify_wrong_pr_rejects_before_running_the_verify_command( + patch_gates, monkeypatch, +): + """The wrong-PR check precedes the (expensive) verify command — a wrong + PR number must not buy a free test run.""" + _snapshot(monkeypatch, headRefName="feat-other") + + def _boom(cmd, cwd): # pragma: no cover - must not be called + raise AssertionError("verify command must not run for a wrong PR") + + monkeypatch.setattr(handoff, "_run_verify_command", _boom) + assert _run() == handoff.EXIT_WRONG_PR + + +def test_verify_unresolvable_worktree_branch_fails_loud_without_burn( + patch_gates, monkeypatch, capsys, +): + """A detached/broken worktree (no branch name) is an infra failure, not a + coder error: loud rejection, no burn — the wrong-PR check must never + pass-by-default on a missing branch.""" + store = patch_gates + monkeypatch.setattr(handoff, "_current_branch", lambda worktree: None) + attempts_before = store.get_subtask("st-1", "room-1").verify_attempts + + assert _run() == handoff.EXIT_HEAD_UNRESOLVED + assert "REJECTED [head_unresolved]" in capsys.readouterr().err + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "verify_pending" + assert sub.verify_attempts == attempts_before + + +def test_verify_pass_persists_the_pr_binding(patch_gates): + """On PASS the subtask↔PR binding is created by the coder who knows the + PR — not first at merge time.""" + store = patch_gates + assert store.get_subtask("st-1", "room-1").pr_number is None + + assert _run() == 0 + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_pending" + assert sub.pr_number == 42 + + +def test_verify_rejection_does_not_bind_the_pr(patch_gates, monkeypatch): + """A failed gate must not bind: only a PROVEN PR is persisted.""" + store = patch_gates + _snapshot(monkeypatch, headRefOid="feed0042") # head mismatch → reject + assert _run() == handoff.EXIT_HEAD_MISMATCH + assert store.get_subtask("st-1", "room-1").pr_number is None + + +def test_pr_head_sha_queries_gh_with_repo_slug(monkeypatch, tmp_path): + """cwd-independent by construction: --repo comes from config's repo.url.""" + from types import SimpleNamespace + + calls = {} + + class _Result: + returncode = 0 + stdout = '{"headRefOid": "a3f9c2e8"}' + stderr = "" + + def _fake_run(cmd, **kwargs): + calls["cmd"] = cmd + return _Result() + + monkeypatch.setattr( + handoff, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + ), + ) + monkeypatch.setattr(handoff.subprocess, "run", _fake_run) + assert handoff._pr_head_sha(tmp_path, 42) == "a3f9c2e8" + assert calls["cmd"] == [ + "gh", "pr", "view", "42", "--json", "headRefOid", "--repo", "acme/widgets", + ] + + +def test_pr_head_sha_returns_none_on_failure(monkeypatch, tmp_path): + from types import SimpleNamespace + + monkeypatch.setattr( + handoff, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + ), + ) + + class _Fail: + returncode = 1 + stdout = "" + stderr = "no such PR" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Fail()) + assert handoff._pr_head_sha(tmp_path, 42) is None + + class _Empty: + returncode = 0 + stdout = '{"headRefOid": ""}' + stderr = "" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Empty()) + assert handoff._pr_head_sha(tmp_path, 42) is None + + class _Garbage: + returncode = 0 + stdout = "not json" + stderr = "" + + monkeypatch.setattr(handoff.subprocess, "run", lambda cmd, **kw: _Garbage()) + assert handoff._pr_head_sha(tmp_path, 42) is None + + +def test_pr_head_sha_returns_none_on_non_github_url(monkeypatch, tmp_path): + from types import SimpleNamespace + + monkeypatch.setattr( + handoff, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://gitlab.example.com/g/p.git"), + ), + ) + + def _boom(cmd, **kw): # pragma: no cover - must not be called + raise AssertionError("gh must not run without a resolvable slug") + + monkeypatch.setattr(handoff.subprocess, "run", _boom) + assert handoff._pr_head_sha(tmp_path, 42) is None + + +# --- claim-time guard --- + + +@pytest.mark.parametrize("bad_id", ["claim-time-guard", "subtask-1"]) +def test_invalid_subtask_id_rejected_with_tagged_stderr(bad_id, capsys): + """The validator refuses anything that is not ``st-N``.""" + assert handoff._validate_subtask_id(bad_id) == handoff.EXIT_INVALID_SUBTASK_ID + err = capsys.readouterr().err + assert "REJECTED [invalid_subtask_id]" in err + assert repr(bad_id) in err + + +@pytest.mark.parametrize("good_id", ["st-1", "st-99"]) +def test_valid_subtask_id_passes_validator(good_id): + """``st-N`` ids return None — no rejection.""" + assert handoff._validate_subtask_id(good_id) is None + + +@pytest.mark.parametrize( + "sneaky_id", + [ + "st-1\n", # trailing newline — Python's $ matches before \n + "st-1\r\n", # CRLF + "st-1\r", # bare CR + "\nst-1", # leading newline + "st-1\nst-2", # embedded newline before another valid id + " st-1", # leading whitespace + "st-1 ", # trailing whitespace + "st-", # no digits + "", # empty + "st-1a", # trailing non-digit + "ST-1", # wrong case + "st-١", # Arabic-Indic digit U+0661 — \d accepts, [0-9] does not + "st-12", # full-width digits U+FF11 U+FF12 — same class + "st-1١", # mixed ASCII + Unicode digit + ], +) +def test_validator_rejects_sneaky_inputs(sneaky_id, capsys): + """Whitespace/newline/empty/case/Unicode-digit bypass attempts must be rejected. + + The trailing-newline case is the round-1 review finding: Python's ``$`` + matches *before* a final ``\\n``, so ``re.match(r"^st-\\d+$", "st-1\\n")`` + returns a match. ``re.fullmatch`` is the structural fix. + + The Unicode-digit cases are the round-2 review finding: ``\\d`` in Python + matches any Unicode decimal digit by default (e.g. Arabic-Indic + ``\\u0661`` or full-width ``\\uff11``), so ``st-\\d+`` would accept + ``"st-\\u0661"``. ``[0-9]+`` restricts to ASCII digits — the only shape + the Planner ever emits. + """ + assert ( + handoff._validate_subtask_id(sneaky_id) + == handoff.EXIT_INVALID_SUBTASK_ID + ) + err = capsys.readouterr().err + assert "REJECTED [invalid_subtask_id]" in err + assert repr(sneaky_id) in err + + +def _build_guard_args(subcommand: str, bad_id: str) -> list[str]: + """The minimum required-flags argv for each subcommand under guard test.""" + common = ["--project-dir", "."] + if subcommand == "start": + return ["start", bad_id, *common] + if subcommand == "verify": + return ["verify", bad_id, "--pr", "42", "--worktree", ".", *common] + if subcommand == "review": + return ["review", bad_id, "--pr", "42", "--approve", *common] + if subcommand == "verify-acceptance": + return ["verify-acceptance", bad_id, "--pr", "42", "--accept", *common] + if subcommand == "abandon": + return ["abandon", bad_id, *common] + if subcommand == "resume": + return ["resume", bad_id, *common] + raise AssertionError(f"unhandled subcommand: {subcommand}") + + +@pytest.mark.parametrize( + "subcommand", + ["start", "verify", "review", "verify-acceptance", "abandon", "resume"], +) +@pytest.mark.parametrize("bad_id", ["claim-time-guard", "subtask-1"]) +def test_guard_fires_before_store_in_every_subcommand( + subcommand, bad_id, monkeypatch, capsys, +): + """Guard runs BEFORE store / task-id resolution in all six legs. + + If the guard ever runs after ``_resolve_store`` / ``_resolve_task_id``, a + malformed id can already have opened the DB or read the active-room + pointer — both of which the guard exists to prevent. Monkeypatching them + to raise makes the ordering structural, not a comment. + """ + def _must_not_call(*args, **kwargs): + raise AssertionError( + "guard must reject before store / task-id resolution", + ) + + monkeypatch.setattr(handoff, "_resolve_store", _must_not_call) + monkeypatch.setattr(handoff, "_resolve_task_id", _must_not_call) + + argv = _build_guard_args(subcommand, bad_id) + assert handoff.main(argv) == handoff.EXIT_INVALID_SUBTASK_ID + err = capsys.readouterr().err + assert "REJECTED [invalid_subtask_id]" in err + assert repr(bad_id) in err + + +def test_guard_regression_valid_id_proceeds_through_start(tmp_path, monkeypatch): + """A valid ``st-N`` id is not blocked — the normal start path still works. + + Uses a real codeband.yaml + tmp_path-backed store via + ``CODEBAND_PROJECT_DIR`` (no resolver stubs) so this exercises the guard + in series with the real store — the one round-trip that proves the guard + does not over-reject the happy path. + """ + from codeband.config import ( + AgentsConfig, + CodebandConfig, + RepoConfig, + WorkspaceConfig, + ) + + project_dir = tmp_path / "project" + project_dir.mkdir() + workspace = tmp_path / "workspace" + cfg = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", branch="main"), + agents=AgentsConfig(), + workspace=WorkspaceConfig(path=str(workspace)), + ) + cfg.to_yaml(project_dir / "codeband.yaml") + (project_dir / ".codeband_room").write_text("room-1", encoding="utf-8") + store = StateStore(workspace / "state" / "orchestration.db") + store.create_task("room-1", "demo", "room-1") + + monkeypatch.setenv("CODEBAND_PROJECT_DIR", str(project_dir)) + + assert handoff.main(["start", "st-1", "--project-dir", str(project_dir)]) == 0 + assert store.get_subtask("st-1", "room-1").state == "in_progress" + + +# ── item-0: identity stamping at start (worker) and review (reviewer) ──────── + +def test_start_stamps_assigned_worker_with_coder_identity(monkeypatch, tmp_path): + """A coder running ``cb-phase start`` stamps its agent_id onto the subtask.""" + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(task_id="room-1", description="demo", room_id="room-1") + transition("st-1", "room-1", "assigned", caller_role="conductor", store=s) + # Distributed-mode env identity (env-first path of resolve_identity). + monkeypatch.setenv("CODEBAND_AGENT_ID", "agent-coder-1") + monkeypatch.setenv("CODEBAND_ROLE", "coder") + assert _start(s, monkeypatch, "st-1") == 0 + sub = s.get_subtask("st-1", "room-1") + assert sub.state == "in_progress" + # The watchdog reads assigned_worker as a mention id — now a real value. + assert sub.assigned_worker == "agent-coder-1" + + +def test_start_by_non_coder_role_does_not_stamp_worker(monkeypatch, tmp_path): + """The Conductor may also run start; its identity must NOT become the worker.""" + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(task_id="room-1", description="demo", room_id="room-1") + transition("st-1", "room-1", "assigned", caller_role="conductor", store=s) + monkeypatch.setenv("CODEBAND_AGENT_ID", "agent-conductor") + monkeypatch.setenv("CODEBAND_ROLE", "conductor") # allowed for start, but not a coder + assert _start(s, monkeypatch, "st-1") == 0 + sub = s.get_subtask("st-1", "room-1") + assert sub.state == "in_progress" + assert sub.assigned_worker is None # role guard left it unstamped + + +def test_review_stamps_assigned_reviewer_with_reviewer_identity(store, monkeypatch): + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + monkeypatch.setenv("CODEBAND_AGENT_ID", "agent-reviewer-2") + monkeypatch.setenv("CODEBAND_ROLE", "reviewer") + assert _review(monkeypatch, store, "--approve") == 0 + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_passed" + assert sub.assigned_reviewer == "agent-reviewer-2" + + +def test_review_reject_also_stamps_assigned_reviewer(store, monkeypatch): + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + monkeypatch.setenv("CODEBAND_AGENT_ID", "agent-reviewer-2") + monkeypatch.setenv("CODEBAND_ROLE", "reviewer") + assert _review(monkeypatch, store, "--reject") == 0 + assert store.get_subtask("st-1", "room-1").assigned_reviewer == "agent-reviewer-2" + + +def test_review_unresolvable_identity_records_verdict_without_stamp(store, monkeypatch): + """No env identity + no location map → assigned_reviewer NULL, but the FSM + verdict still records (the advisory stamp never blocks the transition).""" + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + monkeypatch.delenv("CODEBAND_AGENT_ID", raising=False) + monkeypatch.delenv("CODEBAND_ROLE", raising=False) + assert _review(monkeypatch, store, "--approve") == 0 + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_passed" # verdict recorded + assert sub.assigned_reviewer is None # nothing to stamp diff --git a/tests/test_idempotent_noop.py b/tests/test_idempotent_noop.py new file mode 100644 index 0000000..319d6be --- /dev/null +++ b/tests/test_idempotent_noop.py @@ -0,0 +1,395 @@ +"""Tests for the record-based no-op transition classification. + +A refused FSM transition is a NO-OP (exit 0) iff a passing record for the +requested to_state at the caller's exact head_sha already exists in +transition_log. Anything else (moved head, anomalous split, no head_sha) is +a loud Illegal (InvalidTransitionError / non-zero). +""" + +from __future__ import annotations + +import pytest + +from codeband.cli import handoff, merge +from codeband.state.fsm import ( + InvalidTransitionError, + NoOpTransitionError, + transition, +) +from codeband.state.store import StateStore + +TASK = "room-1" +SHA1 = "sha-aaa111" +SHA2 = "sha-bbb222" + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _make_store(tmp_path) -> StateStore: + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(task_id=TASK, description="demo", room_id=TASK) + return s + + +def _drive_to_review_passed(store, sha=SHA1): + for new_state, role, head in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", sha), + ("review_passed", "reviewer", sha), + ]: + transition("st-1", TASK, new_state, caller_role=role, store=store, head_sha=head) + + +def _drive_to_acceptance_passed(store, sha=SHA1): + _drive_to_review_passed(store, sha=sha) + transition("st-1", TASK, "acceptance_passed", caller_role="verifier", + store=store, head_sha=sha) + + +# ── FSM-level unit tests ────────────────────────────────────────────────────── + +class TestNoOpTransitionFSM: + """Direct FSM tests for NoOpTransitionError classification.""" + + def test_reviewer_approve_exact_dup_raises_noop(self, tmp_path): + """review --approve when state already review_passed, same head → NO-OP.""" + store = _make_store(tmp_path) + _drive_to_review_passed(store, sha=SHA1) + + with pytest.raises(NoOpTransitionError) as exc_info: + transition("st-1", TASK, "review_passed", caller_role="reviewer", + store=store, head_sha=SHA1) + + msg = str(exc_info.value) + assert "NO-OP" in msg + assert "already_review_passed" in msg + assert SHA1 in msg + assert "review_passed" in msg # state echoed + + def test_reviewer_approve_forward_past_raises_noop(self, tmp_path): + """review --approve when state already acceptance_passed → NO-OP. + + The review_passed record at SHA1 is in transition_log, so even though + the current state is acceptance_passed (past review_passed), the record + check finds the row and raises NoOpTransitionError. + """ + store = _make_store(tmp_path) + _drive_to_acceptance_passed(store, sha=SHA1) + + with pytest.raises(NoOpTransitionError) as exc_info: + transition("st-1", TASK, "review_passed", caller_role="reviewer", + store=store, head_sha=SHA1) + + msg = str(exc_info.value) + assert "NO-OP" in msg + assert "already_review_passed" in msg + + def test_verify_acceptance_exact_dup_raises_noop(self, tmp_path): + """verify-acceptance --pass when already acceptance_passed → NO-OP.""" + store = _make_store(tmp_path) + _drive_to_acceptance_passed(store, sha=SHA1) + + with pytest.raises(NoOpTransitionError) as exc_info: + transition("st-1", TASK, "acceptance_passed", caller_role="verifier", + store=store, head_sha=SHA1) + + msg = str(exc_info.value) + assert "NO-OP" in msg + assert "already_acceptance_passed" in msg + + def test_no_state_change_on_noop(self, tmp_path): + """NO-OP writes nothing — durable state is unchanged.""" + store = _make_store(tmp_path) + _drive_to_review_passed(store, sha=SHA1) + + with pytest.raises(NoOpTransitionError): + transition("st-1", TASK, "review_passed", caller_role="reviewer", + store=store, head_sha=SHA1) + + assert store.get_subtask("st-1", TASK).state == "review_passed" + + def test_moved_head_is_illegal_not_noop(self, tmp_path): + """review_passed@SHA1 recorded, attempt review_passed@SHA2 → Illegal.""" + store = _make_store(tmp_path) + _drive_to_review_passed(store, sha=SHA1) + + with pytest.raises(InvalidTransitionError) as exc_info: + transition("st-1", TASK, "review_passed", caller_role="reviewer", + store=store, head_sha=SHA2) + + assert not isinstance(exc_info.value, NoOpTransitionError) + assert "Illegal transition" in str(exc_info.value) + + def test_anomalous_split_is_illegal(self, tmp_path): + """review_passed@SHA1 + acceptance_passed@SHA2; re-attempt review_passed@SHA2 → Illegal. + + No review_passed record exists at SHA2, so there is no matching row and + the refusal escalates to loud Illegal. + """ + store = _make_store(tmp_path) + # Drive to review_passed@SHA1 + _drive_to_review_passed(store, sha=SHA1) + # Manually record acceptance_passed@SHA2 by driving there with SHA2 on the + # verifier leg (verifier can enter acceptance_passed from review_passed). + transition("st-1", TASK, "acceptance_passed", caller_role="verifier", + store=store, head_sha=SHA2) + + with pytest.raises(InvalidTransitionError) as exc_info: + transition("st-1", TASK, "review_passed", caller_role="reviewer", + store=store, head_sha=SHA2) + + assert not isinstance(exc_info.value, NoOpTransitionError) + assert "Illegal transition" in str(exc_info.value) + + def test_refused_transition_head_sha_none_is_illegal(self, tmp_path): + """Refused transition with head_sha=None → Illegal (no record check).""" + store = _make_store(tmp_path) + _drive_to_review_passed(store, sha=SHA1) + + with pytest.raises(InvalidTransitionError) as exc_info: + transition("st-1", TASK, "review_passed", caller_role="reviewer", + store=store, head_sha=None) + + assert not isinstance(exc_info.value, NoOpTransitionError) + assert "Illegal transition" in str(exc_info.value) + + +class TestIllegalRemainsIllegal: + """Branch states stay loud Illegal — not silently NO-OP.""" + + def test_review_from_blocked_is_illegal(self, tmp_path): + """forward verb when state is blocked → unchanged Illegal transition.""" + store = _make_store(tmp_path) + _drive_to_review_passed(store, sha=SHA1) + transition("st-1", TASK, "blocked", caller_role="watchdog", store=store) + + with pytest.raises(InvalidTransitionError) as exc_info: + transition("st-1", TASK, "review_passed", caller_role="reviewer", + store=store, head_sha=SHA1) + + assert "Illegal transition" in str(exc_info.value) + assert not isinstance(exc_info.value, NoOpTransitionError) + + def test_review_from_needs_rebase_is_illegal(self, tmp_path): + """forward verb when state is needs_rebase → unchanged Illegal transition.""" + store = _make_store(tmp_path) + _drive_to_review_passed(store, sha=SHA1) + transition("st-1", TASK, "needs_rebase", caller_role="mergemaster", store=store) + + with pytest.raises(InvalidTransitionError) as exc_info: + transition("st-1", TASK, "review_passed", caller_role="reviewer", + store=store, head_sha=SHA1) + + assert "Illegal transition" in str(exc_info.value) + assert not isinstance(exc_info.value, NoOpTransitionError) + + +# ── CLI-level integration tests ─────────────────────────────────────────────── + +def _make_review_store(tmp_path, sha=SHA1): + """Store with st-1 at review_passed, wired for review commands.""" + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(task_id=TASK, description="demo", room_id=TASK) + _drive_to_review_passed(s, sha=sha) + return s + + +def _patch_review_env(monkeypatch, store, pr_sha=SHA1): + monkeypatch.setattr(handoff, "_resolve_store", lambda p: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda p, s, t: (TASK, None), + ) + monkeypatch.setattr(handoff, "_pr_head_sha", lambda p, n: pr_sha) + + +class TestReviewCommandNoop: + """cb-phase review --approve exits 0 with NO-OP when already satisfied.""" + + def test_exact_dup_exits_zero(self, tmp_path, monkeypatch, capsys): + store = _make_review_store(tmp_path, sha=SHA1) + _patch_review_env(monkeypatch, store, pr_sha=SHA1) + + rc = handoff.main(["review", "st-1", "--pr", "42", "--approve"]) + assert rc == 0 + out = capsys.readouterr().out + assert "NO-OP" in out + assert "already_review_passed" in out + + def test_exact_dup_no_state_change(self, tmp_path, monkeypatch): + store = _make_review_store(tmp_path, sha=SHA1) + _patch_review_env(monkeypatch, store, pr_sha=SHA1) + + handoff.main(["review", "st-1", "--pr", "42", "--approve"]) + assert store.get_subtask("st-1", TASK).state == "review_passed" + + def test_forward_past_acceptance_exits_zero(self, tmp_path, monkeypatch, capsys): + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(task_id=TASK, description="demo", room_id=TASK) + _drive_to_acceptance_passed(store, sha=SHA1) + _patch_review_env(monkeypatch, store, pr_sha=SHA1) + + rc = handoff.main(["review", "st-1", "--pr", "42", "--approve"]) + assert rc == 0 + out = capsys.readouterr().out + assert "NO-OP" in out + + def test_moved_head_illegal_non_zero(self, tmp_path, monkeypatch, capsys): + """review --approve when head SHA has moved → non-zero Illegal (not STALE).""" + store = _make_review_store(tmp_path, sha=SHA1) + _patch_review_env(monkeypatch, store, pr_sha=SHA2) + + rc = handoff.main(["review", "st-1", "--pr", "42", "--approve"]) + assert rc != 0 + err = capsys.readouterr().err + assert "Illegal transition" in err + + def test_blocked_state_illegal_non_zero(self, tmp_path, monkeypatch, capsys): + """review --approve when state is blocked → non-zero Illegal.""" + store = _make_review_store(tmp_path, sha=SHA1) + transition("st-1", TASK, "blocked", caller_role="watchdog", store=store) + _patch_review_env(monkeypatch, store, pr_sha=SHA1) + + rc = handoff.main(["review", "st-1", "--pr", "42", "--approve"]) + assert rc != 0 + err = capsys.readouterr().err + assert "Illegal transition" in err + + def test_needs_rebase_state_illegal_non_zero(self, tmp_path, monkeypatch, capsys): + """review --approve when state is needs_rebase → non-zero Illegal.""" + store = _make_review_store(tmp_path, sha=SHA1) + transition("st-1", TASK, "needs_rebase", caller_role="mergemaster", store=store) + _patch_review_env(monkeypatch, store, pr_sha=SHA1) + + rc = handoff.main(["review", "st-1", "--pr", "42", "--approve"]) + assert rc != 0 + + +def _make_acceptance_store(tmp_path, sha=SHA1): + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(task_id=TASK, description="demo", room_id=TASK) + _drive_to_acceptance_passed(s, sha=sha) + return s + + +def _patch_verify_acceptance_env(monkeypatch, store, pr_sha=SHA1): + monkeypatch.setattr(handoff, "_resolve_store", lambda p: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda p, s, t: (TASK, None), + ) + monkeypatch.setattr(handoff, "_pr_head_sha", lambda p, n: pr_sha) + # Stub chain + claim checks so they always pass + from unittest.mock import MagicMock + chain_ok = MagicMock() + chain_ok.ok = True + monkeypatch.setattr(handoff, "_transition_chain_intact", lambda s: chain_ok) + + +class TestVerifyAcceptanceCommandNoop: + """cb-phase verify-acceptance --accept exits 0 with NO-OP when already passed.""" + + def test_exact_dup_exits_zero(self, tmp_path, monkeypatch, capsys): + store = _make_acceptance_store(tmp_path, sha=SHA1) + _patch_verify_acceptance_env(monkeypatch, store, pr_sha=SHA1) + + rc = handoff.main(["verify-acceptance", "st-1", "--pr", "42", "--accept"]) + assert rc == 0 + out = capsys.readouterr().out + assert "NO-OP" in out + assert "already_acceptance_passed" in out + + def test_exact_dup_no_state_change(self, tmp_path, monkeypatch): + store = _make_acceptance_store(tmp_path, sha=SHA1) + _patch_verify_acceptance_env(monkeypatch, store, pr_sha=SHA1) + + handoff.main(["verify-acceptance", "st-1", "--pr", "42", "--accept"]) + assert store.get_subtask("st-1", TASK).state == "acceptance_passed" + + def test_moved_head_illegal_non_zero(self, tmp_path, monkeypatch, capsys): + """verify-acceptance --accept when head SHA has moved → non-zero Illegal.""" + store = _make_acceptance_store(tmp_path, sha=SHA1) + _patch_verify_acceptance_env(monkeypatch, store, pr_sha=SHA2) + + rc = handoff.main(["verify-acceptance", "st-1", "--pr", "42", "--accept"]) + assert rc != 0 + err = capsys.readouterr().err + assert "Illegal transition" in err + + +# ── cb approve idempotency ──────────────────────────────────────────────────── + +class TestApproveIdempotency: + """cb approve run twice at same head → second is exit 0, NO-OP [already_granted].""" + + def _setup_approval_store(self, tmp_path): + """Store with st-1 at merge_pending with a pending approval request.""" + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(task_id=TASK, description="demo", room_id=TASK) + _drive_to_review_passed(store, sha=SHA1) + transition("st-1", TASK, "merge_pending", caller_role="mergemaster", + store=store, head_sha=SHA1) + store.set_pr_number("st-1", TASK, 42) + store.mark_merge_approval_requested("st-1", TASK, requested_sha=SHA1) + return store + + def test_second_approve_same_head_is_noop(self, tmp_path, monkeypatch, capsys): + from types import SimpleNamespace + + store = self._setup_approval_store(tmp_path) + # Record the first approval so merge_approved_sha == SHA1 + store.record_merge_approval("st-1", TASK, approved_by="owner", approved_sha=SHA1) + + monkeypatch.setattr(merge, "_resolve_store", lambda p: store) + monkeypatch.setattr( + merge, "_resolve_task_id", + lambda p, s, t: (TASK, None), + ) + monkeypatch.setattr( + merge, "load_config", + lambda p: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + agents=SimpleNamespace(max_rebase_rounds=3), + ), + ) + monkeypatch.setattr(merge, "_pr_snapshot", lambda pr_number, cwd, repo=None: { + "state": "OPEN", "headRefOid": SHA1, + }) + + result = merge.record_approval_grant(tmp_path, 42) + # NO-OP: nothing should be recorded (empty list), message printed to stdout + assert result == [] + out = capsys.readouterr().out + assert "NO-OP" in out + assert "already_granted" in out + assert SHA1 in out + + def test_first_approve_is_recorded(self, tmp_path, monkeypatch): + from types import SimpleNamespace + + store = self._setup_approval_store(tmp_path) + # No previous approval recorded + assert store.get_subtask("st-1", TASK).merge_approved_sha is None + + monkeypatch.setattr(merge, "_resolve_store", lambda p: store) + monkeypatch.setattr( + merge, "_resolve_task_id", + lambda p, s, t: (TASK, None), + ) + monkeypatch.setattr( + merge, "load_config", + lambda p: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + agents=SimpleNamespace(max_rebase_rounds=3), + ), + ) + monkeypatch.setattr(merge, "_pr_snapshot", lambda pr_number, cwd, repo=None: { + "state": "OPEN", "headRefOid": SHA1, + }) + + result = merge.record_approval_grant(tmp_path, 42) + assert len(result) == 1 + assert SHA1 in result[0] + assert store.get_subtask("st-1", TASK).merge_approved_sha == SHA1 diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..db7c977 --- /dev/null +++ b/tests/test_identity.py @@ -0,0 +1,208 @@ +"""Tests for durable agent-identity resolution (item-0, Leg 1). + +Covers the two resolution modes (distributed env-first vs local worktree map), +the env-hijack guard at the spawn seam, and every fail-safe-to-None edge of the +location map (missing / malformed / unknown cwd / ambiguous / path boundary). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from codeband.identity import ( + AGENT_ID_ENV, + LOCATION_MAP_FILENAME, + ROLE_ENV, + ResolvedIdentity, + resolve_identity, + write_location_map, +) + + +def _seat(dir_path: Path, worker_id: str, agent_id: str, role: str) -> dict: + return {"dir": str(dir_path), "worker_id": worker_id, "agent_id": agent_id, "role": role} + + +# ── distributed mode: env-first ───────────────────────────────────────────── + +def test_distributed_env_resolves_agent_id_and_role(monkeypatch, tmp_path): + monkeypatch.setenv(AGENT_ID_ENV, "agent-coder-7") + monkeypatch.setenv(ROLE_ENV, "coder") + resolved = resolve_identity(cwd=tmp_path, state_dir=tmp_path) + assert resolved == ResolvedIdentity(agent_id="agent-coder-7", role="coder") + + +def test_distributed_env_without_role_resolves_id_with_none_role(monkeypatch, tmp_path): + monkeypatch.setenv(AGENT_ID_ENV, "agent-x") + monkeypatch.delenv(ROLE_ENV, raising=False) + resolved = resolve_identity(cwd=tmp_path, state_dir=tmp_path) + assert resolved == ResolvedIdentity(agent_id="agent-x", role=None) + + +def test_env_wins_over_map(monkeypatch, tmp_path): + """When both env identity and a matching map entry exist, env wins.""" + wt = tmp_path / "worktrees" / "coder-claude_sdk-0" + wt.mkdir(parents=True) + write_location_map(tmp_path, [_seat(wt, "coder-claude_sdk-0", "map-agent", "coder")]) + monkeypatch.setenv(AGENT_ID_ENV, "env-agent") + monkeypatch.setenv(ROLE_ENV, "coder") + resolved = resolve_identity(cwd=wt, state_dir=tmp_path) + assert resolved is not None + assert resolved.agent_id == "env-agent" + + +# ── local mode: location map ──────────────────────────────────────────────── + +def test_local_map_resolves_coder_worktree(monkeypatch, tmp_path): + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + wt = tmp_path / "worktrees" / "coder-codex-1" + wt.mkdir(parents=True) + write_location_map(tmp_path, [_seat(wt, "coder-codex-1", "agent-codex-1", "coder")]) + resolved = resolve_identity(cwd=wt, state_dir=tmp_path) + assert resolved == ResolvedIdentity(agent_id="agent-codex-1", role="coder") + + +def test_local_map_resolves_from_subdirectory(monkeypatch, tmp_path): + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + wt = tmp_path / "worktrees" / "coder-claude_sdk-0" + sub = wt / "src" / "deep" + sub.mkdir(parents=True) + write_location_map(tmp_path, [_seat(wt, "coder-claude_sdk-0", "agent-0", "coder")]) + resolved = resolve_identity(cwd=sub, state_dir=tmp_path) + assert resolved is not None + assert resolved.agent_id == "agent-0" + + +def test_local_map_resolves_reviewer_scratch(monkeypatch, tmp_path): + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + scratch = tmp_path / "scratch" / "reviewer-codex-0" + scratch.mkdir(parents=True) + write_location_map(tmp_path, [_seat(scratch, "reviewer-codex-0", "agent-rev-0", "reviewer")]) + resolved = resolve_identity(cwd=scratch, state_dir=tmp_path) + assert resolved == ResolvedIdentity(agent_id="agent-rev-0", role="reviewer") + + +def test_local_map_picks_correct_seat_among_many(monkeypatch, tmp_path): + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + coder = tmp_path / "worktrees" / "coder-claude_sdk-0" + rev = tmp_path / "scratch" / "reviewer-codex-0" + coder.mkdir(parents=True) + rev.mkdir(parents=True) + write_location_map( + tmp_path, + [ + _seat(coder, "coder-claude_sdk-0", "agent-coder", "coder"), + _seat(rev, "reviewer-codex-0", "agent-rev", "reviewer"), + ], + ) + assert resolve_identity(cwd=coder, state_dir=tmp_path).agent_id == "agent-coder" + assert resolve_identity(cwd=rev, state_dir=tmp_path).agent_id == "agent-rev" + + +# ── local mode: fail-safe edges (all → None) ──────────────────────────────── + +def test_local_unknown_cwd_returns_none(monkeypatch, tmp_path): + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + wt = tmp_path / "worktrees" / "coder-claude_sdk-0" + wt.mkdir(parents=True) + write_location_map(tmp_path, [_seat(wt, "coder-claude_sdk-0", "agent-0", "coder")]) + elsewhere = tmp_path / "somewhere" / "else" + elsewhere.mkdir(parents=True) + assert resolve_identity(cwd=elsewhere, state_dir=tmp_path) is None + + +def test_local_missing_map_returns_none(monkeypatch, tmp_path): + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + assert resolve_identity(cwd=tmp_path, state_dir=tmp_path) is None + + +def test_local_malformed_map_returns_none(monkeypatch, tmp_path): + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + (tmp_path / LOCATION_MAP_FILENAME).write_text("{not valid json", encoding="utf-8") + assert resolve_identity(cwd=tmp_path, state_dir=tmp_path) is None + + +def test_local_path_boundary_no_false_match(monkeypatch, tmp_path): + """wt-1 must not match a cwd inside wt-10 (string-prefix would false-match).""" + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + wt1 = tmp_path / "worktrees" / "coder-claude_sdk-1" + wt10 = tmp_path / "worktrees" / "coder-claude_sdk-10" + wt1.mkdir(parents=True) + wt10.mkdir(parents=True) + write_location_map(tmp_path, [_seat(wt1, "coder-claude_sdk-1", "agent-1", "coder")]) + # cwd is inside wt-10, which is NOT in the map; wt-1 is a string prefix of + # wt-10's path but not a path-component ancestor → no match. + assert resolve_identity(cwd=wt10, state_dir=tmp_path) is None + + +def test_local_ambiguous_nested_seats_returns_none(monkeypatch, tmp_path): + """If cwd is within two seat dirs (pathologically nested), refuse to guess.""" + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + outer = tmp_path / "worktrees" / "coder-claude_sdk-0" + inner = outer / "nested" / "reviewer-codex-0" + inner.mkdir(parents=True) + write_location_map( + tmp_path, + [ + _seat(outer, "coder-claude_sdk-0", "agent-outer", "coder"), + _seat(inner, "reviewer-codex-0", "agent-inner", "reviewer"), + ], + ) + # cwd == inner is within BOTH outer and inner → ambiguous → None. + assert resolve_identity(cwd=inner, state_dir=tmp_path) is None + + +# ── write_location_map shape ──────────────────────────────────────────────── + +def test_write_location_map_resolves_dirs_absolute(tmp_path): + wt = tmp_path / "worktrees" / "coder-claude_sdk-0" + wt.mkdir(parents=True) + write_location_map(tmp_path, [_seat(wt, "coder-claude_sdk-0", "agent-0", "coder")]) + payload = json.loads((tmp_path / LOCATION_MAP_FILENAME).read_text()) + assert payload["version"] == 1 + entry = payload["entries"][0] + assert Path(entry["dir"]).is_absolute() + assert entry["agent_id"] == "agent-0" + assert entry["role"] == "coder" + + +def test_write_location_map_overwrites(tmp_path): + wt = tmp_path / "worktrees" / "coder-claude_sdk-0" + wt.mkdir(parents=True) + write_location_map(tmp_path, [_seat(wt, "coder-claude_sdk-0", "old-agent", "coder")]) + write_location_map(tmp_path, [_seat(wt, "coder-claude_sdk-0", "new-agent", "coder")]) + payload = json.loads((tmp_path / LOCATION_MAP_FILENAME).read_text()) + assert len(payload["entries"]) == 1 + assert payload["entries"][0]["agent_id"] == "new-agent" + + +# ── spawn-seam env behavior (env-hijack guard) ────────────────────────────── + +def test_export_seam_sets_agent_id_when_given(monkeypatch, tmp_path): + import os + + from codeband.orchestration.runner import _export_project_dir_env + + # Track every key the seam mutates so monkeypatch teardown restores them — + # the function writes os.environ directly (matches test_attribution.py). + monkeypatch.setenv("CODEBAND_PROJECT_DIR", "") + monkeypatch.setenv("CODEBAND_AGENT_SESSION", "") + monkeypatch.setenv("CODEBAND_ROLE", "") + monkeypatch.delenv(AGENT_ID_ENV, raising=False) + _export_project_dir_env(tmp_path, role="coder", agent_id="agent-coder-3") + assert os.environ[AGENT_ID_ENV] == "agent-coder-3" + + +def test_export_seam_clears_stale_agent_id_when_not_given(monkeypatch, tmp_path): + """Local-mode call (no agent_id) must clear an inherited value so it cannot + hijack local resolution — the env-hijack guard.""" + import os + + from codeband.orchestration.runner import _export_project_dir_env + + monkeypatch.setenv("CODEBAND_PROJECT_DIR", "") + monkeypatch.setenv("CODEBAND_AGENT_SESSION", "") + monkeypatch.setenv(AGENT_ID_ENV, "stale-from-prior-run") + _export_project_dir_env(tmp_path) + assert AGENT_ID_ENV not in os.environ diff --git a/tests/test_jam_delivery.py b/tests/test_jam_delivery.py new file mode 100644 index 0000000..1eb6df1 --- /dev/null +++ b/tests/test_jam_delivery.py @@ -0,0 +1,638 @@ +"""Tests for the opt-in jam delivery transport (``CODEBAND_DELIVERY=jam``). + +The guarantees defended here: +* the ``sdk`` default is unchanged and the jam code is dormant when off; +* the jam path hands the adapter the SAME ``AgentInput`` shape (via the real + ``DefaultPreprocessor``) — the brain is unchanged; +* the jam path reproduces the SDK ExecutionContext semantics that matter + (self-filter, retry budget, per-room serialization); +* the non-wedge property: a rejected ack stays cosmetic — other messages flow. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from codeband.transport.jam_control import AckOutcome, Target, agent_scope +from codeband.transport.jam_runtime import JamAgent, _RoomWorker + +# --- doubles --------------------------------------------------------------- + + +class FakeAdapter: + def __init__(self, on_event=None): + self.events: list = [] + self.started = None + self._thenvoi_agent_id = None + self._on_event_hook = on_event + + async def on_started(self, name, description): + self.started = (name, description) + + async def on_event(self, inp): + self.events.append(inp) + if self._on_event_hook is not None: + await self._on_event_hook(inp) + + +class FakeControl: + def __init__(self, batches=None, ack_ok=True): + self._batches = list(batches or []) + self.ack_ok = ack_ok + self.ack_outcomes: dict[str, bool] = {} + self.acked: list[str] = [] + self.adopts: list = [] + self.closed = False + self.sent: list = [] + + async def ping(self): + return True + + async def adopt(self, opts, agent_key): + self.adopts.append((opts, agent_key)) + + async def inbox(self, target): + return self._batches.pop(0) if self._batches else [] + + async def ack(self, target, msg_id): + ok = self.ack_outcomes.get(msg_id, self.ack_ok) + self.acked.append(msg_id) + return AckOutcome(ok=ok, error=None if ok else "simulated") + + async def send(self, *a, **k): + self.sent.append((a, k)) + return {"result": {"message_id": "x", "warnings": []}} + + async def close(self): + self.closed = True + + +def _config(*, delivery="jam", max_retries=3, idle=1): + return SimpleNamespace( + agents=SimpleNamespace( + idle_resync_seconds=idle, + max_message_retries=max_retries, + delivery=delivery, + ), + band=SimpleNamespace(ws_url="ws://test", rest_url="http://test"), + ) + + +def _creds(agent_id="agent-A"): + return SimpleNamespace(agent_id=agent_id, api_key="band_a_testkey") + + +def _wire_msg( + message_id, + chat_id="room-1", + content="hi", + sender_id="user-1", + sender_type="User", + sender_name="User One", + enqueued_at=1_718_649_600_000, +): + return { + "chat_id": chat_id, + "message_id": message_id, + "sender_id": sender_id, + "sender_name": sender_name, + "sender_handle": "owner/u1", + "sender_type": sender_type, + "content": content, + "enqueued_at": enqueued_at, + "addressed_to_user": False, + } + + +def _make_agent(adapter=None, control=None, *, agent_id="agent-A", max_retries=3): + """A JamAgent wired with fakes + a real DefaultPreprocessor, no network.""" + from thenvoi.preprocessing.default import DefaultPreprocessor + from thenvoi.runtime.types import SessionConfig + + adapter = adapter or FakeAdapter() + control = control or FakeControl() + agent = JamAgent( + adapter, + _creds(agent_id), + _config(max_retries=max_retries), + control=control, + link=SimpleNamespace(rest=SimpleNamespace()), + preprocessor=DefaultPreprocessor(), + ) + # Hydration off so the real preprocessor never reaches the network. + agent._session_config = SessionConfig( + enable_context_hydration=False, + max_message_retries=max_retries, + idle_resync_seconds=1, + ) + return agent, adapter, control + + +def _make_worker(agent, room_id="room-1"): + from thenvoi.runtime.execution import ExecutionContext + from thenvoi.runtime.retry_tracker import MessageRetryTracker + + worker = _RoomWorker(room_id, agent) + worker._ctx = ExecutionContext( + room_id, agent._link, _noop, config=agent._session_config, agent_id=agent._agent_id + ) + worker._retry = MessageRetryTracker( + max_retries=agent._config.agents.max_message_retries, room_id=room_id + ) + return worker + + +async def _noop(ctx, event): + return None + + +@pytest.fixture(autouse=True) +def _no_network_hydration(monkeypatch): + """ExecutionContext hydration is REST-backed; stub it out for unit tests.""" + from unittest.mock import AsyncMock + + from thenvoi.runtime.execution import ExecutionContext + + monkeypatch.setattr(ExecutionContext, "_ensure_fresh_context", AsyncMock()) + + +# --- flag resolution & dormancy ------------------------------------------- + + +def test_resolve_delivery_default_sdk(monkeypatch): + import codeband.orchestration.runner as r + + monkeypatch.delenv("CODEBAND_DELIVERY", raising=False) + assert r._resolve_delivery_mode(_config(delivery="sdk")) == "sdk" + + +def test_resolve_delivery_jam_via_config(monkeypatch): + import codeband.orchestration.runner as r + + monkeypatch.delenv("CODEBAND_DELIVERY", raising=False) + assert r._resolve_delivery_mode(_config(delivery="jam")) == "jam" + + +def test_resolve_delivery_env_overrides_and_unknown_is_sdk(monkeypatch): + import codeband.orchestration.runner as r + + monkeypatch.setenv("CODEBAND_DELIVERY", "jam") + assert r._resolve_delivery_mode(_config(delivery="sdk")) == "jam" + monkeypatch.setenv("CODEBAND_DELIVERY", "nonsense") + assert r._resolve_delivery_mode(_config(delivery="jam")) == "sdk" + + +def test_sdk_mode_does_not_touch_jam(monkeypatch): + """sdk mode returns the SDK agent and never constructs a JamAgent.""" + import codeband.orchestration.runner as r + import codeband.transport.jam_runtime as jr + + monkeypatch.delenv("CODEBAND_DELIVERY", raising=False) + monkeypatch.setattr(r, "_create_band_agent", lambda a, c, cfg: "SDK_AGENT") + + def _boom(*a, **k): + raise AssertionError("JamAgent must not be constructed on the sdk path") + + monkeypatch.setattr(jr, "JamAgent", _boom) + out = r._create_delivery_agent(object(), _creds(), _config(delivery="sdk")) + assert out == "SDK_AGENT" + + +def test_jam_mode_builds_jam_agent(monkeypatch): + import codeband.orchestration.runner as r + + monkeypatch.setenv("CODEBAND_DELIVERY", "jam") + out = r._create_delivery_agent(FakeAdapter(), _creds(), _config(delivery="jam")) + assert isinstance(out, JamAgent) + + +async def test_jam_preflight_raises_when_daemon_unreachable(monkeypatch, tmp_path): + # Async on purpose: run_local/run_agent await this from inside a RUNNING + # event loop, so the preflight must not call asyncio.run() (round-1 high). + import codeband.orchestration.runner as r + + # Point at a config dir with no jam.sock → ping fails → fail fast. + monkeypatch.setenv("JAM_CONFIG_DIR", str(tmp_path)) + with pytest.raises(SystemExit): + await r._jam_delivery_preflight(_config(delivery="jam")) + + +async def test_run_closes_control_on_transport_fatal(monkeypatch): + """run() must close the UDS client on a clean (transport-fatal) return.""" + + class BoomControl(FakeControl): + async def inbox(self, target): + raise RuntimeError("jamd down") + + control = BoomControl() + agent, adapter, _ = _make_agent(control=control) + agent._poll_interval = 0 # no backoff sleep in the test + monkeypatch.setattr("codeband.transport.jam_runtime._MAX_INBOX_FAILURES", 2) + + await asyncio.wait_for(agent.run(), timeout=5) + + assert control.closed is True # httpx UDS client not leaked + assert adapter.started # start()'s adapter handshake ran + + +# --- receive shape / brain parity (round-1 blocker #1) -------------------- + + +async def test_jam_receive_delivers_expected_shape(): + agent, adapter, control = _make_agent() + worker = _make_worker(agent) + await worker._process(_wire_msg("m1", content="hello world")) + + assert len(adapter.events) == 1 + inp = adapter.events[0] + assert inp.msg.id == "m1" + assert inp.room_id == "room-1" + assert inp.msg.content == "hello world" + assert inp.msg.sender_id == "user-1" + assert inp.msg.sender_type == "User" + assert inp.msg.message_type == "text" + assert control.acked == ["m1"] # acked on success + + +async def test_payload_is_message_created_payload_not_platform_message(): + """Round-1 blocker #1: the preprocessor reads payload.inserted_at.""" + from thenvoi.preprocessing.default import DefaultPreprocessor + from thenvoi.platform.event import MessageEvent + from thenvoi.runtime.execution import ExecutionContext + from thenvoi.runtime.types import PlatformMessage + + agent, _adapter, _control = _make_agent() + + # The jam-built event uses a MessageCreatedPayload → preprocessor accepts it. + event = agent._build_event("room-1", _wire_msg("m1")) + assert event.payload.inserted_at # MessageCreatedPayload has inserted_at + ctx = ExecutionContext( + "room-1", agent._link, _noop, config=agent._session_config, agent_id="agent-A" + ) + inp = await DefaultPreprocessor().process(ctx=ctx, event=event, agent_id="agent-A") + assert inp is not None and inp.msg.id == "m1" + + # Regression guard: a PlatformMessage (created_at, no inserted_at) as payload + # would crash — proving the test would catch the wrong-shape bug. + import datetime + + bad = MessageEvent( + room_id="room-1", + payload=PlatformMessage( + id="m1", + room_id="room-1", + content="x", + sender_id="u", + sender_type="User", + sender_name=None, + message_type="text", + metadata={}, + created_at=datetime.datetime.now(datetime.timezone.utc), + ), + ) + with pytest.raises(AttributeError): + await DefaultPreprocessor().process(ctx=ctx, event=bad, agent_id="agent-A") + + +# --- self-message filter --------------------------------------------------- + + +async def test_self_message_filtered_and_drained(): + agent, adapter, control = _make_agent(agent_id="agent-A") + worker = _make_worker(agent) + # A message from the agent itself. + await worker._process(_wire_msg("self1", sender_id="agent-A", sender_type="Agent")) + + assert adapter.events == [] # handler NOT invoked + assert control.acked == ["self1"] # drained from the queue + + +# --- NON-WEDGE: a rejected ack stays cosmetic, other messages flow -------- + + +async def test_failed_ack_is_cosmetic_other_messages_flow(): + control = FakeControl(ack_ok=True) + control.ack_outcomes["X"] = False # X's ack is rejected (simulated 422) + agent, adapter, _ = _make_agent(control=control) + worker = _make_worker(agent) + + await worker._process(_wire_msg("X", content="first")) + await worker._process(_wire_msg("Y", content="second")) + + # Both delivered to the handler — X's failed ack did NOT block Y. + assert [e.msg.id for e in adapter.events] == ["X", "Y"] + assert control.acked == ["X", "Y"] # both ack-attempted; X rejected, no raise + + +async def test_no_cross_room_head_of_line(monkeypatch): + """Round-1 high #3: a slow handler in room A must not delay room B.""" + gate = asyncio.Event() + delivered: list[str] = [] + + async def on_event(inp): + delivered.append(inp.room_id) + if inp.room_id == "room-A": + await gate.wait() # block room A indefinitely + + adapter = FakeAdapter(on_event=on_event) + agent, _adapter, control = _make_agent(adapter=adapter) + control.ack_ok = True + + # Route a message to room A (will block) and one to room B (should flow). + agent._route(_wire_msg("a1", chat_id="room-A")) + agent._route(_wire_msg("b1", chat_id="room-B")) + + # B must be handled even while A is stuck. + async def _wait_b(): + while "room-B" not in delivered: + await asyncio.sleep(0.01) + + await asyncio.wait_for(_wait_b(), timeout=2.0) + assert "room-B" in delivered + gate.set() + await agent.stop() + + +# --- retry budget + at-least-once (round-1 high #2) ----------------------- + + +async def test_handler_failure_retries_then_drains(): + calls = {"n": 0} + + async def on_event(inp): + calls["n"] += 1 + raise RuntimeError("boom") + + adapter = FakeAdapter(on_event=on_event) + agent, _adapter, control = _make_agent(adapter=adapter, max_retries=2) + worker = _make_worker(agent) + + # max_retries=2 → attempts 1 and 2 run the handler (and raise, no ack); + # attempt 3 exceeds the budget → drained (acked) without invoking handler. + for _ in range(2): + with pytest.raises(RuntimeError): + await worker._process(_wire_msg("poison")) + assert control.acked == [] # never acked while failing → jam redelivers + + await worker._process(_wire_msg("poison")) # 3rd: exceeded → drain + assert calls["n"] == 2 # handler ran exactly max_retries times + assert control.acked == ["poison"] # drained to stop infinite redelivery + + +# --- dedupe ---------------------------------------------------------------- + + +async def test_route_dedupes_inflight_and_handled(): + agent, adapter, control = _make_agent() + msg = _wire_msg("dup") + + agent._route(msg) # enqueued; now in-flight + agent._route(msg) # duplicate while in-flight → skipped + # let the worker process it + await asyncio.sleep(0.05) + agent._route(msg) # now handled → skipped + await asyncio.sleep(0.05) + + assert [e.msg.id for e in adapter.events] == ["dup"] # delivered once + await agent.stop() + + +# --- lifecycle ------------------------------------------------------------- + + +async def test_stop_tears_down_workers_and_closes_control(): + agent, _adapter, control = _make_agent() + agent._route(_wire_msg("m1")) + await asyncio.sleep(0.02) + assert agent._workers # a worker exists + await agent.stop() + assert control.closed is True + + +def test_agent_scope_and_target(): + assert agent_scope("abc") == "codeband-abc" + t = Target(scope="codeband-abc") + assert t.as_dict() == {"profile": "default", "scope": "codeband-abc", "handle": ""} + + +# --- doctor tripwire for the SDK-internals coupling ------------------------ + + +def _doctor_ctx(tmp_path): + from codeband.doctor import Context + + return Context(project_dir=tmp_path) + + +def test_doctor_jam_coupling_ok_on_current_sdk(tmp_path, monkeypatch): + from codeband.doctor import Status, check_jam_delivery_sdk_coupling + + monkeypatch.delenv("CODEBAND_DELIVERY", raising=False) + res = check_jam_delivery_sdk_coupling(_doctor_ctx(tmp_path)) + assert res.status is Status.OK + + +def test_doctor_jam_coupling_warn_when_symbol_moved_sdk_mode(tmp_path, monkeypatch): + """A moved SDK symbol → WARN on the default sdk path (exit code unaffected).""" + import importlib + + from codeband.doctor import Status, check_jam_delivery_sdk_coupling + + monkeypatch.delenv("CODEBAND_DELIVERY", raising=False) + real = importlib.import_module + + def _fake(name, *a, **k): + if name == "thenvoi.runtime.retry_tracker": + raise ImportError("simulated rename") + return real(name, *a, **k) + + monkeypatch.setattr(importlib, "import_module", _fake) + res = check_jam_delivery_sdk_coupling(_doctor_ctx(tmp_path)) + assert res.status is Status.WARN + assert "retry_tracker" in res.message + + +def test_doctor_jam_coupling_fails_when_jam_selected(tmp_path, monkeypatch): + """Same moved symbol, but CODEBAND_DELIVERY=jam → FAIL (path in use is broken).""" + import importlib + + from codeband.doctor import Status, check_jam_delivery_sdk_coupling + + monkeypatch.setenv("CODEBAND_DELIVERY", "jam") + real = importlib.import_module + + def _fake(name, *a, **k): + if name == "thenvoi.runtime.retry_tracker": + raise ImportError("simulated rename") + return real(name, *a, **k) + + monkeypatch.setattr(importlib, "import_module", _fake) + res = check_jam_delivery_sdk_coupling(_doctor_ctx(tmp_path)) + assert res.status is Status.FAIL + assert res.remediation and "jam_runtime.py" in res.remediation + + +# --- durable dedupe ----------------------------------------------------------- + + +def _make_store(tmp_path): + from codeband.state.store import StateStore + + return StateStore(tmp_path / "test_dedupe.db") + + +def _make_agent_with_store(store, *, agent_id="agent-A", max_retries=3): + agent, adapter, control = _make_agent(agent_id=agent_id, max_retries=max_retries) + agent._store = store + return agent, adapter, control + + +async def test_durable_dedupe_write_after_handler_success(tmp_path): + """Successful handler writes the durable record before the ack.""" + store = _make_store(tmp_path) + agent, adapter, control = _make_agent_with_store(store) + worker = _make_worker(agent) + + scope = agent._target.scope + await worker._process(_wire_msg("m1")) + + assert len(adapter.events) == 1 + assert control.acked == ["m1"] + assert store.is_jam_message_handled(scope, "m1") is True + + +async def test_durable_dedupe_restart_skips_redelivery(tmp_path): + """A fresh JamAgent with empty _handled skips a message already durable-handled.""" + store = _make_store(tmp_path) + + # First agent: process and (fail) the ack so the message stays queued. + control_a = FakeControl(ack_ok=False) # ack rejected + agent_a, adapter_a, _ = _make_agent_with_store(store, agent_id="agent-A") + agent_a._control = control_a + worker_a = _make_worker(agent_a) + await worker_a._process(_wire_msg("m-restart")) + + assert len(adapter_a.events) == 1 # handler ran + scope = agent_a._target.scope + assert store.is_jam_message_handled(scope, "m-restart") is True # durable record written + + # Second agent: same scope (same agent_id → same scope), fresh in-memory state. + agent_b, adapter_b, control_b = _make_agent_with_store(store, agent_id="agent-A") + assert "m-restart" not in agent_b._handled # empty in-memory set + worker_b = _make_worker(agent_b) + await worker_b._process(_wire_msg("m-restart")) + + assert adapter_b.events == [] # handler NOT called on second agent + assert control_b.acked == ["m-restart"] # ack-to-drain called + + +async def test_durable_dedupe_no_false_skip_on_handler_failure(tmp_path): + """A handler that raises must NOT write the durable record (no false-skip).""" + store = _make_store(tmp_path) + + async def on_event_boom(inp): + raise RuntimeError("handler failed") + + adapter = FakeAdapter(on_event=on_event_boom) + agent, _, _ = _make_agent_with_store(store, agent_id="agent-A") + agent._adapter = adapter + worker = _make_worker(agent) + + with pytest.raises(RuntimeError): + await worker._process(_wire_msg("m-fail")) + + scope = agent._target.scope + assert store.is_jam_message_handled(scope, "m-fail") is False # no false-skip + + +async def test_durable_dedupe_migration_idempotent(tmp_path): + """Opening the same StateStore twice creates the table exactly once (idempotent).""" + from codeband.state.store import StateStore + + store1 = StateStore(tmp_path / "idem.db") + store2 = StateStore(tmp_path / "idem.db") # second open, same file + store1.mark_jam_message_handled("scope-A", "msg-1") + assert store2.is_jam_message_handled("scope-A", "msg-1") is True + + # Idempotent: writing the same record again is a no-op (INSERT OR IGNORE). + store1.mark_jam_message_handled("scope-A", "msg-1") + assert store2.is_jam_message_handled("scope-A", "msg-1") is True + + +async def test_durable_check_read_failure_fails_open(tmp_path, monkeypatch): + """A DB read failure in the durable-check falls open: handler still runs.""" + store = _make_store(tmp_path) + + from codeband.state.store import StateStore + + def _boom(*a, **k): + raise Exception("simulated DB read error") + + monkeypatch.setattr(StateStore, "is_jam_message_handled", _boom) + agent, adapter, control = _make_agent_with_store(store) + worker = _make_worker(agent) + + await worker._process(_wire_msg("m-read-fail")) + + assert len(adapter.events) == 1 # handler ran (fail-open) + assert control.acked == ["m-read-fail"] + + +async def test_durable_dedupe_scope_isolation(tmp_path): + """Two agents with different scopes (different agent_ids) never false-skip each other.""" + store = _make_store(tmp_path) + + agent_x, adapter_x, _ = _make_agent_with_store(store, agent_id="agent-X") + agent_y, adapter_y, _ = _make_agent_with_store(store, agent_id="agent-Y") + + # Agent-X handles "shared-msg". + worker_x = _make_worker(agent_x) + await worker_x._process(_wire_msg("shared-msg")) + assert store.is_jam_message_handled(agent_x._target.scope, "shared-msg") is True + + # Agent-Y has a different scope — should NOT be skipped. + worker_y = _make_worker(agent_y) + await worker_y._process(_wire_msg("shared-msg")) + assert len(adapter_y.events) == 1 # handler ran on agent-Y too + + +async def test_durable_dedupe_production_lazy_build(tmp_path): + """Production lazy-build: a non-injected JamAgent constructs a usable StateStore.""" + # Build a config with a real resolved workspace path pointing to tmp_path. + workspace_path = tmp_path / ".codeband" + workspace_path.mkdir() + config = SimpleNamespace( + agents=SimpleNamespace(idle_resync_seconds=1, max_message_retries=3, delivery="jam"), + band=SimpleNamespace(ws_url="ws://test", rest_url="http://test"), + workspace=SimpleNamespace(path=str(workspace_path)), + ) + from thenvoi.preprocessing.default import DefaultPreprocessor + from thenvoi.runtime.types import SessionConfig + + adapter = FakeAdapter() + control = FakeControl() + # No store= injected — triggers the lazy-build path in start(). + agent = JamAgent( + adapter, + _creds("agent-lazy"), + config, + control=control, + link=SimpleNamespace(rest=SimpleNamespace()), + preprocessor=DefaultPreprocessor(), + ) + agent._session_config = SessionConfig( + enable_context_hydration=False, + max_message_retries=3, + idle_resync_seconds=1, + ) + # start() builds the store lazily. + await agent.start() + + assert agent._store is not None + scope = agent._target.scope + # Write and read round-trip. + agent._store.mark_jam_message_handled(scope, "lazy-msg") + assert agent._store.is_jam_message_handled(scope, "lazy-msg") is True diff --git a/tests/test_kickoff.py b/tests/test_kickoff.py index 59ae83c..4dc909b 100644 --- a/tests/test_kickoff.py +++ b/tests/test_kickoff.py @@ -342,12 +342,19 @@ async def test_human_creates_room_and_sends_message( """send_task uses human API to create room and send task message.""" from codeband.orchestration import kickoff + # Registration resolves the default verdict list (includes 'verify'), + # so the config must carry an executable verify command. + sample_config.agents.handoff_verify_command = "true" sample_agent_config.to_yaml(tmp_path / "agent_config.yaml") human_client = AsyncMock() human_client.human_api_chats.create_my_chat_room.return_value = FakeRoomResponse( data=FakeRoom(id="room-123") ) + # Owner resolution is required: send_task aborts without a profile id. + human_client.human_api_profile.get_my_profile.return_value = FakeIdentityResponse( + data=FakeIdentity(id="owner-1", name="Initiator") + ) human_client.human_api_participants.add_my_chat_participant = AsyncMock() human_client.human_api_messages.send_my_chat_message = AsyncMock() human_client.human_api_chats.list_my_chats.return_value = FakeListResponse(data=[]) @@ -400,12 +407,19 @@ async def test_task_message_includes_repo_info( """Task message includes repo URL and branch for specificity.""" from codeband.orchestration import kickoff + # Registration resolves the default verdict list (includes 'verify'), + # so the config must carry an executable verify command. + sample_config.agents.handoff_verify_command = "true" sample_agent_config.to_yaml(tmp_path / "agent_config.yaml") human_client = AsyncMock() human_client.human_api_chats.create_my_chat_room.return_value = FakeRoomResponse( data=FakeRoom(id="room-456") ) + # Owner resolution is required: send_task aborts without a profile id. + human_client.human_api_profile.get_my_profile.return_value = FakeIdentityResponse( + data=FakeIdentity(id="owner-1", name="Initiator") + ) human_client.human_api_participants.add_my_chat_participant = AsyncMock() human_client.human_api_messages.send_my_chat_message = AsyncMock() human_client.human_api_chats.list_my_chats.return_value = FakeListResponse(data=[]) @@ -454,6 +468,45 @@ async def test_empty_room_file_deletes_and_noops(self, sample_config, tmp_path): assert result is None assert not (tmp_path / ".codeband_room").exists() + @pytest.mark.asyncio + async def test_reset_preserves_state_store_without_wipe_flag(self, sample_config, tmp_path): + """Plain reset cleans pointers only; current workspace task rows stay intact.""" + from codeband.orchestration.kickoff import reset_active_room + from codeband.state import StateStore + + db_path = tmp_path / "workspace" / "state" / "orchestration.db" + store = StateStore(db_path) + store.create_task("current-room", "current", "current-room") + + result = await reset_active_room(sample_config, tmp_path) + + assert result is None + assert store.list_active_task_room_ids() == ["current-room"] + + @pytest.mark.asyncio + async def test_reset_wipe_flag_clears_state_store_room_records( + self, sample_config, tmp_path, + ): + """The /codeband re-point wipe retires stale room records before startup.""" + from codeband.orchestration.kickoff import reset_active_room + from codeband.orchestration.runner import _read_active_room_ids + from codeband.state import StateStore + + db_path = tmp_path / "workspace" / "state" / "orchestration.db" + store = StateStore(db_path) + store.create_task("old-room-1", "old one", "old-room-1") + store.create_task("old-room-2", "old two", "old-room-2") + store.ensure_subtask("st-1", "old-room-1", state="in_progress") + + result = await reset_active_room( + sample_config, tmp_path, clear_state_rooms=True, + ) + + assert result is None + assert store.list_active_task_room_ids() == [] + assert store.list_active_subtasks() == [] + assert _read_active_room_ids(db_path) == set() + @pytest.mark.asyncio async def test_removes_all_agents_and_deletes_pointer( self, sample_config, sample_agent_config, tmp_path, @@ -488,6 +541,38 @@ async def test_removes_all_agents_and_deletes_pointer( "stale-room-id", creds.agent_id, ) + @pytest.mark.asyncio + async def test_reset_clears_both_pointer_locations( + self, sample_config, sample_agent_config, tmp_path, + ): + """Transition-era state: pointer present at BOTH the canonical + (workspace/state/) and legacy (project-dir) locations — reset must + clear both so neither can resurrect a dead room.""" + from codeband.orchestration import kickoff + + sample_agent_config.to_yaml(tmp_path / "agent_config.yaml") + canonical = tmp_path / "workspace" / "state" / ".codeband_room" + canonical.parent.mkdir(parents=True, exist_ok=True) + canonical.write_text("stale-room-id", encoding="utf-8") + (tmp_path / ".codeband_room").write_text("stale-room-id", encoding="utf-8") + + factory = _make_clients(AsyncMock(), { + creds.api_key: (creds.agent_id, key) + for key, creds in sample_agent_config.agents.items() + }) + + import thenvoi_rest + original = thenvoi_rest.AsyncRestClient + thenvoi_rest.AsyncRestClient = factory + try: + result = await kickoff.reset_active_room(sample_config, tmp_path) + finally: + thenvoi_rest.AsyncRestClient = original + + assert result == "stale-room-id" + assert not canonical.exists() + assert not (tmp_path / ".codeband_room").exists() + @pytest.mark.asyncio async def test_agent_removal_errors_are_swallowed( self, sample_config, sample_agent_config, tmp_path, @@ -522,3 +607,123 @@ async def test_agent_removal_errors_are_swallowed( # Mergemaster removal still happened despite conductor failure mm_client = factory("key-mm") mm_client.agent_api_participants.remove_agent_chat_participant.assert_called_once() + + +class TestSendRoomMessage: + """Tests for send_room_message — env-gated session agent identity.""" + + def _setup_room(self, tmp_path, sample_agent_config): + """Write room pointer (legacy path) and agent_config.yaml.""" + (tmp_path / ".codeband_room").write_text("room-abc", encoding="utf-8") + sample_agent_config.to_yaml(tmp_path / "agent_config.yaml") + + def _make_factory(self, human_client, session_client=None): + """Return an AsyncRestClient factory callable. + + human_client: returned when api_key == "human-key" + session_client: returned when api_key == "session-key" (optional) + Conductor (api_key == "key-cond") gets a fresh AsyncMock with identity wired. + """ + conductor_client = AsyncMock() + conductor_client.agent_api_identity.get_agent_me.return_value = FakeIdentityResponse( + data=FakeIdentity(id="cond-0", name="Conductor") + ) + _session = session_client # avoid shadowing in closure + + def factory(api_key, base_url=None): + if api_key == "human-key": + return human_client + if api_key == "session-key" and _session is not None: + return _session + if api_key == "key-cond": + return conductor_client + raise ValueError(f"unexpected api_key in factory: {api_key!r}") + + return factory + + @pytest.mark.asyncio + async def test_env_absent_uses_human_path( + self, sample_config, sample_agent_config, tmp_path, monkeypatch + ): + """With CODEBAND_SESSION_AGENT_KEY unset, posts via human_api_messages (regression guard).""" + from codeband.orchestration import kickoff + + self._setup_room(tmp_path, sample_agent_config) + + human_client = AsyncMock() + factory = self._make_factory(human_client) + + monkeypatch.delenv("CODEBAND_SESSION_AGENT_KEY", raising=False) + monkeypatch.setenv("BAND_API_KEY", "human-key") + + import thenvoi_rest + original = thenvoi_rest.AsyncRestClient + thenvoi_rest.AsyncRestClient = factory + try: + await kickoff.send_room_message(sample_config, tmp_path, "APPROVED") + finally: + thenvoi_rest.AsyncRestClient = original + + human_client.human_api_messages.send_my_chat_message.assert_called_once() + call_kwargs = human_client.human_api_messages.send_my_chat_message.call_args + assert call_kwargs[0][0] == "room-abc" + msg = call_kwargs[1]["message"] + assert "APPROVED" in msg.content + assert "Conductor" in msg.content + + @pytest.mark.asyncio + async def test_env_set_uses_agent_path( + self, sample_config, sample_agent_config, tmp_path, monkeypatch + ): + """With CODEBAND_SESSION_AGENT_KEY set, posts via agent_api_messages (session identity).""" + from codeband.orchestration import kickoff + + self._setup_room(tmp_path, sample_agent_config) + + session_client = AsyncMock() + human_client = AsyncMock() + factory = self._make_factory(human_client, session_client=session_client) + + monkeypatch.setenv("CODEBAND_SESSION_AGENT_KEY", "session-key") + monkeypatch.setenv("BAND_API_KEY", "human-key") + + import thenvoi_rest + original = thenvoi_rest.AsyncRestClient + thenvoi_rest.AsyncRestClient = factory + try: + await kickoff.send_room_message(sample_config, tmp_path, "APPROVED") + finally: + thenvoi_rest.AsyncRestClient = original + + session_client.agent_api_messages.create_agent_chat_message.assert_called_once() + human_client.human_api_messages.send_my_chat_message.assert_not_called() + call_kwargs = session_client.agent_api_messages.create_agent_chat_message.call_args + assert call_kwargs[0][0] == "room-abc" + msg = call_kwargs[1]["message"] + assert "APPROVED" in msg.content + assert "Conductor" in msg.content + + @pytest.mark.asyncio + async def test_env_empty_string_uses_human_path( + self, sample_config, sample_agent_config, tmp_path, monkeypatch + ): + """CODEBAND_SESSION_AGENT_KEY='' (empty string) is treated as absent — human path.""" + from codeband.orchestration import kickoff + + self._setup_room(tmp_path, sample_agent_config) + + human_client = AsyncMock() + factory = self._make_factory(human_client) + + monkeypatch.setenv("CODEBAND_SESSION_AGENT_KEY", "") + monkeypatch.setenv("BAND_API_KEY", "human-key") + + import thenvoi_rest + original = thenvoi_rest.AsyncRestClient + thenvoi_rest.AsyncRestClient = factory + try: + await kickoff.send_room_message(sample_config, tmp_path, "REJECTED") + finally: + thenvoi_rest.AsyncRestClient = original + + human_client.human_api_messages.send_my_chat_message.assert_called_once() diff --git a/tests/test_local_memory.py b/tests/test_local_memory.py index c9cbe69..2abf397 100644 --- a/tests/test_local_memory.py +++ b/tests/test_local_memory.py @@ -4,8 +4,10 @@ import asyncio import json +import logging from multiprocessing import Process from pathlib import Path +from unittest.mock import patch import pytest @@ -191,3 +193,175 @@ async def run(): scope="organization", thought="", ) asyncio.run(run()) + + +class TestSidecarLockAndCompaction: + """S6-F10 (stable sidecar lock) + S8-F4 (archived-history compaction).""" + + def _record(self, n: int, status: str = "active") -> MemoryRecord: + return MemoryRecord( + id=f"mem_{n:04d}", + content=f"record {n}", + system="working", + type="episodic", + segment="agent", + scope="organization", + status=status, + ) + + def test_append_during_archive_is_not_lost(self, tmp_path: Path, monkeypatch): + """The archive-vs-append lost-write race is closed (S6-F10). + + archive() replaces the data file's inode; under the old data-file + lock, an append racing the rewrite could acquire the OLD inode's lock + and write into the orphaned file — silently dropping a protocol-state + envelope. The sidecar lock serializes the whole read-modify-rewrite + against the append; the appender, blocked until archive finishes, + re-opens the path and lands in the NEW file. + """ + import threading + + + store = LocalMemoryStore(tmp_path / "memories.jsonl") + rec = asyncio.run( + store.store( + content="seed", system="working", type="episodic", segment="agent", + ), + ) + + in_rewrite = threading.Event() + appender_started = threading.Event() + orig_rewrite = LocalMemoryStore._rewrite_locked + + def slow_rewrite(self, records): + in_rewrite.set() + # Hold the lock long enough for the appender to be blocked on it. + appender_started.wait(timeout=5) + import time + + time.sleep(0.2) + return orig_rewrite(self, records) + + monkeypatch.setattr(LocalMemoryStore, "_rewrite_locked", slow_rewrite) + + def do_append(): + in_rewrite.wait(timeout=5) + appender_started.set() + store._append(self._record(999)) + + t = threading.Thread(target=do_append) + t.start() + asyncio.run(store.archive(rec.id)) + t.join(timeout=5) + assert not t.is_alive() + + contents = (tmp_path / "memories.jsonl").read_text(encoding="utf-8") + assert "mem_0999" in contents # the racing append survived + assert '"status": "archived"' in contents # and so did the archive + + def test_lock_uses_stable_sidecar_file(self, tmp_path: Path): + store = LocalMemoryStore(tmp_path / "memories.jsonl") + with store._locked(): + pass + assert (tmp_path / "memories.jsonl.lock").exists() + + def test_archive_compacts_archived_history_beyond_keep_last( + self, tmp_path: Path, + ): + """archive()'s rewrite keeps only the newest 50 archived records; + live records are untouched and list() semantics unchanged (S8-F4).""" + from codeband.memory.local_store import _ARCHIVED_KEEP_LAST + + store = LocalMemoryStore(tmp_path / "memories.jsonl") + # 60 already-archived records (oldest first), 3 live ones, then + # archive one more live record to trigger the rewrite. + for n in range(60): + store._append(self._record(n, status="archived")) + for n in range(100, 103): + store._append(self._record(n)) + trigger = self._record(200) + store._append(trigger) + + result = asyncio.run(store.archive(trigger.id)) + assert result is not None + + records = list(store._iter_records()) + archived = [r for r in records if r.status == "archived"] + active = [r for r in records if r.status == "active"] + assert len(archived) == _ARCHIVED_KEEP_LAST == 50 + # Newest archived kept: the trigger plus the tail of the original 60. + assert archived[-1].id == "mem_0200" + assert archived[0].id == "mem_0011" # 60+1 archived → oldest 11 dropped + # Live records untouched, in order. + assert [r.id for r in active] == ["mem_0100", "mem_0101", "mem_0102"] + # list() for live records is unchanged. + listed = asyncio.run(store.list()) + assert [r.id for r in listed.data] == ["mem_0100", "mem_0101", "mem_0102"] + + def test_no_compaction_below_threshold(self, tmp_path: Path): + """At or below keep-last-50 archived records, nothing is dropped.""" + store = LocalMemoryStore(tmp_path / "memories.jsonl") + for n in range(20): + store._append(self._record(n, status="archived")) + trigger = self._record(50) + store._append(trigger) + + asyncio.run(store.archive(trigger.id)) + + archived = [r for r in store._iter_records() if r.status == "archived"] + assert len(archived) == 21 + + +class TestOSErrorDegradation: + """T-03: OSError in the write path must degrade, not crash-loop the caller.""" + + async def test_append_oserror_degrades_not_raises( + self, store: LocalMemoryStore, caplog: pytest.LogCaptureFixture + ): + """An OSError opening the data file must be caught and logged, not raised.""" + original_open = Path.open + + def fail_data_write(path_self: Path, mode: str = "r", **kwargs): + # Only the data file (suffix .jsonl) opened for append should fail. + # The lock sidecar (.jsonl.lock) must still open normally. + if path_self.suffix == ".jsonl" and "a" in mode: + raise OSError("simulated disk full") + return original_open(path_self, mode, **kwargs) + + with patch.object(Path, "open", fail_data_write): + with caplog.at_level(logging.ERROR, logger="codeband.memory.local_store"): + result = await store.store( + content="x", system="s", type="t", segment="seg" + ) + + assert result is not None # returns a MemoryRecord — does not raise + assert any(r.levelname == "ERROR" for r in caplog.records) + + def test_rewrite_locked_oserror_degrades_not_raises( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ): + """An OSError writing the tmp file must be caught and logged, not raised.""" + store = LocalMemoryStore(tmp_path / "memories.jsonl") + # Point path at a non-existent directory so the tmp write cannot succeed. + store.path = tmp_path / "nonexistent_dir" / "memories.jsonl" + rec = MemoryRecord( + id="mem_test", content="x", system="s", type="t", + segment="seg", scope="org", thought="", + ) + with caplog.at_level(logging.ERROR, logger="codeband.memory.local_store"): + store._rewrite_locked([rec]) # must not raise + assert any(r.levelname == "ERROR" for r in caplog.records) + + +class TestArchiveOffLoop: + """T-09: archive()'s blocking flock + read + rewrite must run off the event loop.""" + + async def test_archive_dispatches_via_to_thread(self, store: LocalMemoryStore): + rec = await store.store( + content="x", system="working", type="episodic", + segment="agent", scope="organization", thought="", + ) + with patch("asyncio.to_thread", wraps=asyncio.to_thread) as mock_tt: + result = await store.archive(rec.id) + assert result is not None + mock_tt.assert_called_once() diff --git a/tests/test_merge_gate.py b/tests/test_merge_gate.py new file mode 100644 index 0000000..9d713c7 --- /dev/null +++ b/tests/test_merge_gate.py @@ -0,0 +1,398 @@ +"""Tests for the Stage-2 merge edge: SHA-pinned eligibility gate, +``needs_rebase`` send-back, and task-level completion. + +All deterministic — real SQLite, real FSM, no subprocesses. The verdict +records the eligibility check reads are the same ``transition_log`` rows the +FSM writes on the verify (``→ review_pending``) and review +(``→ review_passed``) outcomes, so the fixtures drive real transitions with +``head_sha`` pinned exactly as ``cb-phase`` does. +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from codeband.state.fsm import ( + InvalidTransitionError, + MergeNotEligibleError, + check_merge_eligibility, + transition, +) +from codeband.state.store import StateStore + + +@pytest.fixture +def store(tmp_path) -> StateStore: + s = StateStore(tmp_path / "state" / "orchestration.db") + # create_task leaves required_verdicts NULL — a pre-snapshot task, which + # must resolve to the DEFAULT pair (never to ungated). + s.create_task(task_id="room-1", description="demo", room_id="room-1") + return s + + +def _log_rows(store: StateStore, subtask_id: str) -> list[sqlite3.Row]: + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + try: + return conn.execute( + "SELECT * FROM transition_log WHERE subtask_id = ? ORDER BY id", + (subtask_id,), + ).fetchall() + finally: + conn.close() + + +def _task_status(store: StateStore, task_id: str) -> str: + return store.get_task(task_id).status + + +def _drive(store, sid, steps, task="room-1"): + for new_state, role, sha in steps: + transition(sid, task, new_state, caller_role=role, store=store, + head_sha=sha) + + +def _to_review_passed(store, sid, *, verify_sha, review_sha, task="room-1"): + """Walk a subtask to ``review_passed``, pinning each outcome's SHA. + + ``verify_sha`` lands on the ``→ review_pending`` record (the verify leg), + ``review_sha`` on the ``→ review_passed`` record (the review leg) — split + so stale/NULL-pin scenarios can be staged per leg. + """ + _drive(store, sid, [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", verify_sha), + ("review_passed", "reviewer", review_sha), + ], task=task) + + +def _register_ungated_task(store, task_id): + """A tasks row with the explicit [] snapshot (allow_ungated_merge opt-out).""" + store.register_task_atomic( + task_id=task_id, + description="ungated demo", + room_id=task_id, + owner_id="owner-1", + required_verdicts=[], + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# check_merge_eligibility — the SHA-pinned rules, fail-closed throughout +# ───────────────────────────────────────────────────────────────────────────── + + +def test_all_verdicts_pinned_at_matching_sha_is_eligible(store): + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-1") + + result = check_merge_eligibility("room-1", "st-1", "sha-1", store=store) + assert result.eligible is True + assert result.reasons == [] # gated pass carries no reasons + + +def test_missing_verdict_is_named(store): + # Only the verify leg has a record (subtask sits at review_pending); + # the review leg is missing entirely. + _drive(store, "st-1", [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-1"), + ]) + + result = check_merge_eligibility("room-1", "st-1", "sha-1", store=store) + assert result.eligible is False + assert len(result.reasons) == 1 + assert result.reasons[0].startswith("missing_verdict review") + + +def test_stale_verdict_is_named_per_leg(store): + # Verify pinned to sha-1, review pinned to sha-2 — whichever SHA the merge + # targets, exactly the other leg is stale and is named with its pin. + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-2") + + at_sha1 = check_merge_eligibility("room-1", "st-1", "sha-1", store=store) + assert at_sha1.eligible is False + assert len(at_sha1.reasons) == 1 + assert at_sha1.reasons[0].startswith("stale_verdict review") + assert "sha-2" in at_sha1.reasons[0] + + at_sha2 = check_merge_eligibility("room-1", "st-1", "sha-2", store=store) + assert at_sha2.eligible is False + assert len(at_sha2.reasons) == 1 + assert at_sha2.reasons[0].startswith("stale_verdict verify") + assert "sha-1" in at_sha2.reasons[0] + + +def test_null_pinned_verdict_matches_nothing(store): + # Both outcome records exist but carry NULL head_sha (legacy / best-effort + # git failure) — fail-closed: NULL matches no SHA, each leg is named. + _to_review_passed(store, "st-1", verify_sha=None, review_sha=None) + + result = check_merge_eligibility("room-1", "st-1", "sha-1", store=store) + assert result.eligible is False + assert len(result.reasons) == 2 + tags = sorted(r.split(":")[0] for r in result.reasons) + assert tags == ["unpinned_verdict review", "unpinned_verdict verify"] + + +def test_null_snapshot_enforces_default_pair(store): + # room-1's required_verdicts is NULL (create_task, pre-snapshot task). + # NEVER ungated: with no verdict records at all, BOTH default legs are + # reported missing. + store.ensure_subtask("st-1", "room-1", state="review_passed") + + result = check_merge_eligibility("room-1", "st-1", "sha-1", store=store) + assert result.eligible is False + assert len(result.reasons) == 2 + tags = sorted(r.split(":")[0] for r in result.reasons) + assert tags == ["missing_verdict review", "missing_verdict verify"] + + +def test_empty_snapshot_is_vacuously_eligible_and_says_so(store): + _register_ungated_task(store, "room-2") + store.ensure_subtask("st-1", "room-2", state="review_passed") + + # No verdict records, not even a head_sha — vacuously eligible, and the + # reasons say explicitly that this is the ungated opt-out, so a log + # reader can never mistake it for a checked pass. + result = check_merge_eligibility("room-2", "st-1", None, store=store) + assert result.eligible is True + assert len(result.reasons) == 1 + assert result.reasons[0].startswith("ungated_merge") + assert "allow_ungated_merge" in result.reasons[0] + + +def test_missing_head_sha_fails_closed_on_gated_task(store): + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-1") + + result = check_merge_eligibility("room-1", "st-1", None, store=store) + assert result.eligible is False + assert len(result.reasons) == 1 + assert result.reasons[0].startswith("no_head_sha") + + +def test_unknown_task_fails_closed(store): + result = check_merge_eligibility("no-such-task", "st-1", "sha-1", store=store) + assert result.eligible is False + assert result.reasons[0].startswith("unknown_task") + + +# ───────────────────────────────────────────────────────────────────────────── +# The gate inside the transition — review_passed → merge_pending +# ───────────────────────────────────────────────────────────────────────────── + + +def test_eligible_merge_transition_succeeds(store): + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-1") + + transition("st-1", "room-1", "merge_pending", caller_role="mergemaster", + store=store, head_sha="sha-1") + + assert store.get_subtask("st-1", "room-1").state == "merge_pending" + last = _log_rows(store, "st-1")[-1] + assert (last["from_state"], last["to_state"]) == ( + "review_passed", "merge_pending", + ) + assert last["head_sha"] == "sha-1" # the merge is itself SHA-pinned + + +def test_ineligible_merge_transition_rejected_state_unchanged(store): + # Verdicts pinned to sha-1; the mergemaster tries to merge sha-2 (e.g. a + # commit pushed after review). Rejected loudly, nothing written. + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-1") + rows_before = len(_log_rows(store, "st-1")) + + with pytest.raises(MergeNotEligibleError) as exc_info: + transition("st-1", "room-1", "merge_pending", caller_role="mergemaster", + store=store, head_sha="sha-2") + + # Loud, machine-readable: the exception names every stale leg. + message = str(exc_info.value) + assert "stale_verdict verify" in message + assert "stale_verdict review" in message + assert exc_info.value.eligibility.eligible is False + + assert store.get_subtask("st-1", "room-1").state == "review_passed" + assert len(_log_rows(store, "st-1")) == rows_before + + +def test_merge_transition_without_head_sha_rejected(store): + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-1") + + with pytest.raises(MergeNotEligibleError, match="no_head_sha"): + transition("st-1", "room-1", "merge_pending", caller_role="mergemaster", + store=store) + assert store.get_subtask("st-1", "room-1").state == "review_passed" + + +def test_merge_not_eligible_is_an_invalid_transition_error(store): + # Callers that catch the broad FSM rejection keep working. + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-1") + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "merge_pending", caller_role="mergemaster", + store=store, head_sha="other") + + +def test_ungated_task_merges_without_any_verdict_match(store): + _register_ungated_task(store, "room-2") + # Even the outcome records carry no SHA — the [] snapshot opts out of the + # check entirely, so the merge transition passes (vacuously eligible). + _to_review_passed(store, "st-1", verify_sha=None, review_sha=None, + task="room-2") + + transition("st-1", "room-2", "merge_pending", caller_role="mergemaster", + store=store) + assert store.get_subtask("st-1", "room-2").state == "merge_pending" + + +# ───────────────────────────────────────────────────────────────────────────── +# The needs_rebase send-back leg +# ───────────────────────────────────────────────────────────────────────────── + + +def test_needs_rebase_round_trip_re_earns_verdicts_at_new_sha(store): + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-1") + + # Mergemaster finds the branch stale → send back for a rebase. + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", + store=store) + assert store.get_subtask("st-1", "room-1").state == "needs_rebase" + + # Coder picks the rework up — the same return-to-coder state the + # review-fail loop uses, and it does NOT count as a review round. + transition("st-1", "room-1", "in_progress", caller_role="coder", + store=store) + row = store.get_subtask("st-1", "room-1") + assert row.state == "in_progress" + assert row.review_round == 0 + + # Re-earn both verdicts at the rebased commit (sha-2). A SHA no verdict + # ever blessed (sha-3) is still rejected — the gate pins verdicts to + # exact commits; which commit is fresh enough to merge stays the + # mergemaster's needs_rebase call. + _drive(store, "st-1", [ + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-2"), + ("review_passed", "reviewer", "sha-2"), + ]) + with pytest.raises(MergeNotEligibleError): + transition("st-1", "room-1", "merge_pending", caller_role="mergemaster", + store=store, head_sha="sha-3") + + # The re-earned pair at sha-2 merges. + transition("st-1", "room-1", "merge_pending", caller_role="mergemaster", + store=store, head_sha="sha-2") + assert store.get_subtask("st-1", "room-1").state == "merge_pending" + + +@pytest.mark.parametrize( + "new_state, role", + [ + ("merge_pending", "mergemaster"), # no shortcut back into the queue + ("review_pending", "coder"), # cannot skip the rework walk + ("review_passed", "reviewer"), # cannot re-approve in place + ("merged", "mergemaster"), # certainly cannot merge from here + ], +) +def test_illegal_edges_out_of_needs_rebase_rejected(store, new_state, role): + _to_review_passed(store, "st-1", verify_sha="sha-1", review_sha="sha-1") + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", + store=store) + rows_before = len(_log_rows(store, "st-1")) + + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", new_state, caller_role=role, store=store, + head_sha="sha-1") + assert store.get_subtask("st-1", "room-1").state == "needs_rebase" + assert len(_log_rows(store, "st-1")) == rows_before + + +def test_illegal_edges_into_needs_rebase_rejected(store): + # Only the mergemaster, and only from review_passed, may send back. + _drive(store, "st-1", [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ]) + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "needs_rebase", caller_role="mergemaster", + store=store) + + _to_review_passed(store, "st-2", verify_sha="sha-1", review_sha="sha-1") + with pytest.raises(InvalidTransitionError): + transition("st-2", "room-1", "needs_rebase", caller_role="coder", + store=store) + + +def test_in_progress_cannot_jump_to_merge_pending(store): + _drive(store, "st-1", [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ]) + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "merge_pending", caller_role="mergemaster", + store=store, head_sha="sha-1") + assert store.get_subtask("st-1", "room-1").state == "in_progress" + + +def test_wildcards_apply_to_needs_rebase(store): + # needs_rebase is non-terminal — the conductor-abandon and watchdog-block + # cross-cutting rules apply to it like any other in-flight state. + for sid, target, role in [ + ("st-1", "abandoned", "conductor"), + ("st-2", "blocked", "watchdog"), + ]: + _to_review_passed(store, sid, verify_sha="sha-1", review_sha="sha-1") + transition(sid, "room-1", "needs_rebase", caller_role="mergemaster", + store=store) + transition(sid, "room-1", target, caller_role=role, store=store) + assert store.get_subtask(sid, "room-1").state == target + + +# ───────────────────────────────────────────────────────────────────────────── +# Task-level completion — last merged subtask promotes the task +# ───────────────────────────────────────────────────────────────────────────── + + +def _merge(store, sid, sha, task="room-1"): + _to_review_passed(store, sid, verify_sha=sha, review_sha=sha, task=task) + transition(sid, task, "merge_pending", caller_role="mergemaster", + store=store, head_sha=sha) + transition(sid, task, "merged", caller_role="mergemaster", store=store) + + +def test_last_merged_subtask_promotes_task_partial_does_not(store): + store.ensure_subtask("st-1", "room-1") + store.ensure_subtask("st-2", "room-1") + + _merge(store, "st-1", "sha-a") + assert _task_status(store, "room-1") == "active" # partial → no promotion + + _merge(store, "st-2", "sha-b") + assert _task_status(store, "room-1") == "completed" + + +def test_superseded_task_is_never_promoted(store): + store.create_task(task_id="room-old", description="old", room_id="room-old", + status="superseded") + _merge(store, "st-1", "sha-a", task="room-old") + + # All subtasks merged, but the task was superseded — its status is owned + # by registration semantics and must not be overwritten. + assert _task_status(store, "room-old") == "superseded" + + +def test_abandoned_sibling_blocks_promotion(store): + # Strict rule: 'completed' means every subtask MERGED. A task whose plan + # was partially abandoned never reads as completed (Stage-2 chunk 2b may + # revisit; pinned here so any change is deliberate). + store.ensure_subtask("st-1", "room-1") + transition("st-1", "room-1", "abandoned", caller_role="conductor", + store=store) + _merge(store, "st-2", "sha-a") + + assert _task_status(store, "room-1") == "active" diff --git a/tests/test_merge_leg.py b/tests/test_merge_leg.py new file mode 100644 index 0000000..d0ad20c --- /dev/null +++ b/tests/test_merge_leg.py @@ -0,0 +1,1230 @@ +"""Tests for the ``cb-phase merge`` execution leg (Stage-2 chunk 2b). + +All deterministic: real SQLite + real FSM; every external interaction +(``gh pr view`` / ``gh pr merge`` via :func:`merge._pr_snapshot` / +:func:`merge._gh_merge`, the Band approval-request send via +:func:`merge._send_approval_request`) is monkeypatched at the module seam, +mirroring ``test_handoff.py``. The verdict records the gate reads are real +``transition_log`` rows driven through the FSM with pinned SHAs, exactly as +``cb-phase`` writes them. +""" + +from __future__ import annotations + +import sqlite3 +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from codeband.cli import handoff, merge +from codeband.config import AgentsConfig +from codeband.state.fsm import NoOpTransitionError, transition +from codeband.state.registration import ( + DEFAULT_MERGE_APPROVAL, + register_task, + resolve_merge_approval, +) +from codeband.state.store import StateStore + +TASK = "room-1" +SHA = "sha-1" + + +def _drive_to_review_passed( + store, sid, *, verify_sha=SHA, review_sha=SHA, task=TASK, +): + for new_state, role, sha in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", verify_sha), + ("review_passed", "reviewer", review_sha), + ]: + transition(sid, task, new_state, caller_role=role, store=store, head_sha=sha) + + +def _log_rows(store, subtask_id, to_state=None): + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + try: + sql = "SELECT * FROM transition_log WHERE subtask_id = ?" + params = [subtask_id] + if to_state is not None: + sql += " AND to_state = ?" + params.append(to_state) + return conn.execute(sql + " ORDER BY id", params).fetchall() + finally: + conn.close() + + +@pytest.fixture +def store(tmp_path) -> StateStore: + """A registered (owner-approved, fully gated) task with st-1 at review_passed.""" + s = StateStore(tmp_path / "state" / "orchestration.db") + s.register_task_atomic( + task_id=TASK, description="demo", room_id=TASK, + owner_id="owner-1", owner_handle="yoni", + required_verdicts=["verify", "review"], merge_approval="owner", + ) + _drive_to_review_passed(s, "st-1") + return s + + +@pytest.fixture +def env(monkeypatch, store): + """Wire every external seam to controllable fakes (happy defaults).""" + pr = { + "state": "OPEN", "mergeable": "MERGEABLE", "headRefOid": SHA, + "headRefName": "codeband/coder-claude-0/feat-x", + } + gh_merges: list[int] = [] + gh_merge_pins: list[str | None] = [] + sends: list[tuple] = [] + branch_deletes: list[str | None] = [] + + monkeypatch.setattr(merge, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + merge, "_resolve_task_id", + lambda project_dir, store, task_arg: (TASK, None), + ) + snapshot_repos: list[str | None] = [] + + def _fake_snapshot(pr_number, cwd, repo=None): + snapshot_repos.append(repo) + return dict(pr) + + monkeypatch.setattr(merge, "_pr_snapshot", _fake_snapshot) + # record_approval_grant derives its --repo slug from config repo.url — + # stub the config load so no codeband.yaml is needed on disk. + monkeypatch.setattr( + merge, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + agents=SimpleNamespace(max_rebase_rounds=3), + ), + ) + + gh_merge_repos: list[str | None] = [] + + def _fake_merge(pr_number, cwd, pending_sha, repo=None): + gh_merges.append(pr_number) + gh_merge_pins.append(pending_sha) + gh_merge_repos.append(repo) + return 0, "merged ok" + + monkeypatch.setattr(merge, "_gh_merge", _fake_merge) + + def _fake_send(project_dir, task, subtask_id, pr_number, head_sha, approver_spec): + sends.append((subtask_id, pr_number, head_sha, approver_spec)) + + monkeypatch.setattr(merge, "_send_approval_request", _fake_send) + + def _fake_delete(snapshot, cwd): + branch_deletes.append((snapshot or {}).get("headRefName")) + + monkeypatch.setattr(merge, "_delete_remote_branch", _fake_delete) + return SimpleNamespace( + store=store, pr=pr, gh_merges=gh_merges, gh_merge_pins=gh_merge_pins, + gh_merge_repos=gh_merge_repos, sends=sends, + branch_deletes=branch_deletes, snapshot_repos=snapshot_repos, + ) + + +def _grant(store, sid="st-1", sha=SHA): + store.record_merge_approval(sid, TASK, approved_by="owner", approved_sha=sha) + + +def _run(*argv): + return handoff.main(["merge", *argv] if argv else ["merge", "st-1", "--pr", "42"]) + + +# ───────────────────────────────────────────────────────────────────────────── +# Happy path + approval routing +# ───────────────────────────────────────────────────────────────────────────── + + +def test_happy_path_preapproved_merges_and_completes_task(env): + _grant(env.store) + + assert _run() == 0 + assert env.store.get_subtask("st-1", TASK).state == "merged" + assert env.gh_merges == [42] + # The execution is pinned to the queued (approved) SHA… + assert env.gh_merge_pins == [SHA] + # …and the remote branch is cleaned up after the merge is recorded. + assert env.branch_deletes == ["codeband/coder-claude-0/feat-x"] + # --pr was persisted for argument-less reconcile re-runs. + assert env.store.get_subtask("st-1", TASK).pr_number == 42 + # The queue + landing transitions are both recorded, pinned to the SHA. + assert [r["head_sha"] for r in _log_rows(env.store, "st-1", "merge_pending")] == [SHA] + assert [r["head_sha"] for r in _log_rows(env.store, "st-1", "merged")] == [SHA] + # Last subtask merged → the 2a task-level promotion fires on its own. + assert env.store.get_task(TASK).status == "completed" + + +def test_merge_leg_snapshot_and_merge_are_repo_pinned(env): + """Every gh PR query in the merge leg carries --repo from config + repo.url [S9-7] — completing the gate family's repo pinning (verify and + the grant half already do this). Without it, a same-numbered PR in + whatever repo the worktree cwd happens to be in could be + reconciled/merged.""" + _grant(env.store) + assert _run() == 0 + assert env.snapshot_repos == ["acme/widgets"] + assert env.gh_merge_repos == ["acme/widgets"] + + +def test_gh_merge_argv_carries_repo_flag(monkeypatch): + """The real _gh_merge passes --repo (and keeps --match-head-commit).""" + captured = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + return SimpleNamespace(returncode=0, stdout="ok", stderr="") + + monkeypatch.setattr(merge.subprocess, "run", fake_run) + code, _ = merge._gh_merge(42, Path("."), "sha-1", repo="acme/widgets") + + assert code == 0 + cmd = captured["cmd"] + assert cmd[:5] == ["gh", "pr", "merge", "42", "--merge"] + assert cmd[cmd.index("--match-head-commit") + 1] == "sha-1" + assert cmd[cmd.index("--repo") + 1] == "acme/widgets" + + +def test_unresolvable_slug_rejects_pr_query_failed(env, monkeypatch, capsys): + """An underivable repo slug is an infra failure like a failed snapshot: + fail closed before any PR query, no transition recorded.""" + _grant(env.store) + monkeypatch.setattr( + merge, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://gitlab.example.com/g/p.git"), + ), + ) + + assert _run() == merge.EXIT_PR_QUERY_FAILED + assert "[pr_query_failed]" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "review_passed" + assert env.snapshot_repos == [] + assert env.gh_merges == [] + + +def test_approval_pending_rests_requests_once_then_executes(env): + # 1st invocation: rests at merge_pending, one request to the owner, no merge. + assert _run() == 0 + sub = env.store.get_subtask("st-1", TASK) + assert sub.state == "merge_pending" + assert env.sends == [("st-1", 42, SHA, "owner")] + assert env.gh_merges == [] + assert sub.merge_approval_requested_sha == SHA # send-once marker burned + + # 2nd invocation, still unapproved: no re-send, still resting, exit 0. + assert _run() == 0 + assert len(env.sends) == 1 + assert env.gh_merges == [] + assert env.store.get_subtask("st-1", TASK).state == "merge_pending" + + # Approval lands → the next invocation executes. + _grant(env.store) + assert _run() == 0 + assert env.store.get_subtask("st-1", TASK).state == "merged" + assert env.gh_merges == [42] + assert len(env.sends) == 1 # never re-requested + + +def test_send_failure_leaves_marker_unburned_and_retries(env, monkeypatch, capsys): + def _boom(*args, **kwargs): + raise RuntimeError("band unreachable") + + monkeypatch.setattr(merge, "_send_approval_request", _boom) + assert _run() == 0 # resting at merge_pending is legitimate either way + sub = env.store.get_subtask("st-1", TASK) + assert sub.state == "merge_pending" + assert sub.merge_approval_requested_sha is None # marker-after-send + assert "request send FAILED" in capsys.readouterr().err + + # Send works again → the retry actually sends and burns the marker. + sends: list[tuple] = [] + monkeypatch.setattr( + merge, "_send_approval_request", + lambda *a: sends.append(a), + ) + assert _run() == 0 + assert len(sends) == 1 + assert env.store.get_subtask("st-1", TASK).merge_approval_requested_sha == SHA + + +def test_stale_grant_from_earlier_round_does_not_authorize(env, capsys): + # Grant pinned to a different SHA than the queued one (e.g. a pre-rebase + # grant): not granted — the leg rests and reports the stale pin. + _grant(env.store, sha="sha-0") + assert _run() == 0 + assert env.store.get_subtask("st-1", TASK).state == "merge_pending" + assert env.gh_merges == [] + assert "re-approval required" in capsys.readouterr().err + assert len(env.sends) == 1 # re-requested for the new SHA + + +# ───────────────────────────────────────────────────────────────────────────── +# Gate rejection, SHA drift, mergeability, failure classification +# ───────────────────────────────────────────────────────────────────────────── + + +def test_sha_shaped_ineligibility_routes_to_needs_rebase(env, capsys): + """Stale verdicts (legs exist, pinned to an older SHA) are cured by + rework at the current head — the gate drives review_passed → + needs_rebase with a reason naming the stale leg, not a bare reject.""" + _drive_to_review_passed(env.store, "st-2", verify_sha="sha-0") # stale verify + + assert handoff.main(["merge", "st-2", "--pr", "43"]) == merge.EXIT_NEEDS_REBASE + err = capsys.readouterr().err + assert "REJECTED [stale_verdicts]" in err + assert "stale_verdict verify" in err # the stale leg is named + sub = env.store.get_subtask("st-2", TASK) + assert sub.state == "needs_rebase" + assert sub.rebase_rounds == 1 # counted like every other send-back + assert env.sends == [] # no approval request for an ineligible merge + assert env.gh_merges == [] # and no merge attempt + + +def test_mixed_sha_legs_also_route_to_needs_rebase(env, capsys): + """Both legs exist but pin different SHAs (neither is the head) — still + SHA-shaped: rework at the head cures both.""" + _drive_to_review_passed( + env.store, "st-2", verify_sha="sha-0", review_sha="sha-9", + ) + + assert handoff.main(["merge", "st-2", "--pr", "43"]) == merge.EXIT_NEEDS_REBASE + err = capsys.readouterr().err + assert "REJECTED [stale_verdicts]" in err + assert "stale_verdict verify" in err and "stale_verdict review" in err + assert env.store.get_subtask("st-2", TASK).state == "needs_rebase" + + +def _force_state(store, sid, state): + """Hand-edit a subtask row's state without transition_log rows — the + legacy/hand-edited shape that produces missing_verdict reasons.""" + store.ensure_subtask(sid, TASK) + conn = sqlite3.connect(store.db_path) + try: + conn.execute( + "UPDATE subtask_states SET state = ? " + "WHERE task_id = ? AND subtask_id = ?", + (state, TASK, sid), + ) + conn.commit() + finally: + conn.close() + + +def test_missing_verdict_leg_stays_a_bare_reject(env, capsys): + """A leg with NO passing record at all is not SHA-shaped — the chain + never completed; rework can't cure a routing failure. Bare reject.""" + _force_state(env.store, "st-3", "review_passed") # no verdict rows at all + + assert handoff.main(["merge", "st-3", "--pr", "44"]) == merge.EXIT_NOT_ELIGIBLE + err = capsys.readouterr().err + assert "REJECTED [not_eligible]" in err + assert "missing_verdict" in err + sub = env.store.get_subtask("st-3", TASK) + assert sub.state == "review_passed" # rests, untouched + assert sub.rebase_rounds == 0 + + +def test_mixed_missing_and_stale_stays_a_bare_reject(env, capsys): + """One stale leg + one missing leg → conservative: bare reject (the + missing leg is a process failure, not a re-pin candidate).""" + # A real verify record at a stale SHA… + for new_state, role, sha in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-0"), + ]: + transition( + "st-4", TASK, new_state, caller_role=role, + store=env.store, head_sha=sha, + ) + # …then a hand-edited jump to review_passed with no review verdict row. + _force_state(env.store, "st-4", "review_passed") + + assert handoff.main(["merge", "st-4", "--pr", "45"]) == merge.EXIT_NOT_ELIGIBLE + err = capsys.readouterr().err + assert "REJECTED [not_eligible]" in err + assert env.store.get_subtask("st-4", TASK).state == "review_passed" + + +def test_sha_moved_while_queued_goes_needs_rebase(env, capsys): + assert _run() == 0 # queue at SHA (awaiting approval) + _grant(env.store) + env.pr["headRefOid"] = "sha-2" # someone pushed while waiting + + assert _run() == merge.EXIT_NEEDS_REBASE + err = capsys.readouterr().err + assert "REJECTED [sha_moved]" in err + assert SHA in err and "sha-2" in err # names old and new SHA + assert env.store.get_subtask("st-1", TASK).state == "needs_rebase" + assert env.gh_merges == [] # fail-closed, no execution + + +def test_sha_moved_rechecked_before_approval_no_request_no_marker_burn(env, capsys): + """The execution-time SHA re-check runs BEFORE the approval gate: a head + that moved while queued goes needs_rebase without any grant evaluation or + approval-request send — so the send-once marker is never burned for the + new SHA and the subtask cannot be stranded permanently un-approvable.""" + assert _run() == 0 # queue at SHA; one request sent for SHA + assert len(env.sends) == 1 + env.pr["headRefOid"] = "sha-2" # someone pushed while resting + + assert _run() == merge.EXIT_NEEDS_REBASE + assert "REJECTED [sha_moved]" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "needs_rebase" + assert len(env.sends) == 1 # NO approval request for the sha-2 round + # The marker still names the original SHA — not burned for sha-2. + assert env.store.get_subtask("st-1", TASK).merge_approval_requested_sha == SHA + assert env.gh_merges == [] + + +def test_conflicted_pr_goes_needs_rebase_without_merge_attempt(env, capsys): + _grant(env.store) + env.pr["mergeable"] = "CONFLICTING" + + assert _run() == merge.EXIT_NEEDS_REBASE + assert "REJECTED [conflicted]" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "needs_rebase" + assert env.gh_merges == [] + + +def test_residual_merge_failure_blocks_once_with_reason(env, monkeypatch, capsys): + _grant(env.store) + attempts: list[int] = [] + + def _failing_merge(pr_number, cwd, pending_sha, repo=None): + attempts.append(pr_number) + return 1, "GraphQL: 2 of 3 required status checks are expected" + + monkeypatch.setattr(merge, "_gh_merge", _failing_merge) + + assert _run() == merge.EXIT_MERGE_FAILED + assert "BLOCKED [merge_failed]" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "blocked" + blocked = _log_rows(env.store, "st-1", "blocked") + assert len(blocked) == 1 # exactly one escalation trigger + assert "required status checks" in blocked[0]["reason"] + + # Re-failure does not re-escalate: a blocked subtask is not a valid entry + # state, so re-invocation writes nothing and never re-runs gh. (The single + # owner escalation itself is the watchdog's blocked-subtask patrol — + # escalate-once via its durable trigger, see test_watchdog_upgrade.py.) + assert _run() == 1 + assert "not a valid entry state" in capsys.readouterr().err + assert len(_log_rows(env.store, "st-1", "blocked")) == 1 + assert attempts == [42] + + +def test_execution_time_conflict_classified_as_needs_rebase(env, monkeypatch): + _grant(env.store) + monkeypatch.setattr( + merge, "_gh_merge", + lambda pr_number, cwd, pending_sha, repo=None: ( + 1, "Pull request #42 is not mergeable: the merge commit cannot " + "be cleanly created", + ), + ) + + assert _run() == merge.EXIT_NEEDS_REBASE + assert env.store.get_subtask("st-1", TASK).state == "needs_rebase" + assert _log_rows(env.store, "st-1", "blocked") == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# Effect-verified failure classification (re-snapshot before classifying) +# ───────────────────────────────────────────────────────────────────────────── + + +def _snapshot_sequence(monkeypatch, *snaps): + """Make _pr_snapshot return each snapshot in turn (initial, post-failure).""" + remaining = list(snaps) + monkeypatch.setattr( + merge, "_pr_snapshot", + lambda pr_number, cwd, repo=None: dict(remaining.pop(0)) if remaining else None, + ) + + +def test_gh_failure_with_pr_actually_merged_records_merged(env, monkeypatch, capsys): + """gh exiting non-zero after the merge landed (timeout after the API call) + must record merged, not blocked — the exact misclassification that + produced Scenario A's unrecoverable blocked.""" + _grant(env.store) + monkeypatch.setattr( + merge, "_gh_merge", + lambda pr, cwd, sha, repo=None: (1, "Post https://api.github.com: i/o timeout"), + ) + _snapshot_sequence(monkeypatch, env.pr, {**env.pr, "state": "MERGED"}) + + assert _run() == 0 + assert env.store.get_subtask("st-1", TASK).state == "merged" + rows = _log_rows(env.store, "st-1", "merged") + assert len(rows) == 1 + assert "post-failure reconcile" in rows[0]["reason"] + assert "gh exited 1" in rows[0]["reason"] + assert _log_rows(env.store, "st-1", "blocked") == [] + assert "merge landed" in capsys.readouterr().out + # Task-level completion promotion fires off the recorded merge. + assert env.store.get_task(TASK).status == "completed" + + +def test_gh_failure_with_moved_head_goes_needs_rebase(env, monkeypatch): + """A --match-head-commit rejection shows up as a gh failure with a moved + head in the re-snapshot — classified needs_rebase, never blocked.""" + _grant(env.store) + monkeypatch.setattr( + merge, "_gh_merge", + lambda pr, cwd, sha, repo=None: (1, "head commit does not match expected SHA"), + ) + _snapshot_sequence(monkeypatch, env.pr, {**env.pr, "headRefOid": "sha-2"}) + + assert _run() == merge.EXIT_NEEDS_REBASE + assert env.store.get_subtask("st-1", TASK).state == "needs_rebase" + assert _log_rows(env.store, "st-1", "blocked") == [] + + +def test_gh_failure_with_structured_conflicting_field_goes_needs_rebase( + env, monkeypatch, +): + """The re-snapshot's structured mergeable field classifies a conflict even + when gh's error text matches no conflict regex.""" + _grant(env.store) + monkeypatch.setattr( + merge, "_gh_merge", + lambda pr, cwd, sha, repo=None: (1, "GraphQL: something opaque went wrong"), + ) + _snapshot_sequence(monkeypatch, env.pr, {**env.pr, "mergeable": "CONFLICTING"}) + + assert _run() == merge.EXIT_NEEDS_REBASE + assert env.store.get_subtask("st-1", TASK).state == "needs_rebase" + assert _log_rows(env.store, "st-1", "blocked") == [] + + +def test_gh_failure_with_unavailable_resnapshot_classifies_nothing( + env, monkeypatch, capsys, +): + """No post-failure snapshot → no classification: the subtask rests at + merge_pending for the next reconcile instead of risking a phantom blocked + over a PR that actually merged.""" + _grant(env.store) + monkeypatch.setattr( + merge, "_gh_merge", lambda pr, cwd, sha, repo=None: (1, "network is down"), + ) + _snapshot_sequence(monkeypatch, env.pr) # second call → None + + assert _run() == merge.EXIT_PR_QUERY_FAILED + assert "cannot be classified" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "merge_pending" + assert _log_rows(env.store, "st-1", "blocked") == [] + assert _log_rows(env.store, "st-1", "needs_rebase") == [] + + +def test_closed_pr_blocks_before_approval(env, capsys): + env.pr["state"] = "CLOSED" + + assert _run() == merge.EXIT_MERGE_FAILED + assert "CLOSED" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "blocked" + assert env.sends == [] # never bothers the approver about a dead PR + assert env.gh_merges == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# Reconcile (crash recovery) + PR-number derivation +# ───────────────────────────────────────────────────────────────────────────── + + +def test_reconcile_already_merged_records_and_exits_zero(env, capsys): + assert _run() == 0 # queue + persist --pr 42; rests awaiting approval + # The sanctioned crash-recovery case: approval was granted (the merge only + # executes post-approval) at the queued SHA, the merge landed, but + # recording crashed. Reconcile now REQUIRES that matching grant [S11-1.2]. + _grant(env.store) # grant at SHA == the merged head + env.pr["state"] = "MERGED" + + # Argument-less re-invocation: PR number read back from the subtask row. + assert handoff.main(["merge", "st-1"]) == 0 + assert "reconciled" in capsys.readouterr().out + assert env.store.get_subtask("st-1", TASK).state == "merged" + assert env.gh_merges == [] # recorded, never re-executed + assert env.store.get_task(TASK).status == "completed" + + +def test_reconcile_not_merged_proceeds_per_approval_state(env): + assert _run() == 0 # queue; request sent; resting + assert len(env.sends) == 1 + + # Still OPEN + unapproved: argument-less re-run keeps resting, no re-send. + assert handoff.main(["merge", "st-1"]) == 0 + assert len(env.sends) == 1 + assert env.store.get_subtask("st-1", TASK).state == "merge_pending" + + # Approved: the same argument-less re-run executes. + _grant(env.store) + assert handoff.main(["merge", "st-1"]) == 0 + assert env.store.get_subtask("st-1", TASK).state == "merged" + assert env.gh_merges == [42] + + +def test_first_invocation_without_pr_number_is_rejected(env, capsys): + assert handoff.main(["merge", "st-1"]) == merge.EXIT_NO_PR_NUMBER + assert "REJECTED [no_pr_number]" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "review_passed" + assert env.sends == [] and env.gh_merges == [] + + +def test_rebind_to_a_different_pr_is_rejected(env, capsys): + """A queued subtask bound to PR A, re-invoked with --pr of an already- + MERGED PR B, must NOT record merged — rebinding would route the reconcile + branch through the wrong PR's state (the phantom-merged path).""" + assert _run() == 0 # binds st-1 to PR 42; rests at merge_pending + env.pr["state"] = "MERGED" # PR 99 (the rebind target) is already merged + + assert handoff.main(["merge", "st-1", "--pr", "99"]) == merge.EXIT_PR_REBIND + err = capsys.readouterr().err + assert "REJECTED [pr_rebind]" in err + assert "already bound to PR #42" in err and "#99" in err + + sub = env.store.get_subtask("st-1", TASK) + assert sub.state == "merge_pending" # NOT merged + assert sub.pr_number == 42 # binding unchanged + assert _log_rows(env.store, "st-1", "merged") == [] + assert env.gh_merges == [] + + +def test_rebind_guard_rejects_from_review_passed_too(env, capsys): + """The guard is state-independent: once bound, only the bound PR is valid.""" + env.store.set_pr_number("st-1", TASK, 41) # bound before any queueing + + assert handoff.main(["merge", "st-1", "--pr", "42"]) == merge.EXIT_PR_REBIND + assert "REJECTED [pr_rebind]" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "review_passed" + assert env.store.get_subtask("st-1", TASK).pr_number == 41 + + +def test_same_pr_reinvocation_is_idempotent(env): + assert _run() == 0 # binds + queues + assert _run() == 0 # same --pr again: proceeds (rests, unapproved) + assert env.store.get_subtask("st-1", TASK).pr_number == 42 + assert env.store.get_subtask("st-1", TASK).state == "merge_pending" + + +def test_invalid_entry_state_is_a_clear_error(env, capsys): + assert handoff.main(["merge", "st-9", "--pr", "44"]) == 1 + assert "not a valid entry state" in capsys.readouterr().err + + +def test_already_merged_subtask_is_noop_not_error(env, capsys): + """A duplicate invocation on an already-merged subtask exits 0 with the + NO-OP [already_merged] marker — not an illegal-transition error that + would invite retries (#fix/noop-merge-leg).""" + _grant(env.store) + assert _run() == 0 # happy path: queues + executes merge + assert env.store.get_subtask("st-1", TASK).state == "merged" + capsys.readouterr() # clear output from first invocation + + # Second invocation on the now-merged subtask. + assert _run() == 0 + out, err = capsys.readouterr() + assert "NO-OP [already_merged]" in out + assert err == "" + assert env.gh_merges == [42] # no re-merge attempt + + +# ───────────────────────────────────────────────────────────────────────────── +# Ungated opt-out: the gate is vacuous, the approval flow is not +# ───────────────────────────────────────────────────────────────────────────── + + +def test_ungated_task_merges_vacuously_but_approval_still_applies( + tmp_path, monkeypatch, +): + s = StateStore(tmp_path / "state" / "orchestration.db") + s.register_task_atomic( + task_id=TASK, description="ungated", room_id=TASK, + owner_id="owner-1", required_verdicts=[], merge_approval="owner", + ) + # No SHA-pinned verdicts at all — the [] snapshot makes the gate vacuous. + _drive_to_review_passed(s, "st-1", verify_sha=None, review_sha=None) + + pr = {"state": "OPEN", "mergeable": "MERGEABLE", "headRefOid": SHA} + gh_merges: list[int] = [] + sends: list[tuple] = [] + monkeypatch.setattr(merge, "_resolve_store", lambda project_dir: s) + monkeypatch.setattr( + merge, "_resolve_task_id", + lambda project_dir, store, task_arg: (TASK, None), + ) + monkeypatch.setattr( + merge, "_pr_snapshot", lambda pr_number, cwd, repo=None: dict(pr), + ) + monkeypatch.setattr( + merge, "_gh_merge", + lambda pr_number, cwd, sha, repo=None: ( + gh_merges.append(pr_number), (0, "ok"), + )[1], + ) + monkeypatch.setattr( + merge, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + agents=SimpleNamespace(max_rebase_rounds=3), + ), + ) + monkeypatch.setattr( + merge, "_send_approval_request", lambda *a: sends.append(a), + ) + monkeypatch.setattr(merge, "_delete_remote_branch", lambda snap, cwd: None) + + # The gated transition succeeds vacuously, but the leg rests on approval. + assert _run() == 0 + assert s.get_subtask("st-1", TASK).state == "merge_pending" + assert len(sends) == 1 + assert gh_merges == [] + + _grant(s) + assert _run() == 0 + assert s.get_subtask("st-1", TASK).state == "merged" + assert gh_merges == [42] + + +# ───────────────────────────────────────────────────────────────────────────── +# cb approve — the durable grant writer +# ───────────────────────────────────────────────────────────────────────────── + + +def test_record_approval_grant_grants_at_requested_sha_only(env): + """The grant is pinned to the SHA the approval REQUEST named — a grant + can only ever exist for a SHA a request named (finding 21).""" + env.store.set_pr_number("st-1", TASK, 42) + env.store.mark_merge_approval_requested("st-1", TASK, SHA) + + lines = merge.record_approval_grant(Path("."), 42) + + assert len(lines) == 1 and "st-1" in lines[0] + sub = env.store.get_subtask("st-1", TASK) + assert sub.merge_approved_sha == SHA # the requested SHA, not "live head" + assert sub.merge_approved_by == "owner" # the task's snapshotted approver + + +def test_record_approval_grant_refuses_when_head_moved_past_request(env, capsys): + """The failed C1 probe as a regression test: request sent at SHA, branch + pushed since — cb approve must REFUSE, not grant at the moved head.""" + env.store.set_pr_number("st-1", TASK, 42) + env.store.mark_merge_approval_requested("st-1", TASK, "sha-old") + # live PR head is SHA ("sha-1") — the request named "sha-old". + + with pytest.raises(RuntimeError, match="the branch moved"): + merge.record_approval_grant(Path("."), 42) + + sub = env.store.get_subtask("st-1", TASK) + assert sub.merge_approved_sha is None # nothing recorded + # untouched marker: the merge leg's re-queue owns the fresh request + assert sub.merge_approval_requested_sha == "sha-old" + + +def test_record_approval_grant_never_grants_without_a_request(env, capsys): + """Bound subtask but no approval request ever sent → no speculative + grant. This kills the second-order path: granted==pending can only be + satisfied by a SHA a request named.""" + env.store.set_pr_number("st-1", TASK, 42) + + assert merge.record_approval_grant(Path("."), 42) == [] + + assert env.store.get_subtask("st-1", TASK).merge_approved_sha is None + err = capsys.readouterr().err + assert "NO durable merge grant was recorded" in err + assert "no approval request has been sent" in err + assert "cb approve 42" in err # tells the human exactly what to re-run + + +def test_record_approval_grant_scopes_to_requesting_rows_only(env, capsys): + """Multi-row PR (campaign Observation C): the grant write targets only + the rows whose request matches the head — never every row referencing + the PR.""" + for sid in ("st-2", "st-3"): + _drive_to_review_passed(env.store, sid) + for sid in ("st-1", "st-2", "st-3"): + env.store.set_pr_number(sid, TASK, 42) + env.store.mark_merge_approval_requested("st-1", TASK, SHA) # matches head + env.store.mark_merge_approval_requested("st-2", TASK, "sha-old") # stale + # st-3 never requested approval at all. + + lines = merge.record_approval_grant(Path("."), 42) + + assert len(lines) == 1 and "st-1" in lines[0] + assert env.store.get_subtask("st-1", TASK).merge_approved_sha == SHA + assert env.store.get_subtask("st-2", TASK).merge_approved_sha is None + assert env.store.get_subtask("st-3", TASK).merge_approved_sha is None + err = capsys.readouterr().err + assert "st-2" in err and "NOT granted" in err # the stale row is named + + +def test_record_approval_grant_pins_repo_via_config_slug(env): + """The grant's PR snapshot carries --repo from config repo.url — + repo identity never depends on what repo the cwd happens to be in.""" + env.store.set_pr_number("st-1", TASK, 42) + env.store.mark_merge_approval_requested("st-1", TASK, SHA) + merge.record_approval_grant(Path("."), 42) + assert env.snapshot_repos == ["acme/widgets"] + + +def test_record_approval_grant_unbound_pr_warns_loud_and_records_nothing( + env, capsys, +): + # Approve-before-binding: nothing binds the PR yet — nothing is recorded, + # and the human is TOLD so (a silent [] looked like success). + assert merge.record_approval_grant(Path("."), 99) == [] + assert env.store.get_subtask("st-1", TASK).merge_approved_sha is None + err = capsys.readouterr().err + assert "NO durable merge grant was recorded" in err + assert "cb approve 99" in err # tells the human exactly what to re-run + + +def test_record_approval_grant_raises_when_no_active_task(env, monkeypatch): + """Task-resolution failure must RAISE — an 'approval' recorded against + nothing must never look like success.""" + monkeypatch.setattr( + merge, "_resolve_task_id", + lambda project_dir, store, task_arg: (None, 6), + ) + with pytest.raises(RuntimeError, match="no active task"): + merge.record_approval_grant(Path("."), 42) + + +def test_record_approval_grant_fails_loud_when_head_unreadable(env, monkeypatch): + env.store.set_pr_number("st-1", TASK, 42) + env.store.mark_merge_approval_requested("st-1", TASK, SHA) + monkeypatch.setattr( + merge, "_pr_snapshot", lambda pr_number, cwd, repo=None: None, + ) + + with pytest.raises(RuntimeError, match="head SHA"): + merge.record_approval_grant(Path("."), 42) + assert env.store.get_subtask("st-1", TASK).merge_approved_sha is None + + +def test_record_approval_grant_fails_loud_on_unresolvable_slug(env, monkeypatch): + env.store.set_pr_number("st-1", TASK, 42) + env.store.mark_merge_approval_requested("st-1", TASK, SHA) + monkeypatch.setattr( + merge, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://gitlab.example.com/g/p.git"), + ), + ) + with pytest.raises(RuntimeError, match="repo slug"): + merge.record_approval_grant(Path("."), 42) + assert env.store.get_subtask("st-1", TASK).merge_approved_sha is None + + +def test_second_order_pre_push_grant_refused_then_fresh_request_still_sent( + env, capsys, +): + """The full second-order scenario: a grant attempted after a push is + refused; the merge leg re-queues; the fresh request still goes out; the + grant then lands at the freshly requested SHA and the merge executes.""" + from codeband.cli import merge as merge_mod + + # Round 1: queue at SHA, approval request sent (marker burns at SHA). + assert _run() == 0 + assert len(env.sends) == 1 and env.sends[0][2] == SHA + + # A push moves the head before the human approves. + env.pr["headRefOid"] = "sha-2" + + # Pre-push grant attempt → refused, nothing recorded. + with pytest.raises(RuntimeError, match="the branch moved"): + merge.record_approval_grant(Path("."), 42) + assert env.store.get_subtask("st-1", TASK).merge_approved_sha is None + + # Re-queue: the merge leg detects the drift and sends the subtask back. + assert _run("st-1") == merge_mod.EXIT_NEEDS_REBASE + + # Rework re-earns both verdicts at the new SHA. + for new_state, role, sha in [ + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-2"), + ("review_passed", "reviewer", "sha-2"), + ]: + transition( + "st-1", TASK, new_state, caller_role=role, + store=env.store, head_sha=sha, + ) + + # Fresh queue at sha-2 → a FRESH request goes out (new marker SHA). + assert _run("st-1") == 0 + assert len(env.sends) == 2 and env.sends[1][2] == "sha-2" + + # The grant now lands, pinned to the requested sha-2 — and the merge runs. + assert len(merge.record_approval_grant(Path("."), 42)) == 1 + assert env.store.get_subtask("st-1", TASK).merge_approved_sha == "sha-2" + assert _run("st-1") == 0 + assert env.store.get_subtask("st-1", TASK).state == "merged" + + +def test_cb_approve_refuses_inside_agent_sessions(tmp_path): + """Finding 18 accident guard: the runner marks every spawned agent + session's env; cb approve refuses before any work (note: no codeband.yaml + exists here — the guard fires before config is even loaded).""" + from click.testing import CliRunner + + from codeband.cli import cli as cb_cli + + result = CliRunner(env={"CODEBAND_AGENT_SESSION": "1"}).invoke( + cb_cli, ["approve", "42", "--dir", str(tmp_path)], + ) + assert result.exit_code != 0 + combined = result.output + result.stderr + assert "human-approval primitive" in combined + assert "merge leg" in combined + + +def test_shell_slash_approve_is_exempt_from_the_agent_guard(tmp_path, monkeypatch): + """The interactive shell's /approve runs inside the orchestrator process + (which sets the marker for its spawned agents); command_style="slash" is + only reachable from the human at the REPL prompt, so it bypasses.""" + import codeband.cli.merge as merge_mod + import codeband.orchestration.kickoff as kickoff_mod + from codeband.cli import approve as approve_cmd + + monkeypatch.setenv("CODEBAND_AGENT_SESSION", "1") + calls: list = [] + # Return a non-empty grant list so the notification path is exercised. + monkeypatch.setattr( + merge_mod, "record_approval_grant", + lambda project_dir, number: calls.append(number) or [ + "Merge approval recorded for subtask st-1 at sha-1 (approver: owner)." + ], + ) + + async def _fake_send(config, project, message, command_style="cli"): + calls.append("sent") + + monkeypatch.setattr(kickoff_mod, "send_room_message", _fake_send) + (tmp_path / "codeband.yaml").write_text( + "repo:\n url: https://github.com/acme/widgets\n", encoding="utf-8", + ) + + approve_cmd.callback( + number=42, project_dir=str(tmp_path), command_style="slash", + ) + + assert calls == [42, "sent"] + + +def test_cb_approve_command_renders_grant_failures_as_clean_errors(tmp_path): + """The approve command wraps the grant half in ClickException: a human + gets the message, not a traceback. No pointer/task exists here, so the + grant half raises 'no active task'.""" + from click.testing import CliRunner + + from codeband.cli import cli as cb_cli + + (tmp_path / "codeband.yaml").write_text( + "repo:\n url: https://github.com/acme/widgets\n", encoding="utf-8", + ) + result = CliRunner().invoke( + cb_cli, ["approve", "42", "--dir", str(tmp_path)], + ) + combined = result.output + result.stderr + assert result.exit_code != 0 + assert "no active task" in combined + assert "Traceback" not in combined + + +# ───────────────────────────────────────────────────────────────────────────── +# merge_approval — registration-time validation + snapshot +# ───────────────────────────────────────────────────────────────────────────── + + +def _agents(**overrides) -> AgentsConfig: + return AgentsConfig(handoff_verify_command="true", **overrides) + + +class TestMergeApprovalValidation: + def test_default_is_owner(self): + assert DEFAULT_MERGE_APPROVAL == "owner" + assert resolve_merge_approval(_agents()) == "owner" + + def test_human_handle_accepted(self): + assert resolve_merge_approval(_agents(merge_approval="human:yoni")) == "human:yoni" + + def test_none_is_reserved_and_rejected(self): + with pytest.raises(ValueError, match="not supported in V1"): + resolve_merge_approval(_agents(merge_approval="none")) + + def test_unknown_value_rejected(self): + with pytest.raises(ValueError, match="unknown merge_approval"): + resolve_merge_approval(_agents(merge_approval="banana")) + + def test_empty_human_handle_rejected(self): + with pytest.raises(ValueError, match="names no"): + resolve_merge_approval(_agents(merge_approval="human:")) + + def test_registration_snapshots_approver(self, tmp_path): + store = StateStore(tmp_path / "state" / "orchestration.db") + register_task( + room_id="room-7", description="d", owner_id="owner-1", + agents=_agents(merge_approval="human:yoni"), + project_dir=tmp_path, store=store, + ) + assert store.get_task("room-7").merge_approval == "human:yoni" + + def test_bad_approver_fails_registration_and_writes_nothing(self, tmp_path): + store = StateStore(tmp_path / "state" / "orchestration.db") + with pytest.raises(ValueError, match="not supported in V1"): + register_task( + room_id="room-7", description="d", owner_id="owner-1", + agents=_agents(merge_approval="none"), + project_dir=tmp_path, store=store, + ) + assert store.get_task("room-7") is None + assert not (tmp_path / ".codeband_room").exists() + + def test_reregistration_refreshes_snapshot(self, tmp_path): + store = StateStore(tmp_path / "state" / "orchestration.db") + register_task( + room_id="room-7", description="d", owner_id="owner-1", + agents=_agents(), project_dir=tmp_path, store=store, + ) + assert store.get_task("room-7").merge_approval == "owner" + register_task( + room_id="room-7", description="d", owner_id="owner-1", + agents=_agents(merge_approval="human:yoni"), + project_dir=tmp_path, store=store, + ) + assert store.get_task("room-7").merge_approval == "human:yoni" + + +# ───────────────────────────────────────────────────────────────────────────── +# Subprocess plumbing + exit-code contract +# ───────────────────────────────────────────────────────────────────────────── + + +def test_pr_snapshot_invokes_gh_with_one_combined_query(monkeypatch, tmp_path): + calls = {} + + def _fake_run(cmd, **kwargs): + calls["cmd"] = cmd + calls["cwd"] = kwargs.get("cwd") + return subprocess.CompletedProcess( + cmd, 0, + stdout='{"state": "OPEN", "mergeable": "MERGEABLE", ' + '"headRefOid": "sha-1"}', + stderr="", + ) + + monkeypatch.setattr(merge.subprocess, "run", _fake_run) + snap = merge._pr_snapshot(42, tmp_path) + assert calls["cmd"] == [ + "gh", "pr", "view", "42", + "--json", "state,mergeable,headRefOid,headRefName", + ] + assert calls["cwd"] == str(tmp_path) + assert snap == {"state": "OPEN", "mergeable": "MERGEABLE", "headRefOid": "sha-1"} + + +def test_gh_merge_pins_head_commit_and_never_deletes_local(monkeypatch, tmp_path): + calls = {} + + def _fake_run(cmd, **kwargs): + calls["cmd"] = cmd + calls["cwd"] = kwargs.get("cwd") + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + monkeypatch.setattr(merge.subprocess, "run", _fake_run) + code, output = merge._gh_merge(42, tmp_path, "sha-1") + assert calls["cmd"] == [ + "gh", "pr", "merge", "42", "--merge", "--match-head-commit", "sha-1", + ] + assert calls["cwd"] == str(tmp_path) + assert (code, output) == (0, "ok") + # Local branches belong to coder worktrees — never deleted by the leg. + assert "--delete-branch" not in calls["cmd"] + + +def test_gh_merge_omits_head_pin_for_null_pending_sha(monkeypatch, tmp_path): + calls = {} + + def _fake_run(cmd, **kwargs): + calls["cmd"] = cmd + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + monkeypatch.setattr(merge.subprocess, "run", _fake_run) + merge._gh_merge(42, tmp_path, None) + assert calls["cmd"] == ["gh", "pr", "merge", "42", "--merge"] + assert "--match-head-commit" not in calls["cmd"] + assert "--delete-branch" not in calls["cmd"] + + +def test_delete_remote_branch_is_remote_only(monkeypatch, tmp_path): + calls = {} + + def _fake_run(cmd, **kwargs): + calls["cmd"] = cmd + calls["cwd"] = kwargs.get("cwd") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(merge.subprocess, "run", _fake_run) + merge._delete_remote_branch({"headRefName": "feat-x"}, tmp_path) + # Remote-only: a push --delete, never `git branch -d/-D`. + assert calls["cmd"] == ["git", "push", "origin", "--delete", "feat-x"] + assert calls["cwd"] == str(tmp_path) + + +def test_delete_remote_branch_failure_is_warning_only(monkeypatch, tmp_path, capsys): + monkeypatch.setattr( + merge.subprocess, "run", + lambda cmd, **kw: subprocess.CompletedProcess( + cmd, 1, stdout="", stderr="remote ref does not exist", + ), + ) + # Never raises, never returns a failure — a warning is the whole effect. + assert merge._delete_remote_branch({"headRefName": "feat-x"}, tmp_path) is None + assert "warning" in capsys.readouterr().err + + +def test_delete_remote_branch_tolerates_missing_branch_name(tmp_path, capsys): + assert merge._delete_remote_branch({}, tmp_path) is None + assert merge._delete_remote_branch(None, tmp_path) is None + assert "skipping remote branch cleanup" in capsys.readouterr().err + + +def test_exit_codes_distinct_across_both_legs(): + codes = { + handoff.EXIT_DIRTY_TREE, + handoff.EXIT_NO_PR, + handoff.EXIT_VERIFY_FAILED, + handoff.EXIT_CAP_REACHED, + handoff.EXIT_NO_ACTIVE_TASK, + merge.EXIT_NO_PR_NUMBER, + merge.EXIT_PR_QUERY_FAILED, + merge.EXIT_NOT_ELIGIBLE, + merge.EXIT_NEEDS_REBASE, + merge.EXIT_MERGE_FAILED, + merge.EXIT_PR_REBIND, + } + assert len(codes) == 11 # all distinct + assert 0 not in codes # never collide with success + + +def test_pr_query_failure_is_fail_closed(env, monkeypatch, capsys): + monkeypatch.setattr( + merge, "_pr_snapshot", lambda pr_number, cwd, repo=None: None, + ) + + assert _run() == merge.EXIT_PR_QUERY_FAILED + assert "REJECTED [pr_query_failed]" in capsys.readouterr().err + assert env.store.get_subtask("st-1", TASK).state == "review_passed" + assert env.sends == [] and env.gh_merges == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# Rebase-round cap (S2-1) + + +def _rework_to_review_passed(store, sid="st-1", sha=SHA): + """Walk a needs_rebase subtask back to review_passed (legal edges only).""" + for new_state, role, head in [ + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", sha), + ("review_passed", "reviewer", sha), + ]: + transition(sid, TASK, new_state, caller_role=role, store=store, + head_sha=head) + + +def test_rebase_loop_hits_cap_and_blocks(env, capsys): + """An active rebase loop is bounded: the send-back past the cap escalates + to blocked with the BLOCKED [rebase_cap_reached] tag instead of another + needs_rebase round. The cap is the env-stubbed agents.max_rebase_rounds=3. + """ + _grant(env.store) + env.pr["mergeable"] = "CONFLICTING" # every attempt classifies needs_rebase + + for expected_round in (1, 2, 3): + assert _run() == merge.EXIT_NEEDS_REBASE + sub = env.store.get_subtask("st-1", TASK) + assert sub.state == "needs_rebase" + assert sub.rebase_rounds == expected_round + _rework_to_review_passed(env.store) + + # Round 4: at the cap — escalates instead of sending back again. + assert _run() == merge.EXIT_REBASE_CAP_REACHED + err = capsys.readouterr().err + assert "BLOCKED [rebase_cap_reached]" in err + sub = env.store.get_subtask("st-1", TASK) + assert sub.state == "blocked" + assert sub.rebase_rounds == 3 # the blocked escalation is not a round + blocked = _log_rows(env.store, "st-1", "blocked") + assert len(blocked) == 1 + assert "rebase-round cap 3 reached" in blocked[0]["reason"] + + +def test_rebase_round_counter_survives_restart(env): + """The cap holds across a crash/reopen: a fresh StateStore on the same DB + reads the committed rebase_rounds, so a restarted merge leg still blocks.""" + _grant(env.store) + env.pr["mergeable"] = "CONFLICTING" + + assert _run() == merge.EXIT_NEEDS_REBASE + reopened = StateStore(env.store.db_path) + assert reopened.get_subtask("st-1", TASK).rebase_rounds == 1 + + +def test_sha_moved_send_back_also_counts_toward_cap(env, capsys): + """The execution-queue SHA re-check routes through the same capped helper.""" + _grant(env.store) + + # Queue at SHA, then move the head: sha_moved → needs_rebase round 1. + transition("st-1", TASK, "merge_pending", caller_role="mergemaster", + store=env.store, head_sha=SHA) + env.pr["headRefOid"] = "sha-2" + assert _run() == merge.EXIT_NEEDS_REBASE + assert env.store.get_subtask("st-1", TASK).rebase_rounds == 1 + + +# ───────────────────────────────────────────────────────────────────────────── +# needs_rebase no-op safety (#fix/needs-rebase-noop) +# ───────────────────────────────────────────────────────────────────────────── + + +def test_needs_rebase_already_applied_is_noop_not_error(store, monkeypatch, capsys): + """An already-applied needs_rebase transition converges as a no-op — not an + uncaught NoOpTransitionError (#fix/needs-rebase-noop). Mirrors the pattern + established by #90 for the merge leg.""" + import codeband.state.fsm as fsm_module + from types import SimpleNamespace + + def _raise_noop(*a, **kw): + raise NoOpTransitionError( + f"NO-OP [already_needs_rebase] st-1@{SHA} " + "(state: review_passed); nothing to do" + ) + + monkeypatch.setattr(fsm_module, "transition", _raise_noop) + monkeypatch.setattr( + merge, "load_config", + lambda p: SimpleNamespace(agents=SimpleNamespace(max_rebase_rounds=3)), + ) + + result = merge._needs_rebase_or_blocked( + "st-1", TASK, "conflict at execution time", + store=store, project_dir=Path("/fake"), + ) + assert result is None + out, err = capsys.readouterr() + assert "NO-OP [already_needs_rebase]" in out + assert err == "" diff --git a/tests/test_peer_governance.py b/tests/test_peer_governance.py new file mode 100644 index 0000000..c27574c --- /dev/null +++ b/tests/test_peer_governance.py @@ -0,0 +1,194 @@ +"""Tests for Stage-3 peer governance (PR3). + +The reconcile-requires-grant branch of ``cb-phase merge`` (3a), and the prompt +contracts for the universal scope rule (3b) and the Conductor's verify-claims +duty (3c). +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from codeband.cli import handoff, merge +from codeband.cli.merge import EXIT_MERGE_FAILED +from codeband.state.fsm import transition +from codeband.state.store import StateStore + +TASK = "room-1" +SHA = "sha-merged" + + +def _drive_to_merge_pending(store, sid="st-1", *, head=SHA): + for new_state, role, sha in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", head), + ("review_passed", "reviewer", head), + ("merge_pending", "mergemaster", head), + ]: + transition(sid, TASK, new_state, caller_role=role, store=store, head_sha=sha) + + +@pytest.fixture +def store(tmp_path) -> StateStore: + s = StateStore(tmp_path / "state" / "orchestration.db") + s.register_task_atomic( + task_id=TASK, description="demo", room_id=TASK, + owner_id="owner-1", owner_handle="yoni", + required_verdicts=["verify", "review"], merge_approval="owner", + ) + _drive_to_merge_pending(s, "st-1") + s.set_pr_number("st-1", TASK, 42) + return s + + +@pytest.fixture +def env(monkeypatch, store): + """Reconcile env: PR already MERGED, plus an audit-hook capture.""" + pr = {"state": "MERGED", "mergeable": "MERGEABLE", "headRefOid": SHA, + "headRefName": "feat-x"} + audit_calls: list[tuple] = [] + + monkeypatch.setattr(merge, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + merge, "_resolve_task_id", + lambda project_dir, store, task_arg: (TASK, None), + ) + monkeypatch.setattr(merge, "_pr_snapshot", lambda *a, **k: dict(pr)) + monkeypatch.setattr( + merge, "load_config", + lambda project_dir: SimpleNamespace( + repo=SimpleNamespace(url="https://github.com/acme/widgets.git"), + agents=SimpleNamespace(max_rebase_rounds=3), + ), + ) + monkeypatch.setattr(merge, "_delete_remote_branch", lambda *a, **k: None) + + # Capture the ungated_external_merge audit hook (the append_audit_event + # primitive ships in PR1; we inject a fake so the hook wiring is testable + # on this branch and after rebase alike). + def _fake_audit(event_type, *, task_id=None, subtask_id=None, payload=None): + audit_calls.append((event_type, task_id, subtask_id, payload)) + + store.append_audit_event = _fake_audit # type: ignore[attr-defined] + return SimpleNamespace(store=store, pr=pr, audit_calls=audit_calls) + + +def _run(): + return handoff.main(["merge", "st-1"]) + + +# ── 3a: reconcile requires a grant ─────────────────────────────────────────── + +def test_reconcile_grant_match_records_merged(env): + """Our own merge raced/crashed: a grant matching the merged head → merged.""" + env.store.record_merge_approval("st-1", TASK, approved_by="owner", approved_sha=SHA) + + assert _run() == 0 + assert env.store.get_subtask("st-1", TASK).state == "merged" + # No ungated event for the sanctioned path. + assert env.audit_calls == [] + + +def test_reconcile_grant_absent_blocks_ungated(env, capsys): + """No grant at all → blocked [ungated_external_merge] + audit event.""" + assert _run() == EXIT_MERGE_FAILED + assert env.store.get_subtask("st-1", TASK).state == "blocked" + err = capsys.readouterr().err + assert "[ungated_external_merge]" in err + assert SHA in err + # The audit hook fired with the merged sha + absent grant. + assert len(env.audit_calls) == 1 + event_type, _t, sid, payload = env.audit_calls[0] + assert event_type == "ungated_external_merge" + assert sid == "st-1" + assert payload["merged_sha"] == SHA + assert payload["grant_sha"] is None + + +def test_reconcile_grant_mismatch_blocks_ungated(env, capsys): + """A grant for a DIFFERENT sha than the merged head → blocked + audit.""" + env.store.record_merge_approval( + "st-1", TASK, approved_by="owner", approved_sha="stale-sha", + ) + + assert _run() == EXIT_MERGE_FAILED + assert env.store.get_subtask("st-1", TASK).state == "blocked" + err = capsys.readouterr().err + assert "[ungated_external_merge]" in err + assert len(env.audit_calls) == 1 + _e, _t, _s, payload = env.audit_calls[0] + assert payload["grant_sha"] == "stale-sha" + assert payload["merged_sha"] == SHA + + +def test_ungated_blocked_records_reason_for_watchdog(env): + """The blocked transition carries the ungated reason (watchdog reads it).""" + import sqlite3 + + _run() + conn = sqlite3.connect(env.store.db_path) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT reason FROM transition_log WHERE subtask_id = 'st-1' " + "AND to_state = 'blocked' ORDER BY id DESC LIMIT 1" + ).fetchone() + finally: + conn.close() + assert "ungated_external_merge" in row["reason"] + + +# ── 3b: scope rule in EVERY role prompt ────────────────────────────────────── + +_ROLE_PROMPTS = [ + "code_reviewer", "coder", "conductor", "mergemaster", + "plan_reviewer", "planner", +] + +_SCOPE_SENTENCE = ( + "Operate only on the PR, branch, and worktree assigned by your current task." +) + + +@pytest.mark.parametrize("role", _ROLE_PROMPTS) +def test_scope_rule_present_in_every_role_prompt(role): + text = Path(f"src/codeband/prompts/{role}.md").read_text(encoding="utf-8") + assert _SCOPE_SENTENCE in text + assert "REPORT it in the room instead of acting" in text + + +# ── 3c: conductor verify-claims duty ───────────────────────────────────────── + +def test_conductor_prompt_pins_verify_claims_duty(): + text = Path("src/codeband/prompts/conductor.md").read_text(encoding="utf-8") + assert "Verify claims before acting" in text + assert "cb status" in text + assert "treated as **not having happened**" in text + # Names the protocol effects it must verify. + for effect in ("merged", "abandoned", "approved", "blocked", "resumed"): + assert effect in text + + +# ── 3d: conductor acceptance-verification dispatch carries acceptance criteria ─ + +def test_conductor_acceptance_dispatch_includes_acceptance_criteria(): + """The Acceptance Verification Protocol dispatch must include the task's + acceptance criteria so the Verifier has the contract to check against.""" + text = Path("src/codeband/prompts/conductor.md").read_text(encoding="utf-8") + # The dispatch line must name all five fields — PR URL, subtask, task key, + # branch, and acceptance criteria. + assert "acceptance criteria" in text + # Specifically within the Acceptance Verification Protocol section. + avp_start = text.index("### Acceptance Verification Protocol") + avp_end = text.index("\n###", avp_start + 1) + avp_text = text[avp_start:avp_end] + assert "acceptance criteria" in avp_text + assert "PR URL" in avp_text or "PR url" in avp_text.lower() + assert "subtask" in avp_text + assert "task key" in avp_text + assert "branch" in avp_text diff --git a/tests/test_pin_detection_agreement.py b/tests/test_pin_detection_agreement.py new file mode 100644 index 0000000..6ab2cba --- /dev/null +++ b/tests/test_pin_detection_agreement.py @@ -0,0 +1,206 @@ +"""Drift-guard: pin-detection criteria in _detect_room_pin (#86 discriminator) +and _check_one_agent_room_pins (#85 transport heal) must agree. + +Both carry parallel implementations of the same two criteria: + 1. any ``processing`` record with inserted_at older than threshold + 2. the ``pending`` HEAD (data[0]) with inserted_at older than threshold + +Without a guard, a future edit to one path can silently diverge from the other, +reintroducing the false-block class of bug #86 was built to prevent. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.config import WatchdogConfig + +_THRESHOLD = timedelta(seconds=600) +_ROOM_ID = "room-agree" +_AGENT_ID = "agent-agree" + +# Age constants relative to threshold (T = 600s). +_PINNED_AGE = 900 # > T → should be detected as a pin +_FRESH_AGE = 60 # < T → should NOT be detected as a pin + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _msg(msg_id: str, age_seconds: int) -> MagicMock: + m = MagicMock() + m.id = msg_id + m.inserted_at = datetime.now(UTC) - timedelta(seconds=age_seconds) + return m + + +def _client( + *, + processing: list | None = None, + pending: list | None = None, +) -> MagicMock: + """REST client returning the given processing/pending message lists.""" + client = MagicMock() + client.agent_api_messages = MagicMock() + + async def _list(*_args: object, **kwargs: object) -> MagicMock: + status = kwargs.get("status", "") + resp = MagicMock() + resp.data = (processing or []) if status == "processing" else (pending or []) + return resp + + client.agent_api_messages.list_agent_messages = AsyncMock(side_effect=_list) + client.agent_api_messages.mark_agent_message_processed = AsyncMock() + client.agent_api_messages.mark_agent_message_processing = AsyncMock() + return client + + +def _daemon() -> object: + from codeband.agents.watchdog import WatchdogDaemon + + rest = MagicMock() + rest.agent_api_messages = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + return WatchdogDaemon( + config=WatchdogConfig(transport_pin_threshold_seconds=600), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-wd", + ) + + +# ── shared fixture cases ────────────────────────────────────────────────────── +# Each tuple: (label, processing_msgs, pending_msgs, expected_pin) +# «expected_pin» is the ground-truth each path must agree on. + +_CASES: list[tuple[str, list, list, bool]] = [ + ( + "empty_mailbox", + [], [], + False, + ), + ( + "processing_older_than_threshold", + [_msg("proc-old", _PINNED_AGE)], [], + True, + ), + ( + "processing_younger_than_threshold", + [_msg("proc-fresh", _FRESH_AGE)], [], + False, + ), + ( + "pending_head_older_than_threshold", + [], [_msg("pend-old", _PINNED_AGE)], + True, + ), + ( + "pending_head_younger_than_threshold", + [], [_msg("pend-fresh", _FRESH_AGE)], + False, + ), + ( + "pending_head_fresh_non_head_old", + # data[0] is fresh; only the HEAD (data[0]) governs the cursor. + [], [_msg("pend-head-fresh", _FRESH_AGE), _msg("pend-body-old", _PINNED_AGE)], + False, + ), + ( + "both_buckets_old", + [_msg("proc-old2", _PINNED_AGE)], [_msg("pend-old2", _PINNED_AGE)], + True, + ), +] + +_PARAMS = [(c[0], c[1], c[2], c[3]) for c in _CASES] + + +# ── per-path baseline tests (document expected behaviour for each path) ─────── + +@pytest.mark.parametrize("label,processing,pending,expected_pin", _PARAMS) +@pytest.mark.asyncio +async def test_detect_room_pin_baseline( + label: str, + processing: list, + pending: list, + expected_pin: bool, +) -> None: + """#86 discriminator probe (_detect_room_pin) classifies each fixture correctly.""" + daemon = _daemon() + client = _client(processing=processing, pending=pending) + + result = await daemon._detect_room_pin( + _AGENT_ID, _ROOM_ID, client, _THRESHOLD, datetime.now(UTC), + ) + + assert result is expected_pin, ( + f"[{label}] _detect_room_pin={result!r}, expected={expected_pin!r}" + ) + + +@pytest.mark.parametrize("label,processing,pending,expected_pin", _PARAMS) +@pytest.mark.asyncio +async def test_check_one_agent_room_pins_baseline( + label: str, + processing: list, + pending: list, + expected_pin: bool, +) -> None: + """#85 heal rung (_check_one_agent_room_pins) attempts healing iff a pin is expected.""" + daemon = _daemon() + client = _client(processing=processing, pending=pending) + + await daemon._check_one_agent_room_pins( + _AGENT_ID, _ROOM_ID, client, _THRESHOLD, datetime.now(UTC), + ) + + heal_attempted = ( + client.agent_api_messages.mark_agent_message_processed.await_count > 0 + or client.agent_api_messages.mark_agent_message_processing.await_count > 0 + ) + assert heal_attempted is expected_pin, ( + f"[{label}] heal_attempted={heal_attempted!r}, expected={expected_pin!r}" + ) + + +# ── agreement (drift-guard) test ────────────────────────────────────────────── + +@pytest.mark.parametrize("label,processing,pending,_expected", _PARAMS) +@pytest.mark.asyncio +async def test_both_paths_agree_on_same_mailbox( + label: str, + processing: list, + pending: list, + _expected: bool, +) -> None: + """Core drift guard: _detect_room_pin and _check_one_agent_room_pins must agree. + + Both paths are driven from the same mailbox state. If this test fails, one + path's pin criteria drifted from the other — the discriminator and heal rung + no longer share the same definition of "pinned". + """ + daemon = _daemon() + now = datetime.now(UTC) + + detect_client = _client(processing=processing, pending=pending) + heal_client = _client(processing=processing, pending=pending) + + detected = await daemon._detect_room_pin( + _AGENT_ID, _ROOM_ID, detect_client, _THRESHOLD, now, + ) + + await daemon._check_one_agent_room_pins( + _AGENT_ID, _ROOM_ID, heal_client, _THRESHOLD, now, + ) + heal_attempted = ( + heal_client.agent_api_messages.mark_agent_message_processed.await_count > 0 + or heal_client.agent_api_messages.mark_agent_message_processing.await_count > 0 + ) + + assert detected is heal_attempted, ( + f"[{label}] pin-criteria divergence: " + f"_detect_room_pin={detected!r} but heal_attempted={heal_attempted!r}. " + "A future edit caused the #86 discriminator and #85 heal rung to disagree." + ) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 72b5905..434e7f0 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -316,6 +316,24 @@ async def test_usage_limit_retries_with_openai_api_key_fallback(self, monkeypatc assert os.environ["OPENAI_API_KEY"] == "sk-openai-fallback" assert "CODEBAND_FALLBACK_OPENAI_API_KEY" not in os.environ + @pytest.mark.asyncio + async def test_stripped_api_key_mode_failure_retries_with_fallback(self, monkeypatch): + from codeband.preflight import check_codex_auth + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("CODEBAND_FALLBACK_OPENAI_API_KEY", "sk-openai-fallback") + + with patch( + "codeband.preflight._run_codex_probe", + AsyncMock(side_effect=[(1, "Error: not logged in."), (0, "ok")]), + ) as mock_probe: + err = await check_codex_auth() + + assert err is None + assert mock_probe.await_count == 2 + assert os.environ["OPENAI_API_KEY"] == "sk-openai-fallback" + assert "CODEBAND_FALLBACK_OPENAI_API_KEY" not in os.environ + @pytest.mark.asyncio async def test_detects_invalid_api_key(self): from codeband.preflight import check_codex_auth @@ -371,6 +389,9 @@ def claude_only_config(self): "plan_reviewers": {"claude_sdk": {"count": 1}, "codex": {"count": 0}}, "coders": {"claude_sdk": {"count": 1}, "codex": {"count": 0}}, "reviewers": {"claude_sdk": {"count": 1}, "codex": {"count": 0}}, + # Verifiers pinned inert so this stays genuinely Codex-free + # (the active default would add a Codex verifier). + "verifiers": {"claude_sdk": {"count": 0}, "codex": {"count": 0}}, }, }) diff --git a/tests/test_project_dir_resolution.py b/tests/test_project_dir_resolution.py new file mode 100644 index 0000000..d4172ae --- /dev/null +++ b/tests/test_project_dir_resolution.py @@ -0,0 +1,210 @@ +"""Tests for project-dir context resolution (Batch 2a, PR A). + +Covers the ONE shared resolution helper (``cli/handoff.py: +resolve_project_dir`` — explicit flag > ``$CODEBAND_PROJECT_DIR`` > cwd), +the runner seam that exports the env var into every spawned agent session, +and ``cb-phase``'s traceback-free top-level error handling for config/IO +failures (missing / empty / malformed ``codeband.yaml``). +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from codeband.cli import handoff +from codeband.state.store import StateStore + +ROOM = "room-uuid-1" + + +# ── resolution-order matrix ───────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """No ambient CODEBAND_PROJECT_DIR may leak into these tests.""" + monkeypatch.delenv("CODEBAND_PROJECT_DIR", raising=False) + + +def test_explicit_flag_beats_env(monkeypatch, tmp_path): + flag_dir = tmp_path / "from-flag" + env_dir = tmp_path / "from-env" + monkeypatch.setenv("CODEBAND_PROJECT_DIR", str(env_dir)) + assert handoff.resolve_project_dir(str(flag_dir)) == flag_dir.resolve() + + +def test_env_beats_cwd(monkeypatch, tmp_path): + env_dir = tmp_path / "from-env" + monkeypatch.setenv("CODEBAND_PROJECT_DIR", str(env_dir)) + monkeypatch.chdir(tmp_path) + assert handoff.resolve_project_dir(".") == env_dir.resolve() + + +def test_cwd_is_the_last_resort(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + assert handoff.resolve_project_dir(".") == tmp_path.resolve() + + +def test_empty_env_var_falls_back_to_cwd(monkeypatch, tmp_path): + monkeypatch.setenv("CODEBAND_PROJECT_DIR", "") + monkeypatch.chdir(tmp_path) + assert handoff.resolve_project_dir(".") == tmp_path.resolve() + + +def test_default_flag_value_is_cwd(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + assert handoff.resolve_project_dir() == tmp_path.resolve() + + +# ── env-var path works from a non-project cwd (integration) ──────────────── + + +def _make_project(tmp_path: Path) -> Path: + """A real project dir: codeband.yaml + seeded store + active-room pointer.""" + project = tmp_path / "project" + project.mkdir() + (project / "codeband.yaml").write_text( + "repo:\n url: https://github.com/acme/widgets\n", encoding="utf-8", + ) + store = StateStore(project / ".codeband" / "state" / "orchestration.db") + store.create_task(task_id=ROOM, description="demo", room_id=ROOM) + (project / ".codeband_room").write_text(ROOM, encoding="utf-8") + return project + + +def test_cb_phase_start_resolves_project_via_env_from_foreign_cwd( + monkeypatch, tmp_path, +): + """``cb-phase start`` from a random cwd (a worktree, a container, an + agent scratch dir) must find config/store/pointer via the env var — + the exact cwd-roulette this batch closes.""" + project = _make_project(tmp_path) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("CODEBAND_PROJECT_DIR", str(project)) + + assert handoff.main(["start", "st-1"]) == 0 + + store = StateStore(project / ".codeband" / "state" / "orchestration.db") + assert store.get_subtask("st-1", ROOM).state == "in_progress" + + +def test_cb_phase_start_without_env_fails_from_foreign_cwd( + monkeypatch, tmp_path, capsys, +): + """Sanity inverse: with no env var the historical cwd behavior remains, + and from a foreign cwd that is a clean, tagged failure (no traceback).""" + _make_project(tmp_path) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + + assert handoff.main(["start", "st-1"]) == 1 + err = capsys.readouterr().err + assert err.startswith("cb-phase:") + assert "codeband.yaml not found" in err + assert "Traceback" not in err + + +def test_explicit_project_dir_flag_beats_env_in_cb_phase(monkeypatch, tmp_path): + """The flag wins over a (stale/wrong) env var pointing elsewhere.""" + project = _make_project(tmp_path) + monkeypatch.setenv("CODEBAND_PROJECT_DIR", str(tmp_path / "nowhere")) + monkeypatch.chdir(tmp_path) + + assert handoff.main(["start", "st-1", "--project-dir", str(project)]) == 0 + + +# ── runner seam: spawned sessions inherit the var ─────────────────────────── + + +def test_runner_exports_project_dir_into_process_env(monkeypatch, tmp_path): + """The runner's export seam: agent CLI sessions are subprocesses of this + process, so the exported variable is exactly 'the env of every spawned + coder/reviewer/mergemaster session'.""" + from codeband.orchestration.runner import _export_project_dir_env + + # setenv registers the teardown that undoes the direct os.environ writes + # _export_project_dir_env performs below. + monkeypatch.setenv("CODEBAND_PROJECT_DIR", "stale-value") + monkeypatch.setenv("CODEBAND_AGENT_SESSION", "stale-value") + _export_project_dir_env(tmp_path) + assert os.environ["CODEBAND_PROJECT_DIR"] == str(tmp_path.resolve()) + # The export FORCES the resolved dir — a stale ambient value never wins. + _export_project_dir_env(tmp_path / "other") + assert os.environ["CODEBAND_PROJECT_DIR"] == str((tmp_path / "other").resolve()) + # The agent-session marker rides the same seam (cb approve's accident + # guard, finding 18): every spawned agent session inherits it. + assert os.environ["CODEBAND_AGENT_SESSION"] == "1" + + +def test_record_approval_grant_resolves_project_dir_via_env(monkeypatch, tmp_path): + """``cb approve``'s grant half follows the same contract: a raw default + '.' flag value resolves through the env var, not the cwd.""" + from codeband.cli import merge + + project = _make_project(tmp_path) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("CODEBAND_PROJECT_DIR", str(project)) + + # No subtask is bound to PR 99 — the legacy no-grant path returns []. + # The point is that it RESOLVED the project (config + store + pointer) + # from the env var; an unresolved project dir would raise instead. + assert merge.record_approval_grant(".", 99) == [] + + +# ── cb-phase top-level error handling [F7-4] ──────────────────────────────── + + +def test_missing_config_is_tagged_and_traceback_free(tmp_path, capsys): + empty = tmp_path / "empty" + empty.mkdir() + code = handoff.main(["start", "st-1", "--project-dir", str(empty)]) + err = capsys.readouterr().err + assert code == 1 + assert err.startswith("cb-phase: ") + assert "codeband.yaml not found" in err + assert "Traceback" not in err + + +def test_empty_config_reports_missing_repo_field(tmp_path, capsys): + """[F7-10] A zero-byte codeband.yaml must yield the actionable + 'repo: Field required', tagged and traceback-free.""" + project = tmp_path / "project" + project.mkdir() + (project / "codeband.yaml").write_text("", encoding="utf-8") + code = handoff.main(["start", "st-1", "--project-dir", str(project)]) + err = capsys.readouterr().err + assert code == 1 + assert err.startswith("cb-phase: fatal — ValidationError") + assert "repo" in err + assert "Field required" in err + assert "Traceback" not in err + + +def test_malformed_config_is_tagged_fatal(tmp_path, capsys): + project = tmp_path / "project" + project.mkdir() + (project / "codeband.yaml").write_text("repo: 5\n", encoding="utf-8") + code = handoff.main(["start", "st-1", "--project-dir", str(project)]) + err = capsys.readouterr().err + assert code == 1 + assert err.startswith("cb-phase: fatal — ValidationError") + assert "Traceback" not in err + + +def test_unreadable_yaml_is_tagged_fatal(tmp_path, capsys): + project = tmp_path / "project" + project.mkdir() + (project / "codeband.yaml").write_text("repo: [unclosed\n", encoding="utf-8") + code = handoff.main(["start", "st-1", "--project-dir", str(project)]) + err = capsys.readouterr().err + assert code == 1 + assert err.startswith("cb-phase: fatal — ") + assert "Traceback" not in err diff --git a/tests/test_prompt_invariants.py b/tests/test_prompt_invariants.py index be50138..bd1f648 100644 --- a/tests/test_prompt_invariants.py +++ b/tests/test_prompt_invariants.py @@ -161,3 +161,32 @@ def test_roster_includes_worker_column_and_display_names(self): assert "Coder-Claude-0, Coder-Claude-1" in roster assert "Reviewer-Codex-0, Reviewer-Codex-1" in roster assert "Planner-Claude-0" in roster + + +class TestKnowledgeInjection: + """Knowledge files must be loadable and injected into built prompts.""" + + def test_load_knowledge_returns_nonempty(self): + from codeband.agents.prompts import load_knowledge + + result = load_knowledge("coding-standards") + assert result + assert "# Engineering Knowledge Base" in result + + def test_load_knowledge_empty_args(self): + from codeband.agents.prompts import load_knowledge + + assert load_knowledge() == "" + + def test_built_coder_prompt_contains_knowledge_header(self): + from codeband.agents.player_claude import ClaudePlayerRunner + + runner = ClaudePlayerRunner() + assert "# Engineering Knowledge Base" in runner.adapter.custom_section + + def test_knowledge_files_exist_in_package(self): + from codeband.agents.prompts import _KNOWLEDGE_DIR + + assert (_KNOWLEDGE_DIR / "coding-standards.md").is_file() + assert (_KNOWLEDGE_DIR / "testing.md").is_file() + assert (_KNOWLEDGE_DIR / "security.md").is_file() diff --git a/tests/test_prompt_role_consistency.py b/tests/test_prompt_role_consistency.py index 5d9fffb..39d69e2 100644 --- a/tests/test_prompt_role_consistency.py +++ b/tests/test_prompt_role_consistency.py @@ -81,10 +81,7 @@ def test_coder_dispatches_review_directly_to_opposite_framework_reviewer(): "Coder prompt still routes through the Conductor — the relay was " "supposed to be removed in Bug 4." ) - assert ( - "@mentioning **both an opposite-framework Code Reviewer and @Conductor**" - in coder - ) + assert "@mention @Conductor for awareness" in coder # Coder picks the reviewer themselves via peer discovery — not via a # Conductor relay, and not from a static hard-coded roster (lazy invites). # Discovery filters on `description` (the semantic field), not on a name @@ -97,7 +94,7 @@ def test_coder_dispatches_review_directly_to_opposite_framework_reviewer(): # Code Reviewer side: expects direct dispatch from the Coder and direct # failure reporting back to the PR owner. - assert "A Coder @mentions you directly at PR completion" in code_reviewer + assert "A Coder @mentions you directly once their PR has passed verification" in code_reviewer assert "@mention **both the PR-owning Coder and @Conductor**" in code_reviewer assert ( "Codeband task branches have the form `codeband//`" @@ -105,7 +102,7 @@ def test_coder_dispatches_review_directly_to_opposite_framework_reviewer(): ) # Coder side: after fixes, go back to the same reviewer, not via a generic relay. - assert "both the same Reviewer and @Conductor" in coder + assert "@mention **the same Reviewer and @Conductor**" in coder assert "Use the Reviewer who failed the PR" in coder # Conductor side: stays silent whenever the direct path already reached the @@ -317,3 +314,157 @@ def test_conductor_repo_pin_skips_non_github_url(): ), ) assert _build_repo_pin(config) is None + + +# ── Stage-2 chunk 3: the gated merge edge in prompts (code↔prompt contract) ── + + +def test_mergemaster_merges_only_through_the_gate(): + """Stage-2: the Mergemaster never executes a merge itself — every merge, + in the main flow AND inside the bisect, is one gated ``cb-phase merge`` + call per PR. A direct ``gh`` merge reappearing anywhere in the prompt + re-opens the ungated-merge hole the FSM gate exists to close. + """ + mergemaster = Path("src/codeband/prompts/mergemaster.md").read_text( + encoding="utf-8", + ) + + assert "cb-phase merge --pr " in mergemaster + # One assertion covers both flows: no direct gh merge anywhere. + assert "gh pr merge" not in mergemaster + assert "--admin" not in mergemaster + # The bisect's intermediate merges are gated per-subtask too. + assert "Every intermediate merge in the bisect is gated per-subtask" in mergemaster + # Outcome discipline: rest on awaiting-approval, report needs_rebase, + # stop dead on blocked. + assert "done with this PR for now" in mergemaster + assert "needs_rebase" in mergemaster + assert "rebase the branch yourself" in mergemaster + assert "never attempt to route around a blocked subtask" in mergemaster + # Merge requests carry the subtask id the gate call needs. + assert "ask the Conductor for it before processing that PR" in mergemaster + + +def test_conductor_halt_discipline_covers_merge_gate(): + """The merge edge is gated exactly like verify/review: a gate refusal is + authoritative, and ``needs_rebase`` routes back to the owning Coder.""" + conductor = Path("src/codeband/prompts/conductor.md").read_text( + encoding="utf-8", + ) + + assert "The merge edge is gated exactly like verify and review." in conductor + assert "A merge refused by the gate is authoritative" in conductor + # needs_rebase is rework routing, with verdict-void instructions. + assert "Rebase routing (`needs_rebase`)" in conductor + assert "All prior verdicts are void on the new SHA" in conductor + # Merge requests to the Mergemaster include subtask ids for the gate call. + assert "subtask st-1, risk:" in conductor + # Awaiting-approval is a normal pause, not a failure to re-route. + assert "awaiting approval" in conductor + # Chat approval must not re-trigger Mergemaster routing — Conductor clarifies once, then silent. + assert "chat reply does not advance the merge gate" in conductor + + +def test_coder_rebase_rework_reenters_verify_walk(): + """On a ``needs_rebase`` assignment the Coder rebases, pushes, and re-runs + ``cb-phase verify`` — and knows prior verdicts are void on the new SHA.""" + coder = Path("src/codeband/prompts/coder.md").read_text(encoding="utf-8") + + assert "Rebase rework (`needs_rebase`)" in coder + assert "git rebase origin/" in coder + assert "All prior verdicts are void on the new SHA — by design." in coder + # Post-rebase re-review goes back through the normal walk, same reviewer. + assert "exactly as for a first submission" in coder + assert "post-rebase re-review" in coder + + +def test_code_reviewer_verdict_commands_pin_the_pr(): + """Both ``cb-phase review`` verdict lines must carry ``--pr`` — the + verdict head SHA is resolved from the PR head (cwd-independent), never + from the reviewer's repo-less scratch directory, where a cwd-based HEAD + could only ever record NULL and silently void every verdict at the merge + gate (the 2026-06-10 Scenario A incident). This drift class shipped + undetected precisely because no test pinned the reviewer's gate commands. + """ + reviewer = Path("src/codeband/prompts/code_reviewer.md").read_text( + encoding="utf-8", + ) + + assert ( + "cb-phase review --task --pr --reject" + in reviewer + ) + assert ( + "cb-phase review --task --pr --approve" + in reviewer + ) + # No verdict line may remain without the PR pin. + for line in reviewer.splitlines(): + if "cb-phase review" in line and ("--approve" in line or "--reject" in line): + assert "--pr" in line, f"unpinned verdict command: {line!r}" + + +def test_codeband_command_doc_grants_approval_instead_of_prohibiting(): + """As of the merge-execution leg, ``cb approve`` writes the SHA-pinned + approval grant and the invoking agent is the task owner/approver — the old + do-not-use prohibition is obsolete and must stay gone. + + A2 (F14): the approval flow now uses --no-notify so the coordinator can + post the room notification as its own jam identity (not the human key). + """ + doc = Path("docs/commands/codeband.md").read_text(encoding="utf-8") + + # A2: coordinator-specific grant path uses --no-notify. + assert "cb approve --no-notify" in doc + assert "SHA-pinned" in doc + assert "Never approve blindly" in doc + assert "Do not run `cb approve`" in doc # the withhold path + # A2: coordinator posts notification as its own identity. + assert "Durable merge grant recorded for PR" in doc + # Distinctive substring of the pre-merge-leg prohibition. + assert "do NOT use `cb approve`" not in doc + + +def test_conductor_recovery_playbook_names_all_three_options_with_commands(): + """Batch 4 (findings 23–25): every BLOCKED escalation must offer the three + concrete recovery options WITH their commands. Pinned because unpinned + prompt contracts drift (the sweep-10 lesson).""" + conductor = Path("src/codeband/prompts/conductor.md").read_text(encoding="utf-8") + + assert "## Recovery playbook (blocked subtasks)" in conductor + assert "`cb-phase resume `" in conductor + assert "`cb-phase abandon `" in conductor + assert "Human intervention" in conductor + assert "resume is NOT a cap reset" in conductor + # Conductor-authority edges: never delegated to workers. + assert "never delegate them to a Coder or the Mergemaster" in conductor + + +def test_not_eligible_flow_references_automatic_needs_rebase_routing(): + """SHA-stale verdicts route to needs_rebase automatically (the + stale_verdicts tag); the bare not_eligible reject is the missing-leg + case only. Both prompts must reflect the split.""" + mergemaster = Path("src/codeband/prompts/mergemaster.md").read_text( + encoding="utf-8", + ) + conductor = Path("src/codeband/prompts/conductor.md").read_text(encoding="utf-8") + + assert "`REJECTED [stale_verdicts]`" in mergemaster + assert "routed the subtask to `needs_rebase` automatically" in mergemaster + # The bare-reject bullet survives, scoped to the missing-leg case. + assert "`REJECTED [not_eligible]`" in mergemaster + assert "a verdict leg is missing entirely" in mergemaster + # Conductor's rebase routing names the stale-verdict cause too. + assert "stale_verdicts" in conductor + + +def test_mergemaster_cb_approve_prohibition_is_restated(): + """Finding 18: the Mergemaster must never run the human-approval + primitive itself — the old removal of this prohibition was wrong.""" + mergemaster = Path("src/codeband/prompts/mergemaster.md").read_text( + encoding="utf-8", + ) + + assert "Never run `cb approve` yourself" in mergemaster + assert "HUMAN's approval primitive" in mergemaster + assert "exclusively through `cb-phase merge`" in mergemaster diff --git a/tests/test_rails_integration.py b/tests/test_rails_integration.py new file mode 100644 index 0000000..d989f71 --- /dev/null +++ b/tests/test_rails_integration.py @@ -0,0 +1,1428 @@ +"""Integration gate for the deterministic rails on REAL git + REAL sqlite. + +This is the **(A) hard gate** that must be green *before* P5 activation. The +per-module unit suites (``test_state_store``, ``test_fsm``, ``test_handoff``, +``test_watchdog_upgrade``, ``test_rehydration``) each test one rail in +isolation and mock the boundaries — notably ``subprocess.run`` for every git / +gh shell-out. That mock-theater is the same class of blind spot that hid the +``click`` stderr bug: a green unit suite over mocked subprocess proves the code +*calls* git, not that it *reads real git correctly*. + +This module composes the rails together and drives them against a **real temp +git repository** and a **real temp SQLite database** — no subprocess mock on +the watchdog's git-HEAD leg, which is the signal the whole RFC turns on. It +drives ``cb-phase`` directly at the script level (not via LLM agents): this +validates the *machinery* (the (A) gate). Agent *behavior* — coders actually +calling ``cb-phase`` and the Conductor routing through the FSM — is P5 and out +of scope here. + +Coverage map: + +* ``TestHappyPath`` — one subtask through every state, asserting store rows and + ``transition_log`` at each step. +* ``TestRejectionEdgesFsm`` — illegal edge + wrong caller-role, each asserting + nothing is written. +* ``TestCbPhaseGate`` — the three ``cb-phase verify`` gate rejections (dirty + tree, no open PR, verify command non-zero) plus the happy advance, on real + git with a real verify subprocess; only the ``gh`` PR-state call is isolated + behind ``handoff._verify_pr_snapshot`` (the one-snapshot ``gh`` seam). +* ``TestKillAndRehydrate`` — non-terminal subtasks in the store; each role's + recovery context. +* ``TestFanoutInvariants`` — N concurrent FSM instances: no double-merge, no + merge before approval, and the global cycle cap across the live set. +* ``TestWatchdogRealGit`` — the mechanical progress signal reading *actual* + ``git rev-parse`` output, HEAD-advanced vs not, with NO mocked subprocess. +* ``TestReviewRoundCap`` — the FSM's per-subtask review-round cap: a *productive* + ``review_failed → in_progress → … → review_failed`` loop (a real commit each + round, HEAD advancing) is bounded in code, the count is durable across a store + reopen, the counters are per-subtask, and the cap is a mechanism *distinct* + from the watchdog stall cap (which never fires on a progressing loop). +* ``TestVerifyAttemptCap`` — the handoff CLI's per-subtask verify-attempt cap: + a *productive* ``cb-phase verify`` rejection loop (a real commit each attempt, + HEAD advancing, the verify command failing every time) is bounded in code at + ``MAX_VERIFY_ATTEMPTS`` → escalated ``verify_pending → blocked``; the count is + durable across a store reopen, per-subtask, configurable, and *distinct* from + BOTH the watchdog stall cap (never fires on the progressing loop) and the + review-round cap (the subtask never reaches ``review_failed`` at all). Mirrors + ``TestReviewRoundCap`` on the verify leg of the pipeline. + +The two caps are disjoint by construction. The watchdog's ``max_phase_visits`` +is a *mechanical stall cap* (RFC line 178): it fires on the *absence* of +progress — no git-HEAD change and no new transition across N patrols — so it +cannot bound a loop that commits real code every round. The FSM's +``MAX_REVIEW_ROUNDS`` is a *review-round cap*: it counts how many times a +subtask has bounced back from review and refuses a further rework cycle once the +count is reached, regardless of how much progress each round made. +``TestReviewRoundCap.test_round_cap_distinct_from_watchdog_stall_cap`` drives the +exact loop the watchdog passes and the round cap rejects. +""" + +from __future__ import annotations + +import sqlite3 +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.agents.watchdog import WatchdogDaemon +from codeband.cli import handoff +from codeband.config import ( + AgentsConfig, + CodebandConfig, + RepoConfig, + WatchdogConfig, + WorkspaceConfig, +) +from codeband.state import StateStore +from codeband.cli.handoff import MAX_VERIFY_ATTEMPTS +from codeband.state.fsm import MAX_REVIEW_ROUNDS, InvalidTransitionError, transition +from codeband.state.rehydration import build_agent_recovery_context + + +# ── real-git helpers ───────────────────────────────────────────────────────── + + +def _git(repo: Path, *args: str) -> str: + """Run a real git command in ``repo``; return stdout (raises on failure).""" + result = subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def _stub_pr_snapshot(monkeypatch, repo: Path, *, state: str = "OPEN") -> None: + """Stub verify's ONE PR snapshot (gh) to track the repo's real state. + + PR-pinned verify outcomes require worktree HEAD == PR head and the PR's + head branch == the worktree branch; these tests exercise the real git + side, so the gh side is made to agree (lazily — evaluated per call, so a + commit or checkout made mid-test moves the stubbed PR side too). + """ + monkeypatch.setattr( + handoff, "_verify_pr_snapshot", + lambda project_dir, pr: { + "state": state, + "headRefName": _git(repo, "rev-parse", "--abbrev-ref", "HEAD"), + "headRefOid": _git(repo, "rev-parse", "HEAD"), + }, + ) + + +def _init_repo(path: Path) -> Path: + """Initialise a real git repo at ``path`` with one commit on ``main``.""" + path.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init", "-b", "main", str(path)], check=True, + capture_output=True, text=True) + _git(path, "config", "user.email", "rails-test@example.com") + _git(path, "config", "user.name", "Rails Test") + (path / "README.md").write_text("seed\n", encoding="utf-8") + _git(path, "add", "README.md") + _git(path, "commit", "-m", "initial commit") + return path + + +def _commit_on(repo: Path, branch: str, content: str) -> str: + """Check out ``branch`` (creating it if needed), commit a file, return HEAD.""" + existing = _git(repo, "branch", "--list", branch) + if existing: + _git(repo, "checkout", branch) + else: + _git(repo, "checkout", "-b", branch) + fname = f"{branch}.txt" + (repo / fname).write_text(content, encoding="utf-8") + _git(repo, "add", fname) + _git(repo, "commit", "-m", f"work on {branch}: {content}") + return _git(repo, "rev-parse", branch) + + +def _branch_head(repo: Path, branch: str) -> str: + return _git(repo, "rev-parse", branch) + + +# ── sqlite / store helpers ─────────────────────────────────────────────────── + + +def _new_store(tmp_path: Path) -> StateStore: + store = StateStore(tmp_path / "state" / "orchestration.db") + (store.db_path.parent / ".codeband_room").write_text("room-1", encoding="utf-8") + return store + + +def _log_rows(store: StateStore, subtask_id: str) -> list[sqlite3.Row]: + """Read the real ``transition_log`` rows for a subtask, oldest first.""" + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + try: + return conn.execute( + "SELECT from_state, to_state, caller_role, reason " + "FROM transition_log WHERE subtask_id = ? ORDER BY id", + (subtask_id,), + ).fetchall() + finally: + conn.close() + + +def _log_count(store: StateStore, subtask_id: str) -> int: + return len(_log_rows(store, subtask_id)) + + +def _fake_rest() -> MagicMock: + """A REST stub whose only used method is the async chat-message writer.""" + rest = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + return rest + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Happy path — one subtask through every state (real sqlite, real FSM) +# ───────────────────────────────────────────────────────────────────────────── + + +class TestHappyPath: + """planned → assigned → in_progress → verify_pending → review_pending → + review_passed → merge_pending → merged, asserting store + log each step.""" + + def test_full_lifecycle_records_every_transition(self, tmp_path): + store = _new_store(tmp_path) + store.create_task("room-1", "ship the feature", "room-1") + + # The verify and review outcomes pin head_sha (as cb-phase does); the + # merge_pending step then passes the eligibility gate at the same SHA. + steps = [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-1"), + ("review_passed", "reviewer", "sha-1"), + ("merge_pending", "mergemaster", "sha-1"), + ("merged", "mergemaster", None), + ] + prev_state = "planned" + for i, (new_state, role, sha) in enumerate(steps, start=1): + transition("st-1", "room-1", new_state, caller_role=role, + reason=f"step-{i}", store=store, head_sha=sha) + + row = store.get_subtask("st-1", "room-1") + assert row is not None + assert row.state == new_state + assert row.task_id == "room-1" + + log = _log_rows(store, "st-1") + assert len(log) == i # exactly one new row per step + last = log[-1] + assert last["from_state"] == prev_state + assert last["to_state"] == new_state + assert last["caller_role"] == role + assert last["reason"] == f"step-{i}" + prev_state = new_state + + # Terminal — the full ordered trail is durable. + assert store.get_subtask("st-1", "room-1").state == "merged" + trail = [(r["from_state"], r["to_state"]) for r in _log_rows(store, "st-1")] + assert trail == [ + ("planned", "assigned"), + ("assigned", "in_progress"), + ("in_progress", "verify_pending"), + ("verify_pending", "review_pending"), + ("review_pending", "review_passed"), + ("review_passed", "merge_pending"), + ("merge_pending", "merged"), + ] + + +# ───────────────────────────────────────────────────────────────────────────── +# 2a. Rejection edges in the FSM — illegal edge + wrong caller-role +# ───────────────────────────────────────────────────────────────────────────── + + +class TestRejectionEdgesFsm: + """Each rejection asserts the gate raises AND nothing is written.""" + + def _seed(self, tmp_path, *, state: str, role_chain): + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + for new_state, role in role_chain: + transition("st-1", "room-1", new_state, caller_role=role, store=store) + assert store.get_subtask("st-1", "room-1").state == state + return store + + def test_illegal_edge_not_in_table_rejected(self, tmp_path): + # planned --conductor--> merged is not a legal edge. + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + before = _log_count(store, "st-1") + + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "merged", caller_role="conductor", + store=store) + + # ensure_subtask creates the row at 'planned', but no state change and + # no transition_log row may be written. + assert store.get_subtask("st-1", "room-1").state == "planned" + assert _log_count(store, "st-1") == before == 0 + + def test_wrong_caller_role_rejected(self, tmp_path): + # assigned --coder--> in_progress is legal; the SAME edge driven by a + # 'reviewer' must be rejected. + store = self._seed( + tmp_path, state="assigned", role_chain=[("assigned", "conductor")], + ) + before = _log_count(store, "st-1") + + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "in_progress", caller_role="reviewer", + store=store) + + assert store.get_subtask("st-1", "room-1").state == "assigned" + assert _log_count(store, "st-1") == before # nothing appended + + +# ───────────────────────────────────────────────────────────────────────────── +# 2b. Rejection edges at the cb-phase gate — dirty tree, no PR, verify != 0 +# Real git + real verify subprocess. Only the gh PR-state call is isolated. +# ───────────────────────────────────────────────────────────────────────────── + + +class TestCbPhaseGate: + """``cb-phase verify`` gate, composed against a real git worktree. + + The clean-tree gate and (when configured) the verify command run as real + subprocesses. The PR gates call ``gh pr view`` which cannot run + hermetically in CI, so they are isolated behind + ``handoff._verify_pr_snapshot`` — the single documented ``gh`` seam. Everything else (git status, the verify + command, the FSM transition, the SQLite write) is real. + """ + + def _project(self, tmp_path, *, verify_command=None): + """Build a project dir with codeband.yaml + a store seeded at + ``verify_pending`` for subtask ``st-1``. project_dir is kept separate + from the git worktree so writing codeband.yaml never dirties the tree. + + Returns ``(project_dir, store)``; the store path matches what + ``handoff._resolve_store`` resolves from the config. + """ + project_dir = tmp_path / "project" + project_dir.mkdir() + workspace = tmp_path / "workspace" + cfg = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", + branch="main"), + agents=AgentsConfig(handoff_verify_command=verify_command), + workspace=WorkspaceConfig(path=str(workspace)), + ) + cfg.to_yaml(project_dir / "codeband.yaml") + # Active-room pointer: kickoff writes this; cb-phase resolves the + # authoritative task_id (room UUID) from it, not from --task. + (project_dir / ".codeband_room").write_text("room-1", encoding="utf-8") + + store = StateStore(workspace / "state" / "orchestration.db") + store.create_task("room-1", "demo", "room-1") + transition("st-1", "room-1", "assigned", caller_role="conductor", store=store) + transition("st-1", "room-1", "in_progress", caller_role="coder", store=store) + transition("st-1", "room-1", "verify_pending", caller_role="coder", store=store) + return project_dir, store + + def _run(self, project_dir: Path, worktree: Path) -> int: + return handoff.main([ + "verify", "st-1", + "--task", "room-1", + "--pr", "42", + "--worktree", str(worktree), + "--project-dir", str(project_dir), + ]) + + def test_dirty_tree_rejected(self, tmp_path): + # Fully real: the clean-tree gate fires first, so gh is never reached + # and needs no seam. The worktree is made dirty with an untracked file. + project_dir, store = self._project(tmp_path) + repo = _init_repo(tmp_path / "repo") + (repo / "uncommitted.txt").write_text("dirty\n", encoding="utf-8") + before = _log_count(store, "st-1") + + assert self._run(project_dir, repo) != 0 + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + assert _log_count(store, "st-1") == before + + def test_no_open_pr_rejected(self, tmp_path, monkeypatch): + # gh seam: PR reported not-OPEN. Tree is real and clean. + project_dir, store = self._project(tmp_path) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo, state="CLOSED") + before = _log_count(store, "st-1") + + assert self._run(project_dir, repo) != 0 + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + assert _log_count(store, "st-1") == before + + def test_verify_command_nonzero_rejected(self, tmp_path, monkeypatch): + # gh seam: PR OPEN. The verify command runs as a REAL subprocess and + # exits non-zero, so the gate must reject and write nothing. + project_dir, store = self._project(tmp_path, verify_command="exit 7") + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + before = _log_count(store, "st-1") + + assert self._run(project_dir, repo) != 0 + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + assert _log_count(store, "st-1") == before + + def test_happy_verify_advances_to_review_pending(self, tmp_path, monkeypatch): + # gh seam: PR OPEN. Clean real tree + a REAL passing verify command. + # The subtask advances and a real transition_log row is appended. + project_dir, store = self._project(tmp_path, verify_command="exit 0") + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + before = _log_count(store, "st-1") + + assert self._run(project_dir, repo) == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + assert _log_count(store, "st-1") == before + 1 + last = _log_rows(store, "st-1")[-1] + assert (last["from_state"], last["to_state"]) == ( + "verify_pending", "review_pending", + ) + assert last["caller_role"] == "coder" + + +# ───────────────────────────────────────────────────────────────────────────── +# 2c. Reviewer-verdict command — review_pending → review_passed/failed via FSM +# Real sqlite, driven through the cb-phase CLI (not an LLM). +# ───────────────────────────────────────────────────────────────────────────── + + +class TestCbPhaseReviewVerdict: + """``cb-phase review --approve|--reject`` routes the verdict through the FSM. + + This is the structural bind that makes the verify gate non-bypassable: + ``review_passed`` is reachable ONLY from ``review_pending``, which is + reachable ONLY via the verify gate (``verify_pending → review_pending``). + The verdict edge is legal ONLY from ``review_pending`` — from any other state + the FSM raises and writes nothing, so there is no path to an approved subtask + that skips verification. Driven at the script level on a real SQLite DB. + """ + + def _project(self, tmp_path): + """Project dir with codeband.yaml + a real store (no subtasks yet). + + ``handoff._resolve_store`` resolves the same DB path from the config, so + the CLI and the test share one store. Returns ``(project_dir, store)``. + """ + project_dir = tmp_path / "project" + project_dir.mkdir() + workspace = tmp_path / "workspace" + cfg = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", branch="main"), + agents=AgentsConfig(), + workspace=WorkspaceConfig(path=str(workspace)), + ) + cfg.to_yaml(project_dir / "codeband.yaml") + # Active-room pointer: kickoff writes this; cb-phase resolves the + # authoritative task_id (room UUID) from it, not from --task. + (project_dir / ".codeband_room").write_text("room-1", encoding="utf-8") + store = StateStore(workspace / "state" / "orchestration.db") + store.create_task("room-1", "demo", "room-1") + return project_dir, store + + def _seed(self, store, sid, chain): + # Steps are (state, role) or (state, role, head_sha) — the sha form is + # needed on the verify/review outcomes so the merge-eligibility gate + # passes when a chain walks through merge_pending. + for step in chain: + new_state, role, *rest = step + transition(sid, "room-1", new_state, caller_role=role, store=store, + head_sha=rest[0] if rest else None) + + def _run(self, project_dir, sid, verdict, monkeypatch=None): + if monkeypatch is not None: + # PR-pinned verdicts: the head SHA comes from the PR (gh seam), + # never the invoker's cwd. + monkeypatch.setattr( + handoff, "_pr_head_sha", lambda project_dir, pr: "sha-pr-head", + ) + return handoff.main([ + "review", sid, "--task", "room-1", "--pr", "42", verdict, + "--project-dir", str(project_dir), + ]) + + _TO_REVIEW_PENDING = [ + ("assigned", "conductor"), + ("in_progress", "coder"), + ("verify_pending", "coder"), + ("review_pending", "coder"), + ] + + def test_approve_from_review_pending_passes(self, tmp_path, monkeypatch): + project_dir, store = self._project(tmp_path) + self._seed(store, "st-1", self._TO_REVIEW_PENDING) + before = _log_count(store, "st-1") + + assert self._run(project_dir, "st-1", "--approve", monkeypatch) == 0 + assert store.get_subtask("st-1", "room-1").state == "review_passed" + assert _log_count(store, "st-1") == before + 1 + last = _log_rows(store, "st-1")[-1] + assert (last["from_state"], last["to_state"]) == ( + "review_pending", "review_passed", + ) + assert last["caller_role"] == "reviewer" + + def test_reject_from_review_pending_fails_review(self, tmp_path, monkeypatch): + project_dir, store = self._project(tmp_path) + self._seed(store, "st-1", self._TO_REVIEW_PENDING) + + assert self._run(project_dir, "st-1", "--reject", monkeypatch) == 0 + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_failed" + assert sub.review_round == 1 # a reject counts as one failed review round + last = _log_rows(store, "st-1")[-1] + assert (last["from_state"], last["to_state"]) == ( + "review_pending", "review_failed", + ) + assert last["caller_role"] == "reviewer" + + @pytest.mark.parametrize( + "label, chain", + [ + ("in_progress", [("assigned", "conductor"), ("in_progress", "coder")]), + ("verify_pending", [ + ("assigned", "conductor"), + ("in_progress", "coder"), + ("verify_pending", "coder"), + ]), + ("blocked", [ + ("assigned", "conductor"), + ("in_progress", "coder"), + ("blocked", "coder"), + ]), + ("merged", [ + ("assigned", "conductor"), + ("in_progress", "coder"), + ("verify_pending", "coder"), + ("review_pending", "coder", "sha-1"), + ("review_passed", "reviewer", "sha-1"), + ("merge_pending", "mergemaster", "sha-1"), + ("merged", "mergemaster"), + ]), + ], + ) + def test_verdict_illegal_outside_review_pending_writes_nothing( + self, tmp_path, monkeypatch, label, chain, + ): + project_dir, store = self._project(tmp_path) + self._seed(store, "st-1", chain) + state_before = store.get_subtask("st-1", "room-1").state + before = _log_count(store, "st-1") + + # The CLI surfaces the FSM rejection as a non-zero exit… + assert self._run(project_dir, "st-1", "--approve", monkeypatch) != 0 + assert self._run(project_dir, "st-1", "--reject", monkeypatch) != 0 + # …and nothing was written for either attempt. + assert store.get_subtask("st-1", "room-1").state == state_before + assert _log_count(store, "st-1") == before + + # The FSM is the actual guard: a direct transition raises, writing nothing. + for verdict in ("review_passed", "review_failed"): + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", verdict, caller_role="reviewer", + store=store) + assert _log_count(store, "st-1") == before + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Kill-and-rehydrate — per-role recovery context from durable state +# ───────────────────────────────────────────────────────────────────────────── + + +class TestKillAndRehydrate: + """With non-terminal subtasks in the store, each role gets the right + in-flight context; a terminal subtask is never surfaced.""" + + def _seed(self, tmp_path): + store = _new_store(tmp_path) + store.create_task("room-1", "build the dark-mode toggle", "room-1") + # A spread of states across the lifecycle, plus one terminal. + store.ensure_subtask("st-inprog", "room-1", state="in_progress", + assigned_worker="coder-claude_sdk-0") + store.ensure_subtask("st-review", "room-1", state="review_pending", + assigned_worker="coder-codex-0") + store.ensure_subtask("st-passed", "room-1", state="review_passed", + assigned_worker="coder-claude_sdk-0") + store.ensure_subtask("st-merge", "room-1", state="merge_pending", + assigned_worker="coder-codex-0") + store.ensure_subtask("st-merged", "room-1", state="merged", + assigned_worker="coder-claude_sdk-0") + return store + + async def test_conductor_sees_all_inflight(self, tmp_path): + store = self._seed(tmp_path) + ctx = await build_agent_recovery_context("conductor", store) + assert ctx is not None + # All four non-terminal subtasks appear; the merged one does not. + for sid in ("st-inprog", "st-review", "st-passed", "st-merge"): + assert sid in ctx + assert "st-merged" not in ctx + + async def test_mergemaster_sees_merge_pending_and_review_passed(self, tmp_path): + store = self._seed(tmp_path) + ctx = await build_agent_recovery_context("mergemaster", store) + assert ctx is not None + assert "st-passed" in ctx + assert "st-merge" in ctx + # Not awaiting integration: + assert "st-inprog" not in ctx + assert "st-review" not in ctx + assert "st-merged" not in ctx + + async def test_reviewer_sees_only_review_pending(self, tmp_path): + store = self._seed(tmp_path) + ctx = await build_agent_recovery_context("reviewer-codex-0", store) + assert ctx is not None + assert "st-review" in ctx + for other in ("st-inprog", "st-passed", "st-merge", "st-merged"): + assert other not in ctx + + async def test_planner_sees_active_task(self, tmp_path): + store = self._seed(tmp_path) + ctx = await build_agent_recovery_context("planner-claude_sdk-0", store) + assert ctx is not None + assert "room-1" in ctx + assert "build the dark-mode toggle" in ctx + + async def test_empty_store_yields_no_context(self, tmp_path): + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + # No subtask rows → nothing relevant in durable state for any role. + assert await build_agent_recovery_context("conductor", store) is None + assert await build_agent_recovery_context("planner-claude_sdk-0", store) is None + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Fan-out invariants — N concurrent FSM instances; assert across the set +# ───────────────────────────────────────────────────────────────────────────── + + +class TestFanoutInvariants: + """The genuinely new risk surface vs. band-of-devs' single track.""" + + def _drive_to_merged(self, store, sid): + for new_state, role, sha in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", f"sha-{sid}"), + ("review_passed", "reviewer", f"sha-{sid}"), + ("merge_pending", "mergemaster", f"sha-{sid}"), + ("merged", "mergemaster", None), + ]: + transition(sid, "room-1", new_state, caller_role=role, store=store, + head_sha=sha) + + def test_no_double_merge_across_set(self, tmp_path): + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + sids = [f"st-{i}" for i in range(4)] + for sid in sids: + self._drive_to_merged(store, sid) + + # Every subtask is merged; a SECOND merge of any of them is rejected + # (terminal state) and appends no extra 'merged' row. + for sid in sids: + assert store.get_subtask(sid, "room-1").state == "merged" + merged_rows_before = sum( + 1 for r in _log_rows(store, sid) if r["to_state"] == "merged" + ) + with pytest.raises(InvalidTransitionError): + transition(sid, "room-1", "merged", caller_role="mergemaster", + store=store) + merged_rows_after = sum( + 1 for r in _log_rows(store, sid) if r["to_state"] == "merged" + ) + assert merged_rows_before == merged_rows_after == 1 + + def test_no_merge_before_approval_across_set(self, tmp_path): + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + # N subtasks parked at review_pending — reviewed, not yet PASSED. + sids = [f"st-{i}" for i in range(4)] + for sid in sids: + for new_state, role in [ + ("assigned", "conductor"), + ("in_progress", "coder"), + ("verify_pending", "coder"), + ("review_pending", "coder"), + ]: + transition(sid, "room-1", new_state, caller_role=role, store=store) + + for sid in sids: + # Mergemaster cannot jump an un-approved subtask into merge_pending… + with pytest.raises(InvalidTransitionError): + transition(sid, "room-1", "merge_pending", + caller_role="mergemaster", store=store) + # …nor straight to merged. + with pytest.raises(InvalidTransitionError): + transition(sid, "room-1", "merged", + caller_role="mergemaster", store=store) + assert store.get_subtask(sid, "room-1").state == "review_pending" + assert not any( + r["to_state"] in ("merge_pending", "merged") + for r in _log_rows(store, sid) + ) + + async def test_global_cycle_cap_across_set(self, tmp_path, monkeypatch): + """The watchdog stall cap (``max_phase_visits``) applied across the + full live set, driven by REAL git HEAD movement. + + Three concurrent in-flight subtasks share one repo. One makes real + progress every round; two stall. After ``max_phase_visits`` patrols the + two stalled subtasks — and only those — are marked ``blocked`` via the + real FSM. This is the global enforcement of the cycle cap: one counter + per subtask, evaluated across every active subtask each patrol. + """ + repo = _init_repo(tmp_path / "repo") + for b in ("feat-0", "feat-1", "feat-2"): + _commit_on(repo, b, "init") + _git(repo, "checkout", "main") + monkeypatch.chdir(repo) + + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + # Seed directly at in_progress (no transition_log rows) so git HEAD is + # the ONLY progress signal in play. pr_number stays None → gh is never + # called. + for i in range(3): + store.ensure_subtask(f"st-{i}", "room-1", state="in_progress", + metadata={"branch": f"feat-{i}"}) + + rest = _fake_rest() + daemon = WatchdogDaemon( + config=WatchdogConfig(max_phase_visits=2, git_progress_check=True), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + ) + now = datetime.now(timezone.utc) + + await daemon._check_subtask_progress(now) # patrol 1: baseline + _commit_on(repo, "feat-1", "round-2") # only feat-1 advances + _git(repo, "checkout", "main") + await daemon._check_subtask_progress(now) # patrol 2: 0,2 stall→1 + _commit_on(repo, "feat-1", "round-3") + _git(repo, "checkout", "main") + await daemon._check_subtask_progress(now) # patrol 3: 0,2 stall→2→blocked + + assert store.get_subtask("st-0", "room-1").state == "blocked" + assert store.get_subtask("st-2", "room-1").state == "blocked" + assert store.get_subtask("st-1", "room-1").state == "in_progress" + # Exactly one blocked-alert per stalled subtask (global, not per-run). + assert rest.agent_api_messages.create_agent_chat_message.await_count == 2 + # The blocks were applied by the real FSM with the watchdog role. + for sid in ("st-0", "st-2"): + assert any( + r["to_state"] == "blocked" and r["caller_role"] == "watchdog" + for r in _log_rows(store, sid) + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. Watchdog on REAL git — mechanical progress signal reads actual git HEAD +# ───────────────────────────────────────────────────────────────────────────── + + +class TestWatchdogRealGit: + """No mocked subprocess on the git-HEAD leg. The watchdog runs a real + ``git rev-parse`` in the process cwd; the test drives real commits.""" + + def _daemon(self, store, *, max_phase_visits, rest=None): + return WatchdogDaemon( + config=WatchdogConfig( + max_phase_visits=max_phase_visits, git_progress_check=True, + ), + rest_client=rest if rest is not None else _fake_rest(), + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + ) + + async def test_real_head_advance_resets_stall_counter(self, tmp_path, monkeypatch): + repo = _init_repo(tmp_path / "repo") + sha1 = _commit_on(repo, "feat-a", "v1") + _git(repo, "checkout", "main") + monkeypatch.chdir(repo) + + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + store.ensure_subtask("st-a", "room-1", state="in_progress", + metadata={"branch": "feat-a"}) # pr_number None → no gh + + daemon = self._daemon(store, max_phase_visits=10) + now = datetime.now(timezone.utc) + + await daemon._check_subtask_progress(now) # baseline + health = daemon._subtask_state[("room-1", "st-a")] + assert health.last_git_head == sha1 # read REAL git HEAD + assert health.patrol_visits_without_progress == 0 + + await daemon._check_subtask_progress(now) # no commit → stall + await daemon._check_subtask_progress(now) # stall again + assert health.patrol_visits_without_progress == 2 + + sha2 = _commit_on(repo, "feat-a", "v2") # REAL HEAD movement + _git(repo, "checkout", "main") + assert sha2 != sha1 + await daemon._check_subtask_progress(now) + assert health.last_git_head == sha2 # observed the new SHA + assert health.patrol_visits_without_progress == 0 # progress reset it + + async def test_real_git_stall_marks_blocked(self, tmp_path, monkeypatch): + repo = _init_repo(tmp_path / "repo") + _commit_on(repo, "feat-b", "v1") + _git(repo, "checkout", "main") + monkeypatch.chdir(repo) + + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + store.ensure_subtask("st-b", "room-1", state="in_progress", + metadata={"branch": "feat-b"}) + + rest = _fake_rest() + daemon = self._daemon(store, max_phase_visits=2, rest=rest) + now = datetime.now(timezone.utc) + + await daemon._check_subtask_progress(now) # baseline (counts as progress) + await daemon._check_subtask_progress(now) # stall → 1 + assert store.get_subtask("st-b", "room-1").state == "in_progress" + await daemon._check_subtask_progress(now) # stall → 2 == cap → blocked + + assert store.get_subtask("st-b", "room-1").state == "blocked" + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs[ + "message" + ] + assert "st-b" in msg.content + assert "could not be applied" not in msg.content # FSM applied it + assert any( + r["to_state"] == "blocked" and r["caller_role"] == "watchdog" + for r in _log_rows(store, "st-b") + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. Per-subtask review-round cap — bounds a PROGRESSING loop in code +# ───────────────────────────────────────────────────────────────────────────── + + +class TestReviewRoundCap: + """The FSM's durable per-subtask review-round cap (``MAX_REVIEW_ROUNDS``). + + This is the loop the watchdog cannot catch: ``review_failed → in_progress → + verify_pending → review_pending → review_failed`` with a *real commit every + round*, so git HEAD advances and the watchdog's stall cap never fires. The + cap is enforced in ``fsm.transition`` against a durable count, so it survives + a crash/reopen and is independent per subtask. + """ + + # ── helpers ──────────────────────────────────────────────────────────── + + def _fsm_cycle_to_review_failed(self, store, sid, *, first): + """Run one review cycle ending at ``review_failed`` (pure FSM, no git). + + ``first=True`` starts from ``planned`` (assign first); otherwise starts + from ``review_failed`` (the rework edge that the cap guards). + """ + if first: + transition(sid, "room-1", "assigned", caller_role="conductor", store=store) + transition(sid, "room-1", "in_progress", caller_role="coder", store=store) + transition(sid, "room-1", "verify_pending", caller_role="coder", store=store) + transition(sid, "room-1", "review_pending", caller_role="coder", store=store) + transition(sid, "room-1", "review_failed", caller_role="reviewer", store=store) + + def _drive_to_cap(self, store, sid): + """Cycle ``sid`` to ``review_failed`` exactly ``MAX_REVIEW_ROUNDS`` times.""" + self._fsm_cycle_to_review_failed(store, sid, first=True) # round 1 + for _ in range(MAX_REVIEW_ROUNDS - 1): # rounds 2..MAX + self._fsm_cycle_to_review_failed(store, sid, first=False) + + # ── the crux: a progressing loop, bounded by the cap ───────────────────── + + def test_progressing_loop_hits_cap_with_real_commits(self, tmp_path): + """A real commit every round (HEAD advances) — NOT a stall — and the cap + still rejects ``review_failed → in_progress`` at the cap, writing nothing. + Only ``review_failed → blocked`` is then legal.""" + repo = _init_repo(tmp_path / "repo") + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + + heads: list[str] = [] + + # Round 1: assign → … → review_failed, with a real commit. + self._fsm_cycle_to_review_failed(store, "st-cap", first=True) + heads.append(_commit_on(repo, "feat-cap", "round-1")) + assert store.get_subtask("st-cap", "room-1").review_round == 1 + + # Rounds 2..MAX: rework is legal each time (count below cap), and every + # round lands a real commit so HEAD keeps moving. + for r in range(2, MAX_REVIEW_ROUNDS + 1): + self._fsm_cycle_to_review_failed(store, "st-cap", first=False) + heads.append(_commit_on(repo, "feat-cap", f"round-{r}")) + assert store.get_subtask("st-cap", "room-1").review_round == r + + # Every round advanced HEAD — this is a progressing loop, not a stall. + assert len(set(heads)) == len(heads) == MAX_REVIEW_ROUNDS + + # At the cap, the rework edge is rejected with an ACTIONABLE error and + # NOTHING is written (no state change, no log row, count unchanged). + assert store.get_subtask("st-cap", "room-1").state == "review_failed" + assert store.get_subtask("st-cap", "room-1").review_round == MAX_REVIEW_ROUNDS + before = _log_count(store, "st-cap") + with pytest.raises(InvalidTransitionError) as exc: + transition("st-cap", "room-1", "in_progress", caller_role="coder", + store=store) + message = str(exc.value).lower() + assert "cap" in message and "blocked" in message # actionable: how to escape + assert store.get_subtask("st-cap", "room-1").state == "review_failed" + assert store.get_subtask("st-cap", "room-1").review_round == MAX_REVIEW_ROUNDS + assert _log_count(store, "st-cap") == before # nothing written on rejection + + # The legal escalation out of review_failed at the cap is → blocked. + transition("st-cap", "room-1", "blocked", caller_role="coder", store=store) + assert store.get_subtask("st-cap", "room-1").state == "blocked" + assert _log_count(store, "st-cap") == before + 1 + + def test_configurable_cap_rejects_at_explicit_max(self, tmp_path): + """The cap is configurable: passing ``max_review_rounds=1`` bounds the + loop after a single failed review.""" + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + self._fsm_cycle_to_review_failed(store, "st-1", first=True) # round 1 + assert store.get_subtask("st-1", "room-1").review_round == 1 + + before = _log_count(store, "st-1") + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "in_progress", caller_role="coder", + store=store, max_review_rounds=1) + assert _log_count(store, "st-1") == before # nothing written + + # The default cap (3) would still allow this rework — proving the bound + # came from the override, not the default. + transition("st-1", "room-1", "in_progress", caller_role="coder", store=store) + assert store.get_subtask("st-1", "room-1").state == "in_progress" + + # ── durability: the count survives a crash/reopen mid-loop ─────────────── + + def test_cap_survives_store_reopen(self, tmp_path): + """A crash mid-loop must not reset the cap: the durable count persists + across a fresh ``StateStore`` on the same DB file, and the cap still + fires after reopen.""" + db_path = tmp_path / "state" / "orchestration.db" + store = StateStore(db_path) + store.create_task("room-1", "demo", "room-1") + self._drive_to_cap(store, "st-d") + assert store.get_subtask("st-d", "room-1").review_round == MAX_REVIEW_ROUNDS + + # Simulate a crash/restart: drop the handle, reopen the same file fresh. + del store + reopened = StateStore(db_path) + assert reopened.get_subtask("st-d", "room-1").review_round == MAX_REVIEW_ROUNDS + assert reopened.get_subtask("st-d", "room-1").state == "review_failed" + + before = _log_count(reopened, "st-d") + with pytest.raises(InvalidTransitionError): + transition("st-d", "room-1", "in_progress", caller_role="coder", + store=reopened) + assert _log_count(reopened, "st-d") == before # nothing written + assert reopened.get_subtask("st-d", "room-1").review_round == MAX_REVIEW_ROUNDS + + # ── isolation: one subtask's cap does not affect another's counter ─────── + + def test_per_subtask_round_counters_are_independent(self, tmp_path): + """N concurrent subtasks each carry their own ``review_round``: one + hitting the cap leaves another's rework untouched.""" + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + + self._drive_to_cap(store, "st-capped") # → MAX + self._fsm_cycle_to_review_failed(store, "st-fresh", first=True) # → 1 + # A third, mid-loop, to show counters are tracked independently. + self._fsm_cycle_to_review_failed(store, "st-mid", first=True) + self._fsm_cycle_to_review_failed(store, "st-mid", first=False) # → 2 + + assert store.get_subtask("st-capped", "room-1").review_round == MAX_REVIEW_ROUNDS + assert store.get_subtask("st-fresh", "room-1").review_round == 1 + assert store.get_subtask("st-mid", "room-1").review_round == 2 + + # The capped subtask rejects rework… + with pytest.raises(InvalidTransitionError): + transition("st-capped", "room-1", "in_progress", caller_role="coder", + store=store) + # …while the others, below the cap, rework freely. + transition("st-fresh", "room-1", "in_progress", caller_role="coder", + store=store) + transition("st-mid", "room-1", "in_progress", caller_role="coder", store=store) + assert store.get_subtask("st-fresh", "room-1").state == "in_progress" + assert store.get_subtask("st-mid", "room-1").state == "in_progress" + assert store.get_subtask("st-capped", "room-1").state == "review_failed" + + # ── independence: round cap and watchdog stall cap catch disjoint faults ── + + async def test_round_cap_distinct_from_watchdog_stall_cap(self, tmp_path, monkeypatch): + """The watchdog (even with a tight stall cap) NEVER fires on a loop that + commits real code every round; the FSM round cap bounds that same loop. + Proves the two are separate mechanisms catching disjoint failures.""" + repo = _init_repo(tmp_path / "repo") + _commit_on(repo, "feat-x", "seed") + _git(repo, "checkout", "main") + monkeypatch.chdir(repo) + + store = _new_store(tmp_path) + store.create_task("room-1", "demo", "room-1") + store.ensure_subtask("st-x", "room-1", state="in_progress", + metadata={"branch": "feat-x"}) # pr None → no gh + + rest = _fake_rest() + # A deliberately TIGHT stall cap — it would fire on any 2-patrol stall. + daemon = WatchdogDaemon( + config=WatchdogConfig(max_phase_visits=2, git_progress_check=True), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + ) + now = datetime.now(timezone.utc) + + await daemon._check_subtask_progress(now) # baseline + + # First failed review (round 1). + transition("st-x", "room-1", "verify_pending", caller_role="coder", store=store) + transition("st-x", "room-1", "review_pending", caller_role="coder", store=store) + transition("st-x", "room-1", "review_failed", caller_role="reviewer", store=store) + + # Each subsequent round: rework, a REAL commit (HEAD moves), a watchdog + # patrol (which therefore sees progress), then back to review_failed. + for r in range(2, MAX_REVIEW_ROUNDS + 1): + transition("st-x", "room-1", "in_progress", caller_role="coder", store=store) + _commit_on(repo, "feat-x", f"round-{r}") + _git(repo, "checkout", "main") + await daemon._check_subtask_progress(now) + transition("st-x", "room-1", "verify_pending", caller_role="coder", + store=store) + transition("st-x", "room-1", "review_pending", caller_role="coder", + store=store) + transition("st-x", "room-1", "review_failed", caller_role="reviewer", + store=store) + + # The watchdog never blocked it — HEAD advanced every patrol, so its + # stall counter kept resetting. This is the loop it cannot catch. + assert daemon._subtask_state[("room-1", "st-x")].patrol_visits_without_progress == 0 + assert rest.agent_api_messages.create_agent_chat_message.await_count == 0 + assert store.get_subtask("st-x", "room-1").state == "review_failed" + assert store.get_subtask("st-x", "room-1").review_round == MAX_REVIEW_ROUNDS + + # …but the FSM round cap DOES bound the same progressing loop. + before = _log_count(store, "st-x") + with pytest.raises(InvalidTransitionError): + transition("st-x", "room-1", "in_progress", caller_role="coder", + store=store) + assert _log_count(store, "st-x") == before + + +# ───────────────────────────────────────────────────────────────────────────── +# 7. Per-subtask verify-attempt cap — bounds a PROGRESSING verify loop in code +# ───────────────────────────────────────────────────────────────────────────── + + +class TestVerifyAttemptCap: + """The handoff CLI's durable per-subtask verify-attempt cap. + + This is the verify-leg sibling of ``TestReviewRoundCap``. The loop it bounds: + a coder parked at ``verify_pending`` calls ``cb-phase verify``, a gate fails + (here: the verify command exits non-zero), the coder *edits and commits* + (git HEAD advances — it looks like progress), and tries again — forever. The + watchdog's stall cap reads git HEAD, so a HEAD-advancing loop resets its + counter every patrol and it never fires; the review-round cap counts + ``review_failed`` re-entries, which this subtask never reaches. The cap is + enforced in ``cli/handoff.py`` against a durable ``verify_attempts`` count + (cumulative, never reset), so it survives a crash/reopen and is per-subtask. + + Semantics (a faithful mirror of the review-round cap): each *rejected* verify + attempt increments ``verify_attempts`` (a *success* never does); once the + count has reached ``MAX_VERIFY_ATTEMPTS``, the *next* call escalates + ``verify_pending → blocked`` and writes nothing but that transition — exactly + as the review-round cap records ``MAX_REVIEW_ROUNDS`` failures before refusing + the next rework. + """ + + # ── helpers ────────────────────────────────────────────────────────────── + + def _project(self, tmp_path, *, verify_command="exit 1", max_verify_attempts=None): + """Build a project dir with codeband.yaml + a store (no subtasks yet). + + ``verify_command`` defaults to ``exit 1`` so every verify attempt is + *rejected* at the (real subprocess) verify gate while the tree stays + clean and the PR is reported OPEN — isolating the cap from the other + gates. project_dir is kept separate from the git worktree so writing + codeband.yaml never dirties the tree. Returns ``(project_dir, store)``. + """ + project_dir = tmp_path / "project" + project_dir.mkdir() + workspace = tmp_path / "workspace" + agents_kwargs = {"handoff_verify_command": verify_command} + if max_verify_attempts is not None: + agents_kwargs["max_verify_attempts"] = max_verify_attempts + cfg = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", + branch="main"), + agents=AgentsConfig(**agents_kwargs), + workspace=WorkspaceConfig(path=str(workspace)), + ) + cfg.to_yaml(project_dir / "codeband.yaml") + # Active-room pointer: kickoff writes this; cb-phase resolves the + # authoritative task_id (room UUID) from it, not from --task. + (project_dir / ".codeband_room").write_text("room-1", encoding="utf-8") + store = StateStore(workspace / "state" / "orchestration.db") + (store.db_path.parent / ".codeband_room").write_text("room-1", encoding="utf-8") + store.create_task("room-1", "demo", "room-1") + return project_dir, store + + def _seed_verify_pending(self, store, sid, branch): + """Drive a fresh subtask to ``verify_pending`` with its branch recorded. + + The branch goes in metadata *before* the first transition (which only + ``ensure_subtask``s without metadata), so the watchdog can read the + subtask's git HEAD. + """ + store.ensure_subtask(sid, "room-1", metadata={"branch": branch}) + transition(sid, "room-1", "assigned", caller_role="conductor", store=store) + transition(sid, "room-1", "in_progress", caller_role="coder", store=store) + transition(sid, "room-1", "verify_pending", caller_role="coder", store=store) + + def _run_verify(self, project_dir, worktree, subtask_id, *, pr=42): + return handoff.main([ + "verify", subtask_id, + "--task", "room-1", + "--pr", str(pr), + "--worktree", str(worktree), + "--project-dir", str(project_dir), + ]) + + # ── the crux: a progressing verify loop, bounded by the cap ────────────── + + async def test_cap_fires_on_progressing_loop_distinct_from_stall( + self, tmp_path, monkeypatch, + ): + """A real commit before every attempt (HEAD advances) — NOT a stall — + and the verify cap still escalates to ``blocked`` at the cap, while a + deliberately TIGHT-stall watchdog patrolling the same loop never fires. + Proves the verify cap and the watchdog stall cap catch disjoint faults. + """ + repo = _init_repo(tmp_path / "repo") + _commit_on(repo, "feat-v", "seed") # create the subtask's branch + _git(repo, "checkout", "main") + monkeypatch.chdir(repo) # watchdog runs git here + _stub_pr_snapshot(monkeypatch, repo) # gh seam: PR OPEN, tracking the repo + + # Default cap (20). verify_command 'exit 1' rejects every attempt. + project_dir, store = self._project(tmp_path, verify_command="exit 1") + self._seed_verify_pending(store, "st-5", "feat-v") + assert MAX_VERIFY_ATTEMPTS == 20 # the documented default + + rest = _fake_rest() + daemon = WatchdogDaemon( + config=WatchdogConfig(max_phase_visits=2, git_progress_check=True), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + ) + now = datetime.now(timezone.utc) + await daemon._check_subtask_progress(now) # baseline patrol + + base_log = _log_count(store, "st-5") # 3 seeding rows + for i in range(1, MAX_VERIFY_ATTEMPTS + 1): # MAX rejecting attempts + _commit_on(repo, "feat-v", f"attempt-{i}") # REAL HEAD movement + _git(repo, "checkout", "main") + await daemon._check_subtask_progress(now) # watchdog sees progress + assert self._run_verify(project_dir, repo, "st-5") != 0 + sub = store.get_subtask("st-5", "room-1") + assert sub.verify_attempts == i # one count per rejection + assert sub.state == "verify_pending" # rejection ≠ transition + assert _log_count(store, "st-5") == base_log # no log row on rejection + + # The progressing loop never tripped the (tight) watchdog stall cap. + assert daemon._subtask_state[("room-1", "st-5")].patrol_visits_without_progress == 0 + assert rest.agent_api_messages.create_agent_chat_message.await_count == 0 + assert store.get_subtask("st-5", "room-1").verify_attempts == MAX_VERIFY_ATTEMPTS + + # The next verify call hits the cap: escalate verify_pending → blocked, + # writing nothing but the blocked transition (no further increment). + assert self._run_verify(project_dir, repo, "st-5") != 0 + sub = store.get_subtask("st-5", "room-1") + assert sub.state == "blocked" + assert sub.verify_attempts == MAX_VERIFY_ATTEMPTS # not bumped on escalate + assert _log_count(store, "st-5") == base_log + 1 + last = _log_rows(store, "st-5")[-1] + assert (last["from_state"], last["to_state"]) == ("verify_pending", "blocked") + assert last["caller_role"] == "coder" + + def test_configurable_cap_rejects_at_explicit_max(self, tmp_path, monkeypatch): + """The cap is configurable: ``max_verify_attempts=2`` bounds the loop + after two rejected attempts; the third call escalates to ``blocked``.""" + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + project_dir, store = self._project( + tmp_path, verify_command="exit 1", max_verify_attempts=2, + ) + self._seed_verify_pending(store, "st-3", "feat-c") + base = _log_count(store, "st-3") + + assert self._run_verify(project_dir, repo, "st-3") != 0 # attempt 1 + assert self._run_verify(project_dir, repo, "st-3") != 0 # attempt 2 + sub = store.get_subtask("st-3", "room-1") + assert sub.verify_attempts == 2 + assert sub.state == "verify_pending" + assert _log_count(store, "st-3") == base # no log rows + + # Third call: count has reached the (overridden) cap → blocked. + assert self._run_verify(project_dir, repo, "st-3") != 0 + assert store.get_subtask("st-3", "room-1").state == "blocked" + assert store.get_subtask("st-3", "room-1").verify_attempts == 2 # not re-bumped + assert _log_count(store, "st-3") == base + 1 + + # ── durability: the count survives a crash/reopen mid-loop ─────────────── + + def test_cap_survives_store_reopen(self, tmp_path, monkeypatch): + """A crash mid-loop must not reset the cap: the durable count persists + across a fresh ``StateStore`` on the same DB file, and the cap still + fires after reopen.""" + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + project_dir, store = self._project( + tmp_path, verify_command="exit 1", max_verify_attempts=3, + ) + self._seed_verify_pending(store, "st-4", "feat-d") + + assert self._run_verify(project_dir, repo, "st-4") != 0 # attempt 1 + assert self._run_verify(project_dir, repo, "st-4") != 0 # attempt 2 + assert store.get_subtask("st-4", "room-1").verify_attempts == 2 + + # Simulate a crash/restart: drop the handle, reopen the same file fresh. + db_path = store.db_path + del store + reopened = StateStore(db_path) + assert reopened.get_subtask("st-4", "room-1").verify_attempts == 2 # survived reopen + assert reopened.get_subtask("st-4", "room-1").state == "verify_pending" + + base = _log_count(reopened, "st-4") + assert self._run_verify(project_dir, repo, "st-4") != 0 # attempt 3 → cap + assert reopened.get_subtask("st-4", "room-1").verify_attempts == 3 + assert _log_count(reopened, "st-4") == base # still no log row + + # Next call after reopen escalates — the cap fired across the "crash". + assert self._run_verify(project_dir, repo, "st-4") != 0 + assert reopened.get_subtask("st-4", "room-1").state == "blocked" + assert _log_count(reopened, "st-4") == base + 1 + + # ── isolation: one subtask's cap does not affect another's counter ─────── + + def test_per_subtask_verify_counters_are_independent(self, tmp_path, monkeypatch): + """N concurrent subtasks each carry their own ``verify_attempts``: one + hitting the cap leaves the others' attempts and state untouched.""" + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + project_dir, store = self._project( + tmp_path, verify_command="exit 1", max_verify_attempts=3, + ) + for sid, branch in [("st-1", "feat-a"), ("st-2", "feat-b"), + ("st-3", "feat-c")]: + self._seed_verify_pending(store, sid, branch) + + # st-1 → cap (3 rejections, then a 4th call escalates to blocked). + for _ in range(3): + assert self._run_verify(project_dir, repo, "st-1") != 0 + assert self._run_verify(project_dir, repo, "st-1") != 0 + # st-2 once, st-3 twice — both below the cap. + assert self._run_verify(project_dir, repo, "st-2") != 0 + for _ in range(2): + assert self._run_verify(project_dir, repo, "st-3") != 0 + + assert store.get_subtask("st-1", "room-1").state == "blocked" + assert store.get_subtask("st-1", "room-1").verify_attempts == 3 + assert store.get_subtask("st-2", "room-1").state == "verify_pending" + assert store.get_subtask("st-2", "room-1").verify_attempts == 1 + assert store.get_subtask("st-3", "room-1").state == "verify_pending" + assert store.get_subtask("st-3", "room-1").verify_attempts == 2 + + # The capped subtask is blocked, but the others — below their own caps — + # keep accepting attempts independently. + assert self._run_verify(project_dir, repo, "st-2") != 0 + assert store.get_subtask("st-2", "room-1").state == "verify_pending" + assert store.get_subtask("st-2", "room-1").verify_attempts == 2 + + # ── interaction: verify cap and review-round cap are independent loops ──── + + def test_verify_cap_and_review_cap_are_independent_counters( + self, tmp_path, monkeypatch, + ): + """``verify_attempts`` and ``review_round`` count disjoint loops: verify + rejections never touch the review counter, a verify *success* never bumps + either, and a failed review never touches the verify counter. A subtask + can approach one cap without affecting the other.""" + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + # Cap high enough that nothing blocks during this test. + project_dir, store = self._project( + tmp_path, verify_command="exit 1", max_verify_attempts=20, + ) + self._seed_verify_pending(store, "st-6", "feat-i") + + # Two verify rejections: verify_attempts climbs, review_round stays 0. + assert self._run_verify(project_dir, repo, "st-6") != 0 + assert self._run_verify(project_dir, repo, "st-6") != 0 + sub = store.get_subtask("st-6", "room-1") + assert sub.verify_attempts == 2 + assert sub.review_round == 0 + + # Now verify *passes* (verify command exits 0) → advances to + # review_pending; a success leaves verify_attempts untouched. + monkeypatch.setattr(handoff, "_verify_command", lambda project_dir, worktree: "exit 0") + assert self._run_verify(project_dir, repo, "st-6") == 0 + sub = store.get_subtask("st-6", "room-1") + assert sub.state == "review_pending" + assert sub.verify_attempts == 2 # success never increments + assert sub.review_round == 0 + + # A failed review increments review_round only — verify_attempts is the + # other cap's counter and stays put. + transition("st-6", "room-1", "review_failed", caller_role="reviewer", + store=store) + sub = store.get_subtask("st-6", "room-1") + assert sub.review_round == 1 + assert sub.verify_attempts == 2 + + +# ───────────────────────────────────────────────────────────────────────────── +# Active-room resolution — cb-phase derives the authoritative task_id from +# /.codeband_room, never from the agent-supplied --task label. +# +# Regression guard for the room_id-vs-task_key bug: tasks.task_id is the room +# UUID (kickoff sets task_id == room_id), but agents are trained on the semantic +# task_key (e.g. "subtract-fn") and pass *that* to --task. Trusting it FK-failed +# ``ensure_subtask`` and the verify gate never seeded. Now the room UUID from +# .codeband_room wins and the bogus label is ignored. +# ───────────────────────────────────────────────────────────────────────────── + + +class TestActiveRoomResolution: + """cb-phase resolves the room UUID from .codeband_room; --task is a label.""" + + # The authoritative id (what kickoff writes to .codeband_room and uses as + # tasks.task_id) vs the semantic key an agent wrongly passes to --task. + ROOM = "058dafc0-7913-4c09-98e5-d1b6a96b1358" + BOGUS = "subtract-fn" + + def _project(self, tmp_path, *, write_room=True, verify_command=None): + """Real project_dir + codeband.yaml + store with an active task row. + + ``write_room`` controls whether the active-room pointer exists. The + store path matches what ``handoff._resolve_store`` resolves from config. + """ + project_dir = tmp_path / "project" + project_dir.mkdir() + workspace = tmp_path / "workspace" + cfg = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", + branch="main"), + agents=AgentsConfig(handoff_verify_command=verify_command), + workspace=WorkspaceConfig(path=str(workspace)), + ) + cfg.to_yaml(project_dir / "codeband.yaml") + if write_room: + (project_dir / ".codeband_room").write_text( + self.ROOM, encoding="utf-8", + ) + store = StateStore(workspace / "state" / "orchestration.db") + store.create_task(self.ROOM, "subtract demo", self.ROOM) + return project_dir, store + + def test_start_resolves_room_and_ignores_bogus_task(self, tmp_path): + # cb-phase start with a bogus --task label: it must resolve the room + # UUID from .codeband_room, seed the subtask FK'd to THAT, and walk to + # in_progress — exactly the path that FK-crashed before this fix. + project_dir, store = self._project(tmp_path) + + rc = handoff.main([ + "start", "st-1", "--task", self.BOGUS, + "--project-dir", str(project_dir), + ]) + assert rc == 0 + + sub = store.get_subtask("st-1", self.ROOM) + assert sub is not None + assert sub.task_id == self.ROOM # FK target is the room UUID … + assert sub.task_id != self.BOGUS # … never the semantic label + assert sub.state == "in_progress" + + # The walk really happened, and no stray task row was minted for the key. + trail = [(r["from_state"], r["to_state"]) for r in _log_rows(store, "st-1")] + assert trail == [("planned", "assigned"), ("assigned", "in_progress")] + assert store.get_task(self.BOGUS) is None + + def test_verify_resolves_room_and_advances(self, tmp_path, monkeypatch): + # cb-phase verify with a bogus --task: self-seeds from missing using the + # resolved room, then the gate advances it to review_pending. Real clean + # git tree + real passing verify command; only the gh PR call is stubbed. + project_dir, store = self._project(tmp_path, verify_command="exit 0") + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + rc = handoff.main([ + "verify", "st-1", "--task", self.BOGUS, "--pr", "42", + "--worktree", str(repo), "--project-dir", str(project_dir), + ]) + assert rc == 0 + + sub = store.get_subtask("st-1", self.ROOM) + assert sub.task_id == self.ROOM + assert sub.state == "review_pending" + last = _log_rows(store, "st-1")[-1] + assert (last["from_state"], last["to_state"]) == ( + "verify_pending", "review_pending", + ) + + def test_missing_pointer_errors_clean_and_writes_nothing(self, tmp_path, capsys): + # No .codeband_room: must NOT FK-crash and must NOT silently proceed — + # a clear non-zero "no active task" error, and nothing written. + project_dir, store = self._project(tmp_path, write_room=False) + + rc = handoff.main([ + "start", "st-1", "--task", self.BOGUS, + "--project-dir", str(project_dir), + ]) + assert rc == handoff.EXIT_NO_ACTIVE_TASK + assert "no active task" in capsys.readouterr().err + assert store.get_subtask("st-1", self.ROOM) is None # no partial row + assert _log_count(store, "st-1") == 0 # no transition written + + def test_pointer_without_task_row_errors_clean(self, tmp_path, capsys): + # Pointer present but names a room with no tasks row (the other failure + # branch): same clean error, same nothing-written guarantee. + project_dir, store = self._project(tmp_path, write_room=True) + (project_dir / ".codeband_room").write_text( + "room-does-not-exist", encoding="utf-8", + ) + + rc = handoff.main([ + "start", "st-1", "--task", self.BOGUS, + "--project-dir", str(project_dir), + ]) + assert rc == handoff.EXIT_NO_ACTIVE_TASK + err = capsys.readouterr().err + assert "no active task" in err + assert "room-does-not-exist" in err + assert store.get_subtask("st-1", self.ROOM) is None + assert _log_count(store, "st-1") == 0 diff --git a/tests/test_reconnect_jitter.py b/tests/test_reconnect_jitter.py new file mode 100644 index 0000000..323ae2f --- /dev/null +++ b/tests/test_reconnect_jitter.py @@ -0,0 +1,260 @@ +"""Tests for full-jitter reconnect backoff in runner and supervisor.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from codeband.session.supervisor import WorkerSupervisor + + +# ── shared fakes ────────────────────────────────────────────────────────────── + +class _FakeAgent: + """Minimal agent fake: run() returns cleanly, stop() is a no-op.""" + + async def run(self) -> None: + return None + + async def stop(self, timeout: float = 2.0) -> None: + pass + + +class _Activity: + def log(self, *args, **kwargs) -> None: + pass + + +# ── runner ──────────────────────────────────────────────────────────────────── + +class TestRunnerJitter: + """_run_agent_forever sleeps a jittered fraction of the computed delay.""" + + @pytest.mark.asyncio + async def test_sleep_within_bounds(self): + """Sleep duration is random.random() * capped_delay, not always the full delay.""" + from codeband.orchestration import runner as runner_mod + + sleep_durations: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleep_durations.append(d) + raise asyncio.CancelledError + + make_agent = MagicMock(return_value=_FakeAgent()) + + with ( + patch.object(runner_mod, "_RECONNECT_BASE_DELAY_SECONDS", 4.0), + patch.object(runner_mod, "_RECONNECT_MAX_DELAY_SECONDS", 60.0), + patch("codeband.orchestration.runner.asyncio.sleep", side_effect=fake_sleep), + patch("random.random", return_value=0.3), + ): + with pytest.raises(asyncio.CancelledError): + await runner_mod._run_agent_forever( + make_agent=make_agent, + name="test-agent", + activity=_Activity(), + agent_key=None, + workspace_path=None, + ) + + assert len(sleep_durations) == 1 + # attempt=1 → delay = min(4.0 * 2**0, 60) = 4.0; jitter = 0.3 * 4.0 = 1.2 + assert sleep_durations[0] == pytest.approx(1.2) + + @pytest.mark.asyncio + async def test_sleep_not_always_full_delay(self): + """Jitter is actually applied — sleep < computed delay when random < 1.0.""" + from codeband.orchestration import runner as runner_mod + + sleep_durations: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleep_durations.append(d) + raise asyncio.CancelledError + + make_agent = MagicMock(return_value=_FakeAgent()) + + with ( + patch.object(runner_mod, "_RECONNECT_BASE_DELAY_SECONDS", 4.0), + patch.object(runner_mod, "_RECONNECT_MAX_DELAY_SECONDS", 60.0), + patch("codeband.orchestration.runner.asyncio.sleep", side_effect=fake_sleep), + patch("random.random", return_value=0.5), + ): + with pytest.raises(asyncio.CancelledError): + await runner_mod._run_agent_forever( + make_agent=make_agent, + name="test-agent", + activity=_Activity(), + agent_key=None, + workspace_path=None, + ) + + # 0.5 * 4.0 = 2.0, less than the full 4.0 + assert sleep_durations[0] == pytest.approx(2.0) + assert sleep_durations[0] < 4.0 + + @pytest.mark.asyncio + async def test_zero_base_delay_unaffected(self): + """With base delay=0, full jitter of 0 is still 0 (test-mode contract).""" + from codeband.orchestration import runner as runner_mod + + sleep_durations: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleep_durations.append(d) + raise asyncio.CancelledError + + make_agent = MagicMock(return_value=_FakeAgent()) + + with ( + patch.object(runner_mod, "_RECONNECT_BASE_DELAY_SECONDS", 0.0), + patch.object(runner_mod, "_RECONNECT_MAX_DELAY_SECONDS", 60.0), + patch("codeband.orchestration.runner.asyncio.sleep", side_effect=fake_sleep), + patch("random.random", return_value=0.9), + ): + with pytest.raises(asyncio.CancelledError): + await runner_mod._run_agent_forever( + make_agent=make_agent, + name="test-agent", + activity=_Activity(), + agent_key=None, + workspace_path=None, + ) + + assert sleep_durations[0] == pytest.approx(0.0) + + @pytest.mark.asyncio + async def test_cap_respected(self): + """Jitter upper bound is the 60s cap — no sleep exceeds it.""" + from codeband.orchestration import runner as runner_mod + + sleep_durations: list[float] = [] + call_count = [0] + + async def fake_sleep(d: float) -> None: + sleep_durations.append(d) + call_count[0] += 1 + if call_count[0] >= 6: + raise asyncio.CancelledError + + make_agent = MagicMock(side_effect=lambda _rc: _FakeAgent()) + + with ( + patch.object(runner_mod, "_RECONNECT_BASE_DELAY_SECONDS", 4.0), + patch.object(runner_mod, "_RECONNECT_MAX_DELAY_SECONDS", 60.0), + patch("codeband.orchestration.runner.asyncio.sleep", side_effect=fake_sleep), + patch("random.random", return_value=1.0), + ): + with pytest.raises(asyncio.CancelledError): + await runner_mod._run_agent_forever( + make_agent=make_agent, + name="test-agent", + activity=_Activity(), + agent_key=None, + workspace_path=None, + ) + + # With random=1.0 the sleep equals the capped delay exactly; none exceed 60s + assert len(sleep_durations) == 6 + for d in sleep_durations: + assert d <= 60.0 + + +# ── supervisor ──────────────────────────────────────────────────────────────── + +class TestSupervisorJitter: + """WorkerSupervisor sleeps a jittered fraction of _compute_backoff output.""" + + def _make_supervisor(self, tmp_path: Path, restart_delay: float = 5.0) -> WorkerSupervisor: + return WorkerSupervisor( + worker_id="coder-claude_sdk-0", + agent_id="agent-42", + create_agent_fn=AsyncMock(return_value=_FakeAgent()), + state_dir=tmp_path, + worktree_path=tmp_path, + restart_delay_seconds=restart_delay, + activity=None, + ) + + def test_compute_backoff_unchanged(self, tmp_path: Path): + """_compute_backoff still returns the deterministic capped value.""" + sup = self._make_supervisor(tmp_path, restart_delay=5.0) + assert sup._compute_backoff(0) == pytest.approx(5.0) + assert sup._compute_backoff(1) == pytest.approx(10.0) + assert sup._compute_backoff(6) == pytest.approx(60.0) + assert sup._compute_backoff(10) == pytest.approx(60.0) + + def test_compute_backoff_zero_delay(self, tmp_path: Path): + """Zero restart_delay keeps backoff at 0 regardless of consecutive_same.""" + sup = self._make_supervisor(tmp_path, restart_delay=0.0) + assert sup._compute_backoff(5) == 0.0 + + @pytest.mark.asyncio + async def test_sleep_within_bounds(self, tmp_path: Path): + """Sleep duration is random.random() * compute_backoff, not the full delay.""" + sup = self._make_supervisor(tmp_path, restart_delay=5.0) + sup._create_agent_fn = AsyncMock(return_value=_FakeAgent()) + + sleep_durations: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleep_durations.append(d) + raise asyncio.CancelledError + + with ( + patch("codeband.session.supervisor.asyncio.sleep", side_effect=fake_sleep), + patch("random.random", return_value=0.4), + ): + with pytest.raises(asyncio.CancelledError): + await sup.run() + + assert len(sleep_durations) == 1 + # consecutive_same=0 → backoff = 5.0; jitter = 0.4 * 5.0 = 2.0 + assert sleep_durations[0] == pytest.approx(2.0) + + @pytest.mark.asyncio + async def test_sleep_not_always_full_delay(self, tmp_path: Path): + """Jitter reduces sleep below the computed backoff.""" + sup = self._make_supervisor(tmp_path, restart_delay=5.0) + sup._create_agent_fn = AsyncMock(return_value=_FakeAgent()) + + sleep_durations: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleep_durations.append(d) + raise asyncio.CancelledError + + with ( + patch("codeband.session.supervisor.asyncio.sleep", side_effect=fake_sleep), + patch("random.random", return_value=0.6), + ): + with pytest.raises(asyncio.CancelledError): + await sup.run() + + assert sleep_durations[0] == pytest.approx(3.0) # 0.6 * 5.0 + assert sleep_durations[0] < 5.0 + + @pytest.mark.asyncio + async def test_zero_restart_delay_unaffected(self, tmp_path: Path): + """Zero restart_delay → sleep=0 even with jitter (test-mode contract).""" + sup = self._make_supervisor(tmp_path, restart_delay=0.0) + sup._create_agent_fn = AsyncMock(return_value=_FakeAgent()) + + sleep_durations: list[float] = [] + + async def fake_sleep(d: float) -> None: + sleep_durations.append(d) + raise asyncio.CancelledError + + with ( + patch("codeband.session.supervisor.asyncio.sleep", side_effect=fake_sleep), + patch("random.random", return_value=0.99), + ): + with pytest.raises(asyncio.CancelledError): + await sup.run() + + assert sleep_durations[0] == pytest.approx(0.0) diff --git a/tests/test_recovery_primitives.py b/tests/test_recovery_primitives.py new file mode 100644 index 0000000..4e27b5e --- /dev/null +++ b/tests/test_recovery_primitives.py @@ -0,0 +1,244 @@ +"""Tests for the Conductor recovery primitives (Batch 4, findings 23–25). + +``cb-phase abandon`` (the existing ``(any, conductor) → abandoned`` wildcard +behind a command), ``cb-phase resume`` (the new ``("blocked", "conductor") → +in_progress`` edge, counters preserved), and the watchdog interaction: an +abandoned row drops out of the blocked-owner patrol entirely. + +Same deterministic style as ``test_handoff.py``: real SQLite + real FSM; +the store/task resolution seams are monkeypatched at the module level. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.cli import handoff +from codeband.state.fsm import InvalidTransitionError, transition +from codeband.state.store import StateStore + +TASK = "room-1" + + +@pytest.fixture +def store(tmp_path) -> StateStore: + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task( + task_id=TASK, description="demo", room_id=TASK, owner_id="owner-1", + ) + return s + + +@pytest.fixture +def env(monkeypatch, store): + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda project_dir, store, task_arg: (TASK, None), + ) + return store + + +def _drive(store, sid, *states_and_roles): + for new_state, role in states_and_roles: + transition(sid, TASK, new_state, caller_role=role, store=store) + + +def _to_blocked(store, sid="st-1", reason="watchdog stall"): + _drive( + store, sid, + ("assigned", "conductor"), ("in_progress", "coder"), + ) + transition( + sid, TASK, "blocked", caller_role="watchdog", reason=reason, store=store, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# FSM edges +# ───────────────────────────────────────────────────────────────────────────── + + +def test_conductor_may_resume_blocked_to_in_progress(store): + _to_blocked(store) + transition("st-1", TASK, "in_progress", caller_role="conductor", store=store) + assert store.get_subtask("st-1", TASK).state == "in_progress" + + +def test_resume_edge_is_conductor_only(store): + _to_blocked(store) + for role in ("coder", "mergemaster", "reviewer", "watchdog"): + with pytest.raises(InvalidTransitionError): + transition("st-1", TASK, "in_progress", caller_role=role, store=store) + + +def test_resume_edge_never_revives_terminal_states(store): + _drive(store, "st-1", ("assigned", "conductor")) + transition("st-1", TASK, "abandoned", caller_role="conductor", store=store) + with pytest.raises(InvalidTransitionError): + transition("st-1", TASK, "in_progress", caller_role="conductor", store=store) + + +def test_mergemaster_may_block_from_review_passed(store): + """The rebase-cap escalation can fire at the review_passed gate (the + SHA-shaped not_eligible routing), so blocked must be a legal mergemaster + target there — mirroring the merge_pending row.""" + _drive( + store, "st-1", + ("assigned", "conductor"), ("in_progress", "coder"), + ("verify_pending", "coder"), ("review_pending", "coder"), + ("review_passed", "reviewer"), + ) + transition("st-1", TASK, "blocked", caller_role="mergemaster", store=store) + assert store.get_subtask("st-1", TASK).state == "blocked" + + +# ───────────────────────────────────────────────────────────────────────────── +# cb-phase abandon +# ───────────────────────────────────────────────────────────────────────────── + + +def test_abandon_command_abandons_from_blocked(env, capsys): + _to_blocked(env) + assert handoff.main(["abandon", "st-1", "--task", TASK]) == 0 + assert env.get_subtask("st-1", TASK).state == "abandoned" + out = capsys.readouterr().out + assert "ABANDONED: subtask st-1" in out # the structured-output contract + assert TASK in out + + +def test_abandon_command_works_from_any_nonterminal_state(env): + _drive(env, "st-2", ("assigned", "conductor"), ("in_progress", "coder")) + assert handoff.main(["abandon", "st-2", "--task", TASK]) == 0 + assert env.get_subtask("st-2", TASK).state == "abandoned" + + +def test_abandon_command_rejects_terminal_states(env, capsys): + _to_blocked(env) + assert handoff.main(["abandon", "st-1"]) == 0 + assert handoff.main(["abandon", "st-1"]) == 1 # already abandoned + assert "transition rejected" in capsys.readouterr().err + + +def test_abandon_reason_lands_on_the_transition_log(env): + import sqlite3 + + _to_blocked(env) + assert handoff.main( + ["abandon", "st-1", "--reason", "superseded by st-9"], + ) == 0 + conn = sqlite3.connect(env.db_path) + try: + row = conn.execute( + "SELECT reason FROM transition_log WHERE subtask_id='st-1' " + "AND to_state='abandoned'", + ).fetchone() + finally: + conn.close() + assert row == ("superseded by st-9",) + + +# ───────────────────────────────────────────────────────────────────────────── +# cb-phase resume +# ───────────────────────────────────────────────────────────────────────────── + + +def test_resume_command_returns_blocked_subtask_to_in_progress(env, capsys): + _to_blocked(env) + assert handoff.main(["resume", "st-1", "--task", TASK]) == 0 + assert env.get_subtask("st-1", TASK).state == "in_progress" + out = capsys.readouterr().out + assert "RESUMED: subtask st-1" in out # the structured-output contract + + +def test_resume_preserves_all_counters(env, capsys): + """The whole point versus abandon+redispatch: review_round, + rebase_rounds, and verify_attempts all survive a block/resume cycle.""" + # Earn one of each counter through real FSM walks. + _drive( + env, "st-1", + ("assigned", "conductor"), ("in_progress", "coder"), + ("verify_pending", "coder"), ("review_pending", "coder"), + ("review_failed", "reviewer"), # review_round → 1 + ("in_progress", "coder"), + ("verify_pending", "coder"), ("review_pending", "coder"), + ("review_passed", "reviewer"), + ("needs_rebase", "mergemaster"), # rebase_rounds → 1 + ("in_progress", "coder"), + ) + env.increment_verify_attempts("st-1", TASK) # verify_attempts → 1 + transition("st-1", TASK, "blocked", caller_role="watchdog", store=env) + + assert handoff.main(["resume", "st-1"]) == 0 + + sub = env.get_subtask("st-1", TASK) + assert sub.state == "in_progress" + assert sub.review_round == 1 + assert sub.rebase_rounds == 1 + assert sub.verify_attempts == 1 + out = capsys.readouterr().out + assert "review_round=1" in out + assert "rebase_rounds=1" in out + assert "verify_attempts=1" in out + + +def test_resume_command_rejects_non_blocked_states(env, capsys): + _drive(env, "st-1", ("assigned", "conductor"), ("in_progress", "coder")) + assert handoff.main(["resume", "st-1"]) == 1 + assert "transition rejected" in capsys.readouterr().err + assert env.get_subtask("st-1", TASK).state == "in_progress" + + +# ───────────────────────────────────────────────────────────────────────────── +# Watchdog interaction: abandon clears the blocked-owner patrol +# ───────────────────────────────────────────────────────────────────────────── + + +def _mock_rest(): + rest = MagicMock() + rest.agent_api_messages = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + return rest + + +def _daemon(store, rest): + from codeband.agents.watchdog import WatchdogDaemon + from codeband.config import WatchdogConfig + + return WatchdogDaemon( + config=WatchdogConfig(), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + owner_id="owner-1", + owner_handle="Owner", + ) + + +@pytest.mark.asyncio +async def test_abandon_clears_the_blocked_patrol(env): + """A blocked-but-not-yet-escalated subtask that the Conductor abandons + must never escalate afterwards — the patrol reads live state and an + abandoned row is terminal.""" + _to_blocked(env) + assert handoff.main(["abandon", "st-1"]) == 0 + + rest = _mock_rest() + await _daemon(env, rest)._check_blocked_subtasks(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_blocked_patrol_still_fires_without_abandon(env): + """Control for the test above: the same row, not abandoned, escalates.""" + _to_blocked(env) + + rest = _mock_rest() + await _daemon(env, rest)._check_blocked_subtasks(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() diff --git a/tests/test_registration.py b/tests/test_registration.py new file mode 100644 index 0000000..aaa3a1c --- /dev/null +++ b/tests/test_registration.py @@ -0,0 +1,772 @@ +"""Tests for the atomic task-registration primitive (initiator-as-owner, part 1). + +Covers the ``register_task`` contract (row-first, required owner, supersede +semantics, repair of the historical half-states), the ``send_task`` reorder +(owner required; registration strictly before the task message), and the +``cb register-task`` CLI wrapper. LLM-free: real sqlite + tmp dirs, mocked +Band clients only where ``send_task`` needs them. +""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from click.testing import CliRunner + +from codeband.cli import cli as cb_cli +from codeband.config import AgentsConfig, VerifiersConfig +from codeband.state import StateStore +from codeband.state.registration import register_task + + +def _gated_agents(**overrides) -> AgentsConfig: + """An AgentsConfig whose default verdict list is executable. + + When ``handoff_verify_command`` is configured, the default + ``required_verdicts`` resolution includes ``verify``. Tests that exercise + the fully gated registration mechanics use this config so they pass the + verdict gate. + + This helper explicitly opts verifiers out so the resolved snapshot stays + the ``verify``/``review`` pair these mechanics tests assert (the active + product default couples in ``verify_acceptance``); the acceptance coupling + has its own tests in test_verifier.py. Callers may override. + """ + overrides.setdefault("verifiers", VerifiersConfig()) + return AgentsConfig(handoff_verify_command="true", **overrides) + + +@pytest.fixture +def store(tmp_path: Path) -> StateStore: + """A StateStore backed by an isolated DB under tmp_path.""" + return StateStore(tmp_path / "state" / "orchestration.db") + + +def _pointer(project_dir: Path) -> Path: + """Canonical pointer location for the primitive tests' store fixture. + + The ``store`` fixture lives at ``tmp_path/state/orchestration.db``, and + the pointer now sits next to that DB. + """ + return project_dir / "state" / ".codeband_room" + + +def _ws_pointer(project_dir: Path) -> Path: + """Canonical pointer for the send_task/CLI tests (sample_config uses + ``workspace.path: workspace`` → DB at ``workspace/state/``).""" + return project_dir / "workspace" / "state" / ".codeband_room" + + +def _legacy_pointer(project_dir: Path) -> Path: + """The pre-relocation pointer location (read-fallback only).""" + return project_dir / ".codeband_room" + + +def _task_row_count(db_path: Path) -> int: + conn = sqlite3.connect(db_path) + try: + (count,) = conn.execute("SELECT COUNT(*) FROM tasks").fetchone() + finally: + conn.close() + return count + + +# --------------------------------------------------------------------------- +# register_task — the primitive's contract +# --------------------------------------------------------------------------- + +class TestRegisterTask: + def test_fresh_registration_writes_row_and_pointer( + self, tmp_path: Path, store: StateStore + ) -> None: + result = register_task( + room_id="room-1", + description="do the thing", + owner_id="owner-7", + owner_handle="yoni/claude-abc", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + + assert result.outcome == "registered" + assert result.superseded_task_id is None + task = store.get_task("room-1") + assert task is not None + assert task.description == "do the thing" + assert task.status == "active" + assert task.owner_id == "owner-7" + assert task.owner_handle == "yoni/claude-abc" + assert _pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-1" + + @pytest.mark.parametrize("bad_owner", ["", None]) + def test_missing_owner_raises_and_writes_nothing( + self, tmp_path: Path, store: StateStore, bad_owner + ) -> None: + with pytest.raises(ValueError, match="owner_id"): + register_task( + room_id="room-1", + description="do the thing", + owner_id=bad_owner, + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + + assert store.get_task("room-1") is None + assert _task_row_count(store.db_path) == 0 + assert not _pointer(tmp_path).exists() + + def test_reregister_same_room_updates_owner_only( + self, tmp_path: Path, store: StateStore + ) -> None: + register_task( + room_id="room-1", + description="original description", + owner_id="owner-a", + owner_handle="handle-a", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + result = register_task( + room_id="room-1", + description="DIFFERENT description must be ignored", + owner_id="owner-b", + owner_handle="handle-b", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + + assert result.outcome == "re-registered" + assert _task_row_count(store.db_path) == 1 + task = store.get_task("room-1") + assert task is not None + assert task.owner_id == "owner-b" + assert task.owner_handle == "handle-b" + # Description and status are deliberately untouched on re-registration. + assert task.description == "original description" + assert task.status == "active" + assert _pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-1" + + def test_new_room_supersedes_active_task( + self, tmp_path: Path, store: StateStore + ) -> None: + register_task( + room_id="room-old", + description="old task", + owner_id="owner-a", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + result = register_task( + room_id="room-new", + description="new task", + owner_id="owner-b", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + + assert result.outcome == "superseded" + assert result.superseded_task_id == "room-old" + old = store.get_task("room-old") + new = store.get_task("room-new") + assert old is not None and old.status == "superseded" + assert new is not None and new.status == "active" + assert _pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-new" + + def test_pointer_without_row_is_overwritten_cleanly( + self, tmp_path: Path, store: StateStore + ) -> None: + # The /codeband broken state (H2): a pointer that resolves to no row. + _pointer(tmp_path).write_text("ghost-room", encoding="utf-8") + + result = register_task( + room_id="room-1", + description="real task", + owner_id="owner-7", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + + # Nothing to supersede — the dangling pointer was invalid state. + assert result.outcome == "registered" + assert result.superseded_task_id is None + assert store.get_task("ghost-room") is None + assert store.get_task("room-1") is not None + assert _pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-1" + + def test_fresh_registration_writes_new_location_only( + self, tmp_path: Path, store: StateStore + ) -> None: + """The pointer lives next to the DB; nothing writes the legacy + project-dir location anymore.""" + register_task( + room_id="room-1", + description="task", + owner_id="owner-7", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + assert _pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-1" + assert not _legacy_pointer(tmp_path).exists() + + def test_legacy_pointer_is_read_for_supersede_detection( + self, tmp_path: Path, store: StateStore + ) -> None: + """A pre-relocation install: the active task is known only via the + legacy pointer. Registering a new room must still supersede it.""" + register_task( + room_id="room-old", + description="old", + owner_id="owner-a", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + # Simulate the pre-relocation on-disk state: pointer at the legacy + # location only. + _legacy_pointer(tmp_path).write_text("room-old", encoding="utf-8") + _pointer(tmp_path).unlink() + + result = register_task( + room_id="room-new", + description="new", + owner_id="owner-b", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + assert result.outcome == "superseded" + assert result.superseded_task_id == "room-old" + + def test_reregistration_migrates_legacy_pointer( + self, tmp_path: Path, store: StateStore + ) -> None: + """Re-registering writes the new location and removes the legacy + file — the migration path for pre-relocation installs.""" + register_task( + room_id="room-1", + description="task", + owner_id="owner-a", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + _legacy_pointer(tmp_path).write_text("room-1", encoding="utf-8") + _pointer(tmp_path).unlink() + + register_task( + room_id="room-1", + description="task", + owner_id="owner-b", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + assert _pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-1" + assert not _legacy_pointer(tmp_path).exists() + + def test_row_without_pointer_restores_pointer( + self, tmp_path: Path, store: StateStore + ) -> None: + # H1: the row exists but the pointer write never happened. + register_task( + room_id="room-1", + description="task", + owner_id="owner-a", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + _pointer(tmp_path).unlink() + + result = register_task( + room_id="room-1", + description="task", + owner_id="owner-b", + agents=_gated_agents(), + project_dir=tmp_path, + store=store, + ) + + assert result.outcome == "re-registered" + assert _pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-1" + task = store.get_task("room-1") + assert task is not None + assert task.owner_id == "owner-b" + assert _task_row_count(store.db_path) == 1 + + +# --------------------------------------------------------------------------- +# read_room_pointer — dual-location read (canonical next-to-DB + legacy) +# --------------------------------------------------------------------------- + +class TestReadRoomPointer: + def test_reads_canonical_location_first(self, tmp_path: Path) -> None: + from codeband.state.registration import read_room_pointer + + state_dir = tmp_path / "state" + state_dir.mkdir() + (state_dir / ".codeband_room").write_text("room-new", encoding="utf-8") + _legacy_pointer(tmp_path).write_text("room-stale", encoding="utf-8") + assert read_room_pointer(tmp_path, state_dir) == "room-new" + + def test_legacy_fallback_resolves_with_deprecation_warning( + self, tmp_path: Path, capsys + ) -> None: + from codeband.state.registration import read_room_pointer + + state_dir = tmp_path / "state" + _legacy_pointer(tmp_path).write_text("room-legacy", encoding="utf-8") + assert read_room_pointer(tmp_path, state_dir) == "room-legacy" + err = capsys.readouterr().err + assert "legacy" in err + assert str(_legacy_pointer(tmp_path)) in err + assert str(state_dir / ".codeband_room") in err + + def test_legacy_fallback_warning_is_suppressible( + self, tmp_path: Path, capsys + ) -> None: + from codeband.state.registration import read_room_pointer + + _legacy_pointer(tmp_path).write_text("room-legacy", encoding="utf-8") + assert ( + read_room_pointer(tmp_path, tmp_path / "state", warn_legacy=False) + == "room-legacy" + ) + assert capsys.readouterr().err == "" + + def test_no_pointer_anywhere_returns_none(self, tmp_path: Path) -> None: + from codeband.state.registration import read_room_pointer + + assert read_room_pointer(tmp_path, tmp_path / "state") is None + + def test_cb_phase_resolves_task_via_legacy_pointer( + self, tmp_path: Path, store: StateStore, capsys + ) -> None: + """End-to-end through cb-phase's resolver: a pre-relocation repo + (legacy pointer only) still resolves, with the deprecation warning.""" + from codeband.cli.handoff import _resolve_task_id + + store.create_task(task_id="room-1", description="t", room_id="room-1") + # The store fixture's pointer dir is tmp_path/state — leave it empty. + _legacy_pointer(tmp_path).write_text("room-1", encoding="utf-8") + + task_id, error = _resolve_task_id(tmp_path, store, None) + assert (task_id, error) == ("room-1", None) + assert "legacy" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# required_verdicts — resolution, fail-loud validation, snapshot (Stage-2) +# --------------------------------------------------------------------------- + +class TestRequiredVerdicts: + def _register(self, tmp_path: Path, store: StateStore, agents, room: str = "room-1"): + return register_task( + room_id=room, + description="task", + owner_id="owner-1", + agents=agents, + project_dir=tmp_path, + store=store, + ) + + def test_absent_key_snapshots_default_list( + self, tmp_path: Path, store: StateStore + ) -> None: + # required_verdicts not set (None) → resolves to the full default. + self._register(tmp_path, store, _gated_agents()) + task = store.get_task("room-1") + assert task is not None + assert task.required_verdicts == ["verify", "review"] + + def test_explicit_list_snapshotted_verbatim( + self, tmp_path: Path, store: StateStore + ) -> None: + agents = _gated_agents(required_verdicts=["review", "verify"]) + self._register(tmp_path, store, agents) + task = store.get_task("room-1") + assert task is not None + assert task.required_verdicts == ["review", "verify"] # order preserved + + def test_empty_list_without_flag_fails_and_writes_nothing( + self, tmp_path: Path, store: StateStore + ) -> None: + agents = _gated_agents(required_verdicts=[]) + with pytest.raises(ValueError, match="allow_ungated_merge"): + self._register(tmp_path, store, agents) + assert store.get_task("room-1") is None + assert _task_row_count(store.db_path) == 0 + assert not _pointer(tmp_path).exists() + + def test_empty_list_with_ugly_flag_snapshots_empty( + self, tmp_path: Path, store: StateStore + ) -> None: + agents = _gated_agents(required_verdicts=[], allow_ungated_merge=True) + result = self._register(tmp_path, store, agents) + assert result.outcome == "registered" + task = store.get_task("room-1") + assert task is not None + assert task.required_verdicts == [] + + def test_fresh_default_without_verify_command_registers_without_verify( + self, tmp_path: Path, store: StateStore + ) -> None: + agents = AgentsConfig() # handoff_verify_command unset + result = self._register(tmp_path, store, agents) + + assert result.outcome == "registered" + task = store.get_task("room-1") + assert task is not None + assert "verify" not in task.required_verdicts + assert task.required_verdicts == ["review", "verify_acceptance"] + + def test_explicit_verify_without_command_fails_at_seed( + self, tmp_path: Path, store: StateStore + ) -> None: + agents = AgentsConfig(required_verdicts=["verify", "review"]) + with pytest.raises(ValueError, match="handoff_verify_command"): + self._register(tmp_path, store, agents) + assert _task_row_count(store.db_path) == 0 + assert not _pointer(tmp_path).exists() + + def test_unknown_verdict_fails_naming_the_entry( + self, tmp_path: Path, store: StateStore + ) -> None: + agents = _gated_agents(required_verdicts=["verify", "vibes"]) + with pytest.raises(ValueError, match="'vibes'"): + self._register(tmp_path, store, agents) + assert _task_row_count(store.db_path) == 0 + assert not _pointer(tmp_path).exists() + + def test_reregister_refreshes_snapshot_from_current_config( + self, tmp_path: Path, store: StateStore + ) -> None: + self._register(tmp_path, store, _gated_agents()) + assert store.get_task("room-1").required_verdicts == ["verify", "review"] + + # Config changed between registrations — a re-register of the same + # room re-resolves and overwrites the snapshot (consistent with + # re-register-updates-owner). + result = self._register( + tmp_path, store, _gated_agents(required_verdicts=["review"]) + ) + assert result.outcome == "re-registered" + task = store.get_task("room-1") + assert task.required_verdicts == ["review"] + # Description/status remain untouched by re-registration. + assert task.status == "active" + + def test_superseding_room_gets_its_own_fresh_snapshot( + self, tmp_path: Path, store: StateStore + ) -> None: + self._register(tmp_path, store, _gated_agents(), room="room-old") + result = self._register( + tmp_path, + store, + _gated_agents(required_verdicts=["review"]), + room="room-new", + ) + assert result.outcome == "superseded" + old = store.get_task("room-old") + new = store.get_task("room-new") + # The superseded row keeps its original snapshot; the new row carries + # the list resolved from current config. + assert old.status == "superseded" + assert old.required_verdicts == ["verify", "review"] + assert new.required_verdicts == ["review"] + + +# --------------------------------------------------------------------------- +# send_task — owner required, registration strictly before the task message +# --------------------------------------------------------------------------- + +@dataclass +class FakeIdentity: + id: str + name: str + + +@dataclass +class FakeIdentityResponse: + data: FakeIdentity + + +@dataclass +class FakeRoom: + id: str + + +@dataclass +class FakeRoomResponse: + data: FakeRoom + + +def _make_human_client(room_id: str) -> AsyncMock: + human_client = AsyncMock() + human_client.human_api_chats.create_my_chat_room.return_value = FakeRoomResponse( + data=FakeRoom(id=room_id) + ) + human_client.human_api_profile.get_my_profile.return_value = FakeIdentityResponse( + data=FakeIdentity(id="owner-1", name="Initiator") + ) + return human_client + + +def _make_client_factory(human_client: AsyncMock): + """AsyncRestClient replacement: human key → human client, else conductor.""" + conductor_client = AsyncMock() + conductor_client.agent_api_identity.get_agent_me.return_value = FakeIdentityResponse( + data=FakeIdentity(id="cond-0", name="Conductor") + ) + + def factory(api_key, base_url=None): + if api_key == "human-key": + return human_client + return conductor_client + + return factory + + +async def _run_send_task(human_client, sample_config, tmp_path: Path) -> None: + import os + + import thenvoi_rest + + from codeband.orchestration import kickoff + + factory = _make_client_factory(human_client) + with patch.dict(os.environ, {"BAND_API_KEY": "human-key"}): + original = thenvoi_rest.AsyncRestClient + thenvoi_rest.AsyncRestClient = factory + try: + await kickoff.send_task(sample_config, tmp_path, "implement feature X") + finally: + thenvoi_rest.AsyncRestClient = original + + +class TestSendTaskRegistration: + @pytest.mark.asyncio + async def test_owner_resolution_failure_aborts_before_message( + self, sample_config, sample_agent_config, tmp_path: Path + ) -> None: + sample_agent_config.to_yaml(tmp_path / "agent_config.yaml") + human_client = _make_human_client("room-123") + human_client.human_api_profile.get_my_profile.side_effect = RuntimeError( + "profile endpoint down" + ) + + with pytest.raises(RuntimeError, match="initiator"): + await _run_send_task(human_client, sample_config, tmp_path) + + # Aborted loudly before any participant add or message post … + human_client.human_api_participants.add_my_chat_participant.assert_not_called() + human_client.human_api_messages.send_my_chat_message.assert_not_called() + # … and before anything was registered. + assert not _ws_pointer(tmp_path).exists() + db_path = tmp_path / "workspace" / "state" / "orchestration.db" + assert not db_path.exists() or _task_row_count(db_path) == 0 + + @pytest.mark.asyncio + async def test_registration_ordered_before_message_post( + self, sample_config, sample_agent_config, tmp_path: Path, monkeypatch + ) -> None: + sample_agent_config.to_yaml(tmp_path / "agent_config.yaml") + human_client = _make_human_client("room-123") + + events: list[str] = [] + + from codeband.state import registration as registration_module + + real_register_task = registration_module.register_task + + def recording_register_task(**kwargs): + events.append("register") + return real_register_task(**kwargs) + + monkeypatch.setattr( + registration_module, "register_task", recording_register_task + ) + + async def recording_send_message(room_id, message): + events.append("message") + # The pointer and the tasks row must already exist when the task + # message (the agent-activation edge) is posted. + assert _ws_pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-123" + db_path = tmp_path / "workspace" / "state" / "orchestration.db" + task = StateStore(db_path).get_task("room-123") + assert task is not None + assert task.owner_id == "owner-1" + + human_client.human_api_messages.send_my_chat_message = AsyncMock( + side_effect=recording_send_message + ) + + await _run_send_task(human_client, sample_config, tmp_path) + + assert events == ["register", "message"] + + @pytest.mark.asyncio + async def test_workspace_env_var_wins_over_config_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """$WORKSPACE overrides relative workspace.path for the StateStore location. + + send_task() must route through resolve_workspace_path() (the ONE shared + rule) so that Docker containers with $WORKSPACE=/workspace write the task + row and pointer to the shared volume, not to a project-relative shadow. + """ + import os + + import thenvoi_rest + + from codeband.config import ( + AgentsConfig, + CodebandConfig, + RepoConfig, + WorkspaceConfig, + ) + from codeband.orchestration import kickoff + + workspace_dir = tmp_path / "custom_workspace" + workspace_dir.mkdir() + monkeypatch.setenv("WORKSPACE", str(workspace_dir)) + + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", branch="main"), + agents=AgentsConfig(handoff_verify_command="true"), + workspace=WorkspaceConfig(path=".codeband"), + ) + config.to_yaml(tmp_path / "codeband.yaml") + + # Minimal agent_config (just the conductor key send_task resolves) + from codeband.config import AgentCredentials, AgentConfigFile + agent_cfg = AgentConfigFile(agents={ + "conductor": AgentCredentials(agent_id="cond-0", api_key="key-conductor"), + }) + agent_cfg.to_yaml(tmp_path / "agent_config.yaml") + + human_client = _make_human_client("room-workspace-test") + factory = _make_client_factory(human_client) + + with patch.dict(os.environ, {"BAND_API_KEY": "human-key"}): + original = thenvoi_rest.AsyncRestClient + thenvoi_rest.AsyncRestClient = factory + try: + await kickoff.send_task(config, tmp_path, "workspace env test task") + finally: + thenvoi_rest.AsyncRestClient = original + + # DB must be written under $WORKSPACE, not under project_dir/.codeband/ + ws_db = workspace_dir / ".codeband" / "state" / "orchestration.db" + assert ws_db.exists(), f"Expected DB at {ws_db}" + task = StateStore(ws_db).get_task("room-workspace-test") + assert task is not None + + # Shadow path (project-relative) must be absent. + shadow_db = tmp_path / ".codeband" / "state" / "orchestration.db" + assert not shadow_db.exists(), ( + f"Shadow DB found at {shadow_db} — send_task() ignored $WORKSPACE" + ) + + +# --------------------------------------------------------------------------- +# cb register-task — thin CLI wrapper +# --------------------------------------------------------------------------- + +class TestRegisterTaskCli: + def test_success_exits_zero_and_registers(self, sample_config, tmp_path: Path) -> None: + sample_config.to_yaml(tmp_path / "codeband.yaml") + + runner = CliRunner() + result = runner.invoke(cb_cli, [ + "register-task", + "--room", "room-cli", + "--owner", "owner-9", + "--owner-handle", "yoni/peer", + "--description", "seeded by a peer", + "--dir", str(tmp_path), + ]) + + assert result.exit_code == 0, result.output + assert "Registered task room-cli" in result.output + assert _ws_pointer(tmp_path).read_text(encoding="utf-8").strip() == "room-cli" + task = StateStore( + tmp_path / "workspace" / "state" / "orchestration.db" + ).get_task("room-cli") + assert task is not None + assert task.owner_id == "owner-9" + assert task.owner_handle == "yoni/peer" + assert task.status == "active" + assert task.required_verdicts == ["review"] + + def test_missing_owner_exits_nonzero_writes_nothing( + self, sample_config, tmp_path: Path + ) -> None: + sample_config.to_yaml(tmp_path / "codeband.yaml") + + runner = CliRunner() + result = runner.invoke(cb_cli, [ + "register-task", + "--room", "room-cli", + "--description", "seeded by a peer", + "--dir", str(tmp_path), + ]) + + assert result.exit_code != 0 + assert not _ws_pointer(tmp_path).exists() + assert not (tmp_path / "workspace" / "state" / "orchestration.db").exists() + + def test_workspace_env_var_wins_over_config_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """$WORKSPACE overrides relative config.workspace.path for the store location. + + register-task must use resolve_workspace_path (the ONE shared rule) so + Docker containers with $WORKSPACE=/workspace don't open a shadow store + in a project-relative directory. + """ + from codeband.config import AgentsConfig, CodebandConfig, RepoConfig, WorkspaceConfig + + workspace_dir = tmp_path / "custom_workspace" + workspace_dir.mkdir() + monkeypatch.setenv("WORKSPACE", str(workspace_dir)) + + # Use a relative workspace.path so $WORKSPACE kicks in. + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", branch="main"), + agents=AgentsConfig(handoff_verify_command="true"), + workspace=WorkspaceConfig(path=".codeband"), + ) + config.to_yaml(tmp_path / "codeband.yaml") + + runner = CliRunner() + result = runner.invoke(cb_cli, [ + "register-task", + "--room", "room-ws", + "--owner", "owner-1", + "--description", "workspace env test", + "--dir", str(tmp_path), + ]) + + assert result.exit_code == 0, result.output + # DB must be under $WORKSPACE, not under project_dir/.codeband/ + ws_db = workspace_dir / ".codeband" / "state" / "orchestration.db" + assert ws_db.exists(), f"Expected DB at {ws_db}" + task = StateStore(ws_db).get_task("room-ws") + assert task is not None + assert task.owner_id == "owner-1" + # The shadow path (project-relative) must NOT have a DB. + shadow_db = tmp_path / ".codeband" / "state" / "orchestration.db" + assert not shadow_db.exists(), f"Shadow DB found at {shadow_db} — $WORKSPACE not honored" diff --git a/tests/test_rehydration.py b/tests/test_rehydration.py new file mode 100644 index 0000000..64cb53c --- /dev/null +++ b/tests/test_rehydration.py @@ -0,0 +1,241 @@ +"""Tests for universal agent rehydration (RFC Workstream 5).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from codeband.state.rehydration import ( + build_agent_recovery_context, + recover_for_reconnect, +) +from codeband.state.store import StateStore + + +def _seed(tmp_path): + """Seed a StateStore with one task and a spread of subtask states.""" + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task("task-1", "Build the widget pipeline", "room-1") + store.ensure_subtask("st-plan", "task-1", state="planned", assigned_worker="coder-claude_sdk-0") + store.ensure_subtask("st-rev", "task-1", state="review_pending", assigned_worker="coder-codex-0") + store.ensure_subtask("st-pass", "task-1", state="review_passed", assigned_worker="coder-codex-1") + store.ensure_subtask("st-accept", "task-1", state="acceptance_passed", assigned_worker="coder-claude_sdk-0") + store.ensure_subtask("st-merge", "task-1", state="merge_pending", assigned_worker="coder-claude_sdk-1") + store.ensure_subtask("st-rebase", "task-1", state="needs_rebase", assigned_worker="coder-codex-0") + # Terminal subtasks — must never appear in any recovery context. + store.ensure_subtask("st-done", "task-1", state="merged") + store.ensure_subtask("st-drop", "task-1", state="abandoned") + return store + + +def test_conductor_lists_all_non_terminal_as_table(tmp_path): + store = _seed(tmp_path) + ctx = asyncio.run(build_agent_recovery_context("conductor", store)) + assert ctx is not None + assert "| Subtask | State | Worker | PR |" in ctx + # All six non-terminal subtasks present... + for sid in ("st-plan", "st-rev", "st-pass", "st-accept", "st-merge", "st-rebase"): + assert sid in ctx + # ...and the two terminal ones excluded. + assert "st-done" not in ctx + assert "st-drop" not in ctx + + +def test_mergemaster_shows_merge_states_only(tmp_path): + store = _seed(tmp_path) + ctx = asyncio.run(build_agent_recovery_context("mergemaster", store)) + assert ctx is not None + assert "st-pass" in ctx # review_passed + assert "st-accept" in ctx # acceptance_passed — ready to queue + assert "st-merge" in ctx # merge_pending + assert "st-rebase" in ctx # needs_rebase — the merge gate's send-back + assert "st-rev" not in ctx # review_pending — not Mergemaster's concern + assert "st-plan" not in ctx + + +def test_reviewer_shows_only_review_pending(tmp_path): + store = _seed(tmp_path) + ctx = asyncio.run(build_agent_recovery_context("reviewer-codex-0", store)) + assert ctx is not None + assert "st-rev" in ctx + assert "st-pass" not in ctx + assert "st-merge" not in ctx + assert "st-plan" not in ctx + + +def test_planner_shows_active_task_description(tmp_path): + store = _seed(tmp_path) + ctx = asyncio.run(build_agent_recovery_context("planner-claude_sdk-0", store)) + assert ctx is not None + assert "Build the widget pipeline" in ctx + assert "task-1" in ctx + + +def test_plan_reviewer_shows_task_and_subtask_count(tmp_path): + store = _seed(tmp_path) + ctx = asyncio.run(build_agent_recovery_context("plan_reviewer-codex-0", store)) + assert ctx is not None + assert "Build the widget pipeline" in ctx + # Six non-terminal subtasks reference task-1. + assert "subtasks in flight: 6" in ctx + + +def test_verifier_shows_only_review_passed(tmp_path): + store = _seed(tmp_path) + ctx = asyncio.run(build_agent_recovery_context("verifier-codex-0", store)) + assert ctx is not None + assert "st-pass" in ctx # review_passed — awaiting the acceptance verdict + assert "st-rev" not in ctx # review_pending — the reviewer's concern + assert "st-accept" not in ctx # already accepted + assert "st-merge" not in ctx + + +def test_returns_none_when_nothing_relevant(tmp_path): + """Empty store → no recovery context for any role.""" + store = StateStore(tmp_path / "state" / "orchestration.db") + for key in ("conductor", "mergemaster", "reviewer-codex-0", + "planner-claude_sdk-0", "plan_reviewer-codex-0"): + assert asyncio.run(build_agent_recovery_context(key, store)) is None + + +def test_mergemaster_none_when_no_matching_states(tmp_path): + """A store with only review_pending work yields nothing for Mergemaster.""" + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task("task-1", "desc", "room-1") + store.ensure_subtask("st-rev", "task-1", state="review_pending") + assert asyncio.run(build_agent_recovery_context("mergemaster", store)) is None + # ...but the reviewer does see it. + assert asyncio.run(build_agent_recovery_context("reviewer-codex-0", store)) is not None + + +def test_unknown_role_returns_none(tmp_path): + store = _seed(tmp_path) + assert asyncio.run(build_agent_recovery_context("watchdog", store)) is None + + +def test_recover_for_reconnect_opens_store_and_builds(tmp_path): + _seed(tmp_path) + ctx = asyncio.run(recover_for_reconnect("conductor", tmp_path)) + assert ctx is not None + assert "st-plan" in ctx + + +def test_recover_for_reconnect_swallows_errors(tmp_path, monkeypatch): + """A rehydration failure returns None rather than raising.""" + import codeband.state.rehydration as rehydration + + async def _boom(agent_key, store): + raise RuntimeError("kaboom") + + monkeypatch.setattr(rehydration, "build_agent_recovery_context", _boom) + _seed(tmp_path) + assert asyncio.run(recover_for_reconnect("conductor", tmp_path)) is None + + +# ── reconnect-loop wiring (single-process _run_agent_forever) ──────────────── + +def test_run_agent_forever_threads_recovery_context(tmp_path): + """_run_agent_forever rebuilds recovery context and passes it to the factory.""" + from codeband.orchestration import runner + + _seed(tmp_path) + captured: dict = {} + + class _FakeAgent: + async def run(self): + # End the otherwise-infinite loop after the first cycle. + raise asyncio.CancelledError + + def make_agent(recovery_context=None): + captured["rc"] = recovery_context + return _FakeAgent() + + class _Activity: + def log(self, *a, **k): + pass + + with pytest.raises(asyncio.CancelledError): + asyncio.run( + runner._run_agent_forever( + make_agent, "conductor", _Activity(), + agent_key="conductor", workspace_path=tmp_path, + ) + ) + + assert captured["rc"] is not None + assert "st-plan" in captured["rc"] + + +def test_run_agent_forever_falls_back_to_none_on_rehydration_error(tmp_path, monkeypatch): + """A rehydration error must not break the reconnect loop; rc falls back to None.""" + from codeband.orchestration import runner + import codeband.state.rehydration as rehydration + + async def _boom(agent_key, workspace_path): + raise RuntimeError("kaboom") + + # Patch the symbol _run_agent_forever imports at call time. + monkeypatch.setattr(rehydration, "recover_for_reconnect", _boom) + + captured: dict = {} + + class _FakeAgent: + async def run(self): + raise asyncio.CancelledError + + def make_agent(recovery_context=None): + captured["rc"] = recovery_context + return _FakeAgent() + + class _Activity: + def log(self, *a, **k): + pass + + with pytest.raises(asyncio.CancelledError): + asyncio.run( + runner._run_agent_forever( + make_agent, "conductor", _Activity(), + agent_key="conductor", workspace_path=tmp_path, + ) + ) + + assert captured["rc"] is None + + +def test_run_agent_forever_survives_activity_log_oserror(tmp_path, monkeypatch): + """The crash handler's own AGENT_CRASH log line raising OSError must not + kill the reconnect-forever loop it reports on (S6-F9).""" + from codeband.orchestration import runner + + monkeypatch.setattr(runner, "_RECONNECT_BASE_DELAY_SECONDS", 0) + + cycles = {"n": 0} + + class _FakeAgent: + async def run(self): + cycles["n"] += 1 + if cycles["n"] >= 3: + raise asyncio.CancelledError # end the otherwise-infinite loop + raise RuntimeError("agent crashed") + + class _DiskFullActivity: + calls = 0 + + def log(self, *a, **k): + type(self).calls += 1 + raise OSError(28, "No space left on device") + + with pytest.raises(asyncio.CancelledError): + asyncio.run( + runner._run_agent_forever( + lambda recovery_context=None: _FakeAgent(), + "conductor", _DiskFullActivity(), + ) + ) + + # Two crashes (cycles 1 and 2) were each logged (and each log raised); + # cycle 3 raises CancelledError before any log call; no AGENT_RECONNECTED + # fires because no run() succeeded. The loop lived on regardless. + assert cycles["n"] == 3 + assert _DiskFullActivity.calls == 2 diff --git a/tests/test_reviewer.py b/tests/test_reviewer.py index 6d89e30..a21de3a 100644 --- a/tests/test_reviewer.py +++ b/tests/test_reviewer.py @@ -2,7 +2,9 @@ from __future__ import annotations +import gc import os +import weakref from pathlib import Path from codeband.config import ( @@ -40,7 +42,7 @@ def test_yaml_roundtrip(self, tmp_path: Path): agents=AgentsConfig( reviewers=ReviewersConfig( claude_sdk=PoolEntry(count=0), - codex=PoolEntry(count=2, model="gpt-5.4"), + codex=PoolEntry(count=2, model="gpt-5.5"), review_guidelines="Must have tests", ), ), @@ -50,7 +52,7 @@ def test_yaml_roundtrip(self, tmp_path: Path): loaded = CodebandConfig.from_yaml(yaml_path) assert loaded.agents.reviewers.claude_sdk.count == 0 assert loaded.agents.reviewers.codex.count == 2 - assert loaded.agents.reviewers.codex.model == "gpt-5.4" + assert loaded.agents.reviewers.codex.model == "gpt-5.5" assert loaded.agents.reviewers.review_guidelines == "Must have tests" @@ -169,6 +171,41 @@ def test_codex_conductor_uses_isolated_scratch_cwd(self, tmp_path: Path, monkeyp assert config.sandbox == "read-only" assert config.approval_policy == "never" + def test_codex_conductor_adapter_retains_scratch_cwd_after_runner_gc(self): + """The adapter path must keep the TemporaryDirectory owner alive.""" + from codeband.agents.conductor import CodexConductorRunner + + runner = CodexConductorRunner() + adapter = runner.adapter + scratch_owner_ref = weakref.ref(runner._scratch_dir) + runner_ref = weakref.ref(runner) + scratch_path = Path(adapter.config.cwd) + + del runner + gc.collect() + + assert runner_ref() is None + assert scratch_owner_ref() is not None + assert getattr(adapter, "_codeband_scratch_dir") is scratch_owner_ref() + assert scratch_path.is_dir() + assert adapter.config.cwd == str(scratch_path) + + def test_codex_conductor_scratch_cwd_cleans_up_with_adapter(self): + """Extending the scratch lifetime to the adapter must not leak it.""" + from codeband.agents.conductor import CodexConductorRunner + + adapter = CodexConductorRunner().adapter + scratch_owner_ref = weakref.ref(getattr(adapter, "_codeband_scratch_dir")) + scratch_path = Path(adapter.config.cwd) + + assert scratch_path.is_dir() + + del adapter + gc.collect() + + assert scratch_owner_ref() is None + assert not scratch_path.exists() + def test_codex_player_uses_full_access_sandbox(self, tmp_path: Path): """Codex coder needs full access for git operations and network.""" from codeband.agents.player_codex import CodexPlayerRunner diff --git a/tests/test_run_provisioning.py b/tests/test_run_provisioning.py new file mode 100644 index 0000000..8c61eb2 --- /dev/null +++ b/tests/test_run_provisioning.py @@ -0,0 +1,378 @@ +"""Tests for cb run session-agent provisioning wiring. + +Covers: mint-on-startup, skip-if-already-set, crash-recovery sweep, +clean-exit delete, and late room enrollment — all via CliRunner so the +full run() CLI path is exercised. +""" + +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from click.testing import CliRunner + +from codeband.cli import cli + + +def _make_mock_config(total_agents: int = 8) -> MagicMock: + config = MagicMock() + config.agents.total_agent_count.return_value = total_agents + config.band.rest_url = "https://band.example.com" + config.workspace.mode = "local" + return config + + +def _stale_ts() -> str: + """Timestamp older than the 900-second stale threshold.""" + old = datetime.now(timezone.utc) - timedelta(seconds=1000) + return old.isoformat() + + +# ─── Provisioning fires when BAND_API_KEY is set ────────────────────────────── + + +@patch("codeband.cli.load_config") +@patch("codeband.orchestration.runner.run_local", new_callable=AsyncMock) +@patch("codeband.cli._provision_coordinator_identity", new_callable=AsyncMock) +def test_run_provisions_session_agent_when_band_key_set( + mock_provision, + mock_run_local, + mock_load_config, + tmp_path, +): + """cb run calls _provision_coordinator_identity when BAND_API_KEY is present.""" + mock_load_config.return_value = _make_mock_config() + mock_run_local.return_value = None + mock_provision.return_value = ("agent-id-abc", "band-key-xyz") + + runner = CliRunner() + result = runner.invoke( + cli, + ["run", "--skip-preflight", "--dir", str(tmp_path)], + env={"BAND_API_KEY": "band-key-xyz"}, + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_provision.assert_awaited_once() + # First arg is config, second is project Path, third is the band key + assert mock_provision.call_args.args[2] == "band-key-xyz" + + +@patch("codeband.cli.load_config") +@patch("codeband.orchestration.runner.run_local", new_callable=AsyncMock) +@patch("codeband.cli._provision_coordinator_identity", new_callable=AsyncMock) +def test_run_skips_provisioning_when_key_already_set( + mock_provision, + mock_run_local, + mock_load_config, + tmp_path, +): + """cb run does NOT reprovision when CODEBAND_SESSION_AGENT_KEY is already set.""" + mock_load_config.return_value = _make_mock_config() + mock_run_local.return_value = None + + runner = CliRunner() + result = runner.invoke( + cli, + ["run", "--skip-preflight", "--dir", str(tmp_path)], + env={ + "BAND_API_KEY": "band-key-xyz", + "CODEBAND_SESSION_AGENT_KEY": "already-set-key", + }, + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_provision.assert_not_awaited() + + +@patch("codeband.cli.load_config") +@patch("codeband.orchestration.runner.run_local", new_callable=AsyncMock) +@patch("codeband.cli._provision_coordinator_identity", new_callable=AsyncMock) +def test_run_skips_provisioning_when_no_band_key( + mock_provision, + mock_run_local, + mock_load_config, + tmp_path, +): + """cb run silently skips provisioning when BAND_API_KEY is absent.""" + mock_load_config.return_value = _make_mock_config() + mock_run_local.return_value = None + + runner = CliRunner() + result = runner.invoke( + cli, + ["run", "--skip-preflight", "--dir", str(tmp_path)], + env={}, # no BAND_API_KEY + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_provision.assert_not_awaited() + + +# ─── Clean-exit cleanup ─────────────────────────────────────────────────────── + + +@patch("codeband.cli.load_config") +@patch("codeband.orchestration.runner.run_local", new_callable=AsyncMock) +@patch("codeband.cli._provision_coordinator_identity", new_callable=AsyncMock) +@patch( + "codeband.orchestration.session_agent.delete_session_agent", + new_callable=AsyncMock, +) +def test_run_deletes_session_agent_on_clean_exit( + mock_delete, + mock_provision, + mock_run_local, + mock_load_config, + tmp_path, +): + """Session agent is deleted after run_local returns (clean exit).""" + mock_load_config.return_value = _make_mock_config() + mock_run_local.return_value = None + mock_provision.return_value = ("agent-id-clean", "band-key-clean") + + runner = CliRunner() + result = runner.invoke( + cli, + ["run", "--skip-preflight", "--dir", str(tmp_path)], + env={"BAND_API_KEY": "band-key-clean"}, + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_delete.assert_awaited_once() + assert mock_delete.call_args.args[0] == "agent-id-clean" + assert mock_delete.call_args.kwargs["band_api_key"] == "band-key-clean" + + +@patch("codeband.cli.load_config") +@patch("codeband.orchestration.runner.run_local", new_callable=AsyncMock) +@patch("codeband.cli._provision_coordinator_identity", new_callable=AsyncMock) +@patch( + "codeband.orchestration.session_agent.delete_session_agent", + new_callable=AsyncMock, +) +def test_run_deletes_session_agent_even_when_run_local_errors( + mock_delete, + mock_provision, + mock_run_local, + mock_load_config, + tmp_path, +): + """Session agent cleanup runs in finally — fires even when run_local raises.""" + mock_load_config.return_value = _make_mock_config() + mock_run_local.side_effect = RuntimeError("orchestrator crashed") + mock_provision.return_value = ("agent-id-error", "band-key-err") + + runner = CliRunner() + result = runner.invoke( + cli, + ["run", "--skip-preflight", "--dir", str(tmp_path)], + env={"BAND_API_KEY": "band-key-err"}, + ) + + assert result.exit_code != 0 + # Cleanup must have fired despite the crash + mock_delete.assert_awaited_once() + assert mock_delete.call_args.args[0] == "agent-id-error" + + +# ─── Crash-recovery sweep at startup ───────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_provision_sweeps_dead_pid_agent_before_registering(tmp_path): + """_provision_coordinator_identity sweeps a dead-pid stale agent, then mints fresh. + + This exercises the real crash-recovery path: a previous run crashed, leaving + a session-agent marker with pid=999999999 (provably dead). The next run's + provisioning step must sweep it, then register a new agent. + """ + import json + + from codeband.cli import _provision_coordinator_identity + + # Write a real stale marker: dead pid (999999999) + fresh timestamp. + # Dead pid is the crash-recovery signal — the process died between heartbeats. + stale_agent_id = "crash-leftover-agent" + stale_marker = tmp_path / f"{stale_agent_id}.json" + stale_marker.write_text( + json.dumps( + { + "agent_id": stale_agent_id, + "agent_name": f"codeband-session-repo-{stale_agent_id}", + "pid": 999999999, # dead — provably not running + "last_heartbeat": datetime.now(timezone.utc).isoformat(), + "repo": "testrepo", + } + ), + encoding="utf-8", + ) + assert stale_marker.is_file() + + config = MagicMock() + config.band.rest_url = "https://x.com" + + sweep_list_resp = MagicMock() + stale_agent_obj = MagicMock() + stale_agent_obj.id = stale_agent_id + stale_agent_obj.name = f"codeband-session-repo-{stale_agent_id}" + sweep_list_resp.data = [stale_agent_obj] + + new_agent_obj = MagicMock() + new_agent_obj.id = "new-session-agent-id" + new_creds_obj = MagicMock() + new_creds_obj.api_key = "new-session-key" + register_resp = MagicMock() + register_resp.data.agent = new_agent_obj + register_resp.data.credentials = new_creds_obj + + profile_resp = MagicMock() + profile_resp.data.id = "operator-id" + + sweep_client = MagicMock() + sweep_client.human_api_agents.list_my_agents = AsyncMock(return_value=sweep_list_resp) + sweep_client.human_api_agents.delete_my_agent = AsyncMock() + sweep_client.human_api_agents.register_my_agent = AsyncMock(return_value=register_resp) + sweep_client.human_api_profile.get_my_profile = AsyncMock(return_value=profile_resp) + + # Override sessions_dir so markers go to tmp_path, not ~/.codeband/sessions + with ( + patch("thenvoi_rest.AsyncRestClient", return_value=sweep_client), + patch( + "codeband.orchestration.session_agent._sessions_dir", + return_value=tmp_path, + ), + patch("codeband.state.registration.read_room_pointer", return_value=None), + patch("codeband.state.registration.resolve_state_dir", return_value=tmp_path), + ): + agent_id, returned_key = await _provision_coordinator_identity( + config, tmp_path, "test-band-key" + ) + + # Stale agent was genuinely removed: REST delete called + marker gone + sweep_client.human_api_agents.delete_my_agent.assert_any_call( + stale_agent_id, + force=True, + ) + assert not stale_marker.is_file(), "Stale marker must be deleted by sweep" + + # Fresh agent was registered + assert agent_id == "new-session-agent-id" + assert os.environ.get("CODEBAND_SESSION_AGENT_KEY") == "new-session-key" + + # Cleanup the env side-effect so we don't pollute other tests + os.environ.pop("CODEBAND_SESSION_AGENT_KEY", None) + + +# ─── Late room enrollment ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_provision_enrolls_session_agent_in_active_room(tmp_path): + """_provision_coordinator_identity enrolls the minted agent in the active room.""" + from codeband.cli import _provision_coordinator_identity + + config = MagicMock() + config.band.rest_url = "https://x.com" + + sweep_list_resp = MagicMock() + sweep_list_resp.data = [] + + new_agent_obj = MagicMock() + new_agent_obj.id = "enrolled-session-agent-id" + new_creds_obj = MagicMock() + new_creds_obj.api_key = "enrolled-session-key" + register_resp = MagicMock() + register_resp.data.agent = new_agent_obj + register_resp.data.credentials = new_creds_obj + + profile_resp = MagicMock() + profile_resp.data.id = "operator-id" + + session_identity_resp = MagicMock() + session_identity_resp.data.id = "enrolled-session-agent-id" + + shared_client = MagicMock() + shared_client.human_api_agents.list_my_agents = AsyncMock(return_value=sweep_list_resp) + shared_client.human_api_agents.register_my_agent = AsyncMock(return_value=register_resp) + shared_client.human_api_profile.get_my_profile = AsyncMock(return_value=profile_resp) + shared_client.agent_api_identity.get_agent_me = AsyncMock(return_value=session_identity_resp) + shared_client.human_api_participants.add_my_chat_participant = AsyncMock() + + with ( + patch("thenvoi_rest.AsyncRestClient", return_value=shared_client), + patch( + "codeband.orchestration.session_agent._sessions_dir", + return_value=tmp_path, + ), + patch( + "codeband.state.registration.read_room_pointer", + return_value="active-room-uuid", + ), + patch("codeband.state.registration.resolve_state_dir", return_value=tmp_path), + ): + agent_id, _ = await _provision_coordinator_identity(config, tmp_path, "test-band-key") + + # Session agent was enrolled as a room participant + shared_client.human_api_participants.add_my_chat_participant.assert_awaited_once() + enroll_call = shared_client.human_api_participants.add_my_chat_participant.call_args + assert enroll_call.args[0] == "active-room-uuid" + assert enroll_call.kwargs["participant"].participant_id == "enrolled-session-agent-id" + + os.environ.pop("CODEBAND_SESSION_AGENT_KEY", None) + + +@pytest.mark.asyncio +async def test_provision_skips_enrollment_when_no_active_room(tmp_path): + """No active room → enrollment is skipped silently (non-fatal).""" + from codeband.cli import _provision_coordinator_identity + + config = MagicMock() + config.band.rest_url = "https://x.com" + + sweep_list_resp = MagicMock() + sweep_list_resp.data = [] + + new_agent_obj = MagicMock() + new_agent_obj.id = "no-room-agent-id" + new_creds_obj = MagicMock() + new_creds_obj.api_key = "no-room-key" + register_resp = MagicMock() + register_resp.data.agent = new_agent_obj + register_resp.data.credentials = new_creds_obj + + profile_resp = MagicMock() + profile_resp.data.id = "operator-id" + + shared_client = MagicMock() + shared_client.human_api_agents.list_my_agents = AsyncMock(return_value=sweep_list_resp) + shared_client.human_api_agents.register_my_agent = AsyncMock(return_value=register_resp) + shared_client.human_api_profile.get_my_profile = AsyncMock(return_value=profile_resp) + shared_client.human_api_participants.add_my_chat_participant = AsyncMock() + + with ( + patch("thenvoi_rest.AsyncRestClient", return_value=shared_client), + patch( + "codeband.orchestration.session_agent._sessions_dir", + return_value=tmp_path, + ), + patch( + "codeband.state.registration.read_room_pointer", + return_value=None, # no active room + ), + patch("codeband.state.registration.resolve_state_dir", return_value=tmp_path), + ): + agent_id, _ = await _provision_coordinator_identity(config, tmp_path, "test-band-key") + + # No enrollment attempted + shared_client.human_api_participants.add_my_chat_participant.assert_not_awaited() + assert agent_id == "no-room-agent-id" + + os.environ.pop("CODEBAND_SESSION_AGENT_KEY", None) diff --git a/tests/test_runner_patches.py b/tests/test_runner_patches.py index e115ad9..6d0698a 100644 --- a/tests/test_runner_patches.py +++ b/tests/test_runner_patches.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -285,7 +286,7 @@ async def clean_exit_run() -> None: target_reached.set() await asyncio.sleep(60) # block until cancelled - def make_agent() -> MagicMock: + def make_agent(recovery_context: str | None = None) -> MagicMock: agent, _ = self._make_agent_with_run(clean_exit_run) produced_agents.append(agent) return agent @@ -352,7 +353,7 @@ async def crashing_run() -> None: await asyncio.sleep(60) raise RuntimeError("simulated agent crash") - def make_agent() -> MagicMock: + def make_agent(recovery_context: str | None = None) -> MagicMock: agent, _ = self._make_agent_with_run(crashing_run) produced_agents.append(agent) return agent @@ -389,6 +390,61 @@ def make_agent() -> MagicMock: ) +class TestNativeSubjectIdStripping: + """The old `_patch_band_subject_id_bug` workaround was removed because + band-sdk >=0.2.11 strips `subject_id=None` natively before the API call. + This pins the BEHAVIOR (not the patch) so an SDK regression resurfaces + here instead of as runtime 422s from the memory API.""" + + @pytest.mark.asyncio + async def test_native_store_memory_strips_subject_id_none(self): + from thenvoi.runtime import tools as _tools_mod + + cls = getattr(_tools_mod, "AgentToolsRuntime", None) or getattr( + _tools_mod, "AgentTools", + ) + instance = _FakeAgentToolsInstance() + instance.rest = MagicMock() + response = MagicMock() + response.data = {"id": "mem_1"} + instance.rest.agent_api_memories.create_agent_memory = AsyncMock( + return_value=response, + ) + + await cls.store_memory( + instance, + "protocol code_review cid cr_1_r1 state findings_posted", + "working", "episodic", "agent", + "review done", + scope="organization", + subject_id=None, + ) + + call = instance.rest.agent_api_memories.create_agent_memory.await_args + request = call.kwargs["memory"] + assert "subject_id" not in request.model_fields_set, ( + "native band-sdk store_memory no longer strips subject_id=None — " + "the removed _patch_band_subject_id_bug workaround is needed again" + ) + + +class TestLocalRuntimePatchFailsLoud: + """An SDK whose local-runtime hooks can't even be imported must abort + startup — silently skipping the patch runs the fleet with PHX + auto-reconnect enabled, which corrupts the reconnect lifecycle.""" + + def test_import_error_raises_runtime_error(self, monkeypatch): + import sys + + # `None` in sys.modules makes `from thenvoi.client.streaming import + # client` raise ImportError — the same failure shape as an installed + # band-sdk 1.0.0, which renamed the thenvoi.* namespace away. + monkeypatch.setitem(sys.modules, "thenvoi.client.streaming", None) + + with pytest.raises(RuntimeError, match="band-sdk"): + _patch_band_local_runtime() + + class TestPhoenixReconnectOwnership: """Local mode must not let PHX run a hidden reconnect loop under Codeband.""" @@ -437,18 +493,47 @@ async def __aenter__(self): "_codeband_no_signal_handlers", False, ) is True - @pytest.mark.asyncio - async def test_local_patch_skips_existing_room_subscriptions_by_default( - self, monkeypatch, - ): +class TestSubscribeExistingSweep: + """Local-mode startup sweep: subscribe-by-default, store-scoped (finding 9). + + Matrix: fresh run / mid-task resume / stale rooms / store failure / + --fresh opt-out / deprecated env var. Filtering happens inside OUR + patched sweep — never via the SDK's ``RoomPresence(room_filter=...)``, + which also gates live ``room_added`` joins and would block new tasks. + """ + + @pytest.fixture(autouse=True) + def _sweep_settings(self, monkeypatch): + """Isolate the module-level sweep settings per test.""" + from codeband.orchestration import runner + + monkeypatch.setattr( + runner, "_local_sweep_settings", runner._LocalSweepSettings(), + ) + monkeypatch.delenv("CODEBAND_LOCAL_SUBSCRIBE_EXISTING", raising=False) + return runner._local_sweep_settings + + def _make_store(self, tmp_path: Path, active_rooms: list[str]) -> Path: + from codeband.state import StateStore + + db_path = tmp_path / "state" / "orchestration.db" + store = StateStore(db_path) + for room_id in active_rooms: + store.create_task( + task_id=room_id, description="task", room_id=room_id, + ) + return db_path + + async def _run_sweep(self, monkeypatch, participant_rooms: list[str]): + """Run the patched sweep against fake link + rooms; return subscribe calls.""" from thenvoi.runtime.presence import RoomPresence original_subscribe = RoomPresence._subscribe_to_existing_rooms - calls = [] + calls: list[str] = [] class FakeLink: async def subscribe_room(self, room_id): - calls.append(("subscribe", room_id)) + calls.append(room_id) try: _patch_band_local_runtime() @@ -456,54 +541,261 @@ async def subscribe_room(self, room_id): presence.link = FakeLink() presence.rooms = set() presence.on_room_joined = None - monkeypatch.delenv("CODEBAND_LOCAL_SUBSCRIBE_EXISTING", raising=False) - presence._list_existing_rooms = AsyncMock(return_value=[ - ("room-1", {}), - ("room-2", {}), - ]) + presence._list_existing_rooms = AsyncMock( + return_value=[(room_id, {}) for room_id in participant_rooms], + ) monkeypatch.setattr( "codeband.orchestration.runner.asyncio.sleep", AsyncMock(), ) - await RoomPresence._subscribe_to_existing_rooms(presence) finally: RoomPresence._subscribe_to_existing_rooms = original_subscribe + return calls, presence + + @pytest.mark.asyncio + async def test_fresh_run_subscribes_nothing_without_warning( + self, monkeypatch, tmp_path, caplog, _sweep_settings, + ): + """Store readable, zero active tasks, no rooms — normal, no warning.""" + _sweep_settings.state_db_path = self._make_store(tmp_path, []) + + with caplog.at_level("INFO", logger="codeband.orchestration.runner"): + calls, presence = await self._run_sweep(monkeypatch, []) - presence._list_existing_rooms.assert_not_awaited() assert calls == [] assert presence.rooms == set() + assert not [ + r for r in caplog.records + if r.name.startswith("codeband") and r.levelno >= logging.WARNING + ] @pytest.mark.asyncio - async def test_local_patch_can_serialize_existing_room_subscriptions( - self, monkeypatch, + async def test_mid_task_resume_rejoins_active_room( + self, monkeypatch, tmp_path, _sweep_settings, ): - from thenvoi.runtime.presence import RoomPresence + _sweep_settings.state_db_path = self._make_store(tmp_path, ["room-1"]) - original_subscribe = RoomPresence._subscribe_to_existing_rooms - calls = [] + calls, presence = await self._run_sweep(monkeypatch, ["room-1"]) - class FakeLink: - async def subscribe_room(self, room_id): - calls.append(("subscribe", room_id)) + assert calls == ["room-1"] + assert presence.rooms == {"room-1"} - try: - _patch_band_local_runtime() - presence = object.__new__(RoomPresence) - presence.link = FakeLink() - presence.rooms = set() - presence.on_room_joined = None - monkeypatch.setenv("CODEBAND_LOCAL_SUBSCRIBE_EXISTING", "1") - presence._list_existing_rooms = AsyncMock(return_value=[ - ("room-1", {}), - ("room-2", {}), - ]) - monkeypatch.setattr( - "codeband.orchestration.runner.asyncio.sleep", AsyncMock(), + @pytest.mark.asyncio + async def test_stale_rooms_skipped_with_info_line( + self, monkeypatch, tmp_path, caplog, _sweep_settings, + ): + """Rooms not tied to an active task are skipped, with one INFO count.""" + _sweep_settings.state_db_path = self._make_store(tmp_path, ["room-1"]) + + with caplog.at_level("INFO", logger="codeband.orchestration.runner"): + calls, presence = await self._run_sweep( + monkeypatch, ["room-1", "stale-a", "stale-b"], ) - await RoomPresence._subscribe_to_existing_rooms(presence) - finally: - RoomPresence._subscribe_to_existing_rooms = original_subscribe + assert calls == ["room-1"] + assert presence.rooms == {"room-1"} + skip_lines = [ + r for r in caplog.records + if r.levelno == logging.INFO and "Skipping 2" in r.getMessage() + ] + assert len(skip_lines) == 1 + + @pytest.mark.asyncio + async def test_store_failure_subscribes_all_with_error( + self, monkeypatch, tmp_path, caplog, _sweep_settings, + ): + """Unreadable store → loud ERROR, subscribe ALL (fail toward connectivity).""" + corrupt = tmp_path / "state" / "orchestration.db" + corrupt.parent.mkdir(parents=True) + corrupt.write_text("this is not a sqlite database, not even close") + _sweep_settings.state_db_path = corrupt + + with caplog.at_level("ERROR", logger="codeband.orchestration.runner"): + calls, _ = await self._run_sweep(monkeypatch, ["room-1", "room-2"]) + + assert calls == ["room-1", "room-2"] + errors = [ + r for r in caplog.records + if r.name == "codeband.orchestration.runner" + and r.levelno == logging.ERROR + ] + assert len(errors) == 1 + assert "ALL participant rooms" in errors[0].getMessage() + + @pytest.mark.asyncio + async def test_unresolved_state_dir_subscribes_all_with_error( + self, monkeypatch, caplog, _sweep_settings, + ): + assert _sweep_settings.state_db_path is None + + with caplog.at_level("ERROR", logger="codeband.orchestration.runner"): + calls, _ = await self._run_sweep(monkeypatch, ["room-1"]) + + assert calls == ["room-1"] + errors = [ + r for r in caplog.records + if r.name == "codeband.orchestration.runner" + and r.levelno == logging.ERROR + ] + assert len(errors) == 1 + assert "state dir unresolved" in errors[0].getMessage() + + @pytest.mark.asyncio + async def test_fresh_flag_skips_sweep_even_with_active_rooms( + self, monkeypatch, tmp_path, _sweep_settings, + ): + _sweep_settings.state_db_path = self._make_store(tmp_path, ["room-1"]) + _sweep_settings.fresh = True + + calls, presence = await self._run_sweep(monkeypatch, ["room-1"]) + + assert calls == [] + assert presence.rooms == set() + presence._list_existing_rooms.assert_not_awaited() + + @pytest.mark.asyncio + async def test_deprecated_env_var_warns_and_changes_nothing( + self, monkeypatch, tmp_path, caplog, _sweep_settings, + ): + """CODEBAND_LOCAL_SUBSCRIBE_EXISTING is ignored: one deprecation warning, + behavior identical to the store-scoped default.""" + monkeypatch.setenv("CODEBAND_LOCAL_SUBSCRIBE_EXISTING", "1") + _sweep_settings.state_db_path = self._make_store(tmp_path, ["room-1"]) + + with caplog.at_level("WARNING", logger="codeband.orchestration.runner"): + calls, _ = await self._run_sweep(monkeypatch, ["room-1", "stale-a"]) + + assert calls == ["room-1"] # stale room still skipped — default semantics + deprecations = [ + r for r in caplog.records if "deprecated" in r.getMessage() + ] + assert len(deprecations) == 1 + assert "--fresh" in deprecations[0].getMessage() + + +class TestSessionConfigWiring: + """agents.idle_resync_seconds reaches the SDK at the Agent.create seam.""" + + def test_create_band_agent_passes_idle_resync_seconds(self, tmp_path, monkeypatch): + import thenvoi + + from codeband.config import AgentCredentials + from codeband.orchestration.runner import _create_band_agent + + config = _make_config(tmp_path) + config.agents.idle_resync_seconds = 7 + captured = {} + + def fake_create(cls, **kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr( + thenvoi.Agent, "create", classmethod(fake_create), + ) + _create_band_agent( + adapter=MagicMock(), + creds=AgentCredentials(agent_id="agent-1", api_key="key-1"), + config=config, + ) + + session_config = captured["session_config"] + assert session_config.idle_resync_seconds == 7 + + def test_default_is_30(self, tmp_path): + config = _make_config(tmp_path) + assert config.agents.idle_resync_seconds == 30 + + +class TestSafeStopAgentFailsLoud: + """Teardown failures log at ERROR and verify closure — never raise.""" + + @pytest.mark.asyncio + async def test_stop_failure_logs_error_and_does_not_raise(self, caplog): + from codeband.orchestration.runner import _safe_stop_agent + + class FailingAgent: + _runtime = None + + async def stop(self, timeout=None): + raise RuntimeError("socket exploded") + + with caplog.at_level("ERROR", logger="codeband.orchestration.runner"): + await _safe_stop_agent(FailingAgent(), "conductor") + + errors = [ + r for r in caplog.records + if r.name == "codeband.orchestration.runner" + and r.levelno == logging.ERROR + ] + assert len(errors) == 1 + message = errors[0].getMessage() + assert "conductor" in message + assert "socket exploded" in message + + @pytest.mark.asyncio + async def test_leaked_connection_logs_second_error(self, caplog): + from codeband.orchestration.runner import _safe_stop_agent + + class LeakyLink: + _ws = object() # still holding a websocket after stop() + is_connected = True + + class LeakyRuntime: + _link = LeakyLink() + + class LeakyAgent: + _runtime = LeakyRuntime() + + async def stop(self, timeout=None): + return True # claims success but leaves the socket open + + with caplog.at_level("ERROR", logger="codeband.orchestration.runner"): + await _safe_stop_agent(LeakyAgent(), "mergemaster") + + errors = [ + r for r in caplog.records + if r.name == "codeband.orchestration.runner" + and r.levelno == logging.ERROR + ] + assert len(errors) == 1 + assert "mergemaster" in errors[0].getMessage() + assert "leaked" in errors[0].getMessage() + + @pytest.mark.asyncio + async def test_clean_stop_logs_nothing(self, caplog): + from codeband.orchestration.runner import _safe_stop_agent + + class ClosedLink: + _ws = None + is_connected = False + + class ClosedRuntime: + _link = ClosedLink() + + class CleanAgent: + _runtime = ClosedRuntime() + + async def stop(self, timeout=None): + return True + + with caplog.at_level("ERROR", logger="codeband.orchestration.runner"): + await _safe_stop_agent(CleanAgent(), "conductor") + + assert not [ + r for r in caplog.records + if r.name.startswith("codeband") and r.levelno >= logging.ERROR + ] + + @pytest.mark.asyncio + async def test_cancellation_propagates(self): + from codeband.orchestration.runner import _safe_stop_agent + + class CancelledAgent: + _runtime = None + + async def stop(self, timeout=None): + raise asyncio.CancelledError - assert calls == [("subscribe", "room-1"), ("subscribe", "room-2")] - assert presence.rooms == {"room-1", "room-2"} + with pytest.raises(asyncio.CancelledError): + await _safe_stop_agent(CancelledAgent(), "conductor") diff --git a/tests/test_session.py b/tests/test_session.py index 62d0b93..a44f934 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -359,12 +359,63 @@ async def test_close_agent_prefers_sdk_stop_over_legacy_close(self): agent = MagicMock() agent.stop = AsyncMock(return_value=True) agent.close = AsyncMock() + agent._runtime = None # nothing leaked — closure verification stays quiet await supervisor._close_agent(agent) agent.stop.assert_awaited_once_with(timeout=2.0) agent.close.assert_not_awaited() + @pytest.mark.asyncio + async def test_close_agent_failure_logs_error_and_does_not_raise(self, caplog): + """Teardown failures are loud (ERROR + worker id), never fatal.""" + import logging + + supervisor = self._build_supervisor(AsyncMock()) + agent = MagicMock() + agent.stop = AsyncMock(side_effect=RuntimeError("socket exploded")) + agent._runtime = None + + with caplog.at_level("ERROR", logger="codeband.session.supervisor"): + await supervisor._close_agent(agent) + + errors = [ + r for r in caplog.records + if r.name == "codeband.session.supervisor" + and r.levelno == logging.ERROR + ] + assert len(errors) == 1 + assert "coder-claude_sdk-0" in errors[0].getMessage() + assert "socket exploded" in errors[0].getMessage() + + @pytest.mark.asyncio + async def test_close_agent_leak_verification_fires(self, caplog): + """A still-open websocket after stop() gets its own ERROR line.""" + import logging + + class LeakyLink: + _ws = object() + is_connected = True + + class LeakyRuntime: + _link = LeakyLink() + + supervisor = self._build_supervisor(AsyncMock()) + agent = MagicMock() + agent.stop = AsyncMock(return_value=True) + agent._runtime = LeakyRuntime() + + with caplog.at_level("ERROR", logger="codeband.session.supervisor"): + await supervisor._close_agent(agent) + + errors = [ + r for r in caplog.records + if r.name == "codeband.session.supervisor" + and r.levelno == logging.ERROR + ] + assert len(errors) == 1 + assert "leaked" in errors[0].getMessage() + @pytest.mark.asyncio async def test_infinite_restart_until_cancelled(self): """Supervisor keeps restarting until the task is cancelled. @@ -488,3 +539,61 @@ def raising_recovery(*args, **kwargs): assert len(recovery_records) >= 2, ( "expected at least 2 cycles to fail recovery context" ) + + +class TestSupervisorBookkeepingIsBestEffort: + """Bookkeeping writes inside the supervision loop must not kill it (S6-F9).""" + + @pytest.mark.asyncio + async def test_loop_survives_activity_and_identity_oserror(self, tmp_path: Path): + """OSError from activity.log AND identity.save (full disk, unwritable + state dir) degrades to warnings; the reconnect-forever loop lives.""" + from codeband.session.supervisor import WorkerSupervisor + + call_count = 0 + target_reached = asyncio.Event() + + async def mock_run(): + nonlocal call_count + call_count += 1 + if call_count >= 3: + target_reached.set() + await asyncio.sleep(60) # block until cancelled + raise RuntimeError("session crashed") + + activity = MagicMock() + activity.log = MagicMock(side_effect=OSError(28, "No space left on device")) + + supervisor = WorkerSupervisor( + worker_id="coder-claude_sdk-0", + agent_id="agent-123", + create_agent_fn=AsyncMock( + return_value=MagicMock(run=mock_run, close=AsyncMock()), + ), + state_dir=tmp_path, + worktree_path=tmp_path, + restart_delay_seconds=0.0, + activity=activity, + ) + + with patch("codeband.session.supervisor.build_recovery_context", return_value="ctx"): + with patch.object( + WorkerIdentity, "save", side_effect=OSError(28, "No space left"), + ): + with patch.object(WorkerIdentity, "load", return_value=None): + task = asyncio.create_task(supervisor.run()) + try: + await asyncio.wait_for(target_reached.wait(), timeout=2.0) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert call_count == 3, ( + "supervisor must keep restarting when its own bookkeeping writes fail" + ) + # The bookkeeping was attempted (and failed) every cycle — proving the + # failures were swallowed rather than the calls skipped. + assert activity.log.call_count >= 3 diff --git a/tests/test_session_agent.py b/tests/test_session_agent.py new file mode 100644 index 0000000..d156eb2 --- /dev/null +++ b/tests/test_session_agent.py @@ -0,0 +1,763 @@ +"""Tests for orchestration/session_agent.py and the kickoff enrollment path.""" + +from __future__ import annotations + +import asyncio +import json +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from codeband.orchestration.session_agent import ( + _HEARTBEAT_INTERVAL_SECONDS, + _STALE_THRESHOLD_SECONDS, + delete_session_agent, + enroll_session_agent_in_room, + is_stale, + read_marker, + register_session_agent, + start_heartbeat_loop, + sweep_stale_session_agents, + write_heartbeat, +) + + +# ─── Helpers ────────────────────────────────────────────────────────────────── + + +def _fresh_ts() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _stale_ts() -> str: + """Timestamp older than _STALE_THRESHOLD_SECONDS.""" + old = datetime.now(timezone.utc) - timedelta(seconds=_STALE_THRESHOLD_SECONDS + 60) + return old.isoformat() + + +def _make_marker( + tmp_path: Path, + agent_id: str, + *, + ts: str | None = None, + pid: int | None = None, +) -> Path: + marker = { + "agent_id": agent_id, + "agent_name": f"codeband-session-repo-{agent_id[:4]}", + "pid": pid if pid is not None else os.getpid(), + "last_heartbeat": ts if ts is not None else _fresh_ts(), + "repo": "testrepo", + } + path = tmp_path / f"{agent_id}.json" + path.write_text(json.dumps(marker), encoding="utf-8") + return path + + +# ─── Threshold constants ─────────────────────────────────────────────────────── + + +def test_stale_threshold_is_900s(): + assert _STALE_THRESHOLD_SECONDS == 900 + + +def test_heartbeat_interval_is_300s(): + assert _HEARTBEAT_INTERVAL_SECONDS == 300 + + +# ─── is_stale ───────────────────────────────────────────────────────────────── + + +def test_is_stale_no_marker(): + assert is_stale(None) is True + + +def test_is_stale_old_timestamp(tmp_path): + marker = { + "agent_id": "abc", + "pid": os.getpid(), + "last_heartbeat": _stale_ts(), + } + assert is_stale(marker) is True + + +def test_is_stale_fresh_marker_live_pid(tmp_path): + marker = { + "agent_id": "abc", + "pid": os.getpid(), + "last_heartbeat": _fresh_ts(), + } + assert is_stale(marker) is False + + +def test_is_stale_dead_pid(): + # Use a pid that is very unlikely to be alive: 0 is invalid, use a probe + # on a safely non-existent pid (os.kill raises ProcessLookupError). + marker = { + "agent_id": "abc", + "pid": 999999999, + "last_heartbeat": _fresh_ts(), + } + # 999999999 is above Linux's pid_max (4194304) and definitely not alive. + assert is_stale(marker) is True + + +def test_is_stale_missing_ts(): + assert is_stale({"agent_id": "x", "pid": os.getpid()}) is True + + +def test_is_stale_malformed_ts(): + marker = {"agent_id": "x", "pid": os.getpid(), "last_heartbeat": "not-a-date"} + assert is_stale(marker) is True + + +# ─── write_heartbeat / read_marker ──────────────────────────────────────────── + + +def test_write_and_read_heartbeat(tmp_path): + agent_id = "agent-aabbccdd" + path = write_heartbeat( + agent_id, "codeband-session-repo-aabb", pid=os.getpid(), repo="repo", + sessions_dir=tmp_path, + ) + assert path.is_file() + data = read_marker(agent_id, sessions_dir=tmp_path) + assert data is not None + assert data["agent_id"] == agent_id + assert data["repo"] == "repo" + assert data["pid"] == os.getpid() + ts = datetime.fromisoformat(data["last_heartbeat"]) + assert (datetime.now(timezone.utc) - ts).total_seconds() < 5 + + +def test_write_heartbeat_updates_timestamp(tmp_path): + agent_id = "agent-update" + write_heartbeat(agent_id, "n", pid=1, repo="r", sessions_dir=tmp_path) + # Force an old timestamp into the file + path = tmp_path / f"{agent_id}.json" + old = json.loads(path.read_text()) + old["last_heartbeat"] = _stale_ts() + path.write_text(json.dumps(old)) + + write_heartbeat(agent_id, "n", pid=os.getpid(), repo="r", sessions_dir=tmp_path) + data = read_marker(agent_id, sessions_dir=tmp_path) + ts = datetime.fromisoformat(data["last_heartbeat"]) + assert (datetime.now(timezone.utc) - ts).total_seconds() < 5 + + +def test_read_marker_missing(tmp_path): + assert read_marker("nonexistent", sessions_dir=tmp_path) is None + + +def test_read_marker_corrupt(tmp_path): + (tmp_path / "badagent.json").write_text("not json") + assert read_marker("badagent", sessions_dir=tmp_path) is None + + +# ─── register_session_agent ─────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_register_writes_marker_and_returns_creds(tmp_path): + mock_client = MagicMock() + agent_obj = MagicMock() + agent_obj.id = "agent-id-123" + creds_obj = MagicMock() + creds_obj.api_key = "sk-test-key" + response = MagicMock() + response.data.agent = agent_obj + response.data.credentials = creds_obj + mock_client.human_api_agents.register_my_agent = AsyncMock(return_value=response) + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + agent_id, api_key = await register_session_agent( + "owner-42", + "testrepo", + rest_url="https://example.com", + band_api_key="band-key", + sessions_dir=tmp_path, + ) + + assert agent_id == "agent-id-123" + assert api_key == "sk-test-key" + marker = read_marker("agent-id-123", sessions_dir=tmp_path) + assert marker is not None + assert marker["agent_id"] == "agent-id-123" + assert marker["repo"] == "testrepo" + + +@pytest.mark.asyncio +async def test_register_rollback_on_marker_failure(tmp_path): + """If marker write fails, the just-created agent is deleted before raising.""" + mock_client = MagicMock() + agent_obj = MagicMock() + agent_obj.id = "agent-id-rollback" + creds_obj = MagicMock() + creds_obj.api_key = "sk-rollback" + response = MagicMock() + response.data.agent = agent_obj + response.data.credentials = creds_obj + mock_client.human_api_agents.register_my_agent = AsyncMock(return_value=response) + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + # Make sessions_dir a FILE so mkdir fails → write_heartbeat raises + fake_sessions = tmp_path / "sessions" + fake_sessions.write_text("not a dir") + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + with pytest.raises(RuntimeError, match="agent rolled back"): + await register_session_agent( + "owner-42", + "testrepo", + rest_url="https://example.com", + band_api_key="band-key", + sessions_dir=fake_sessions, + ) + + mock_client.human_api_agents.delete_my_agent.assert_awaited_once_with( + "agent-id-rollback", force=True, + ) + + +# ─── sweep_stale_session_agents ─────────────────────────────────────────────── + + +def _make_agent(agent_id: str, name: str) -> MagicMock: + a = MagicMock() + a.id = agent_id + a.name = name + return a + + +@pytest.mark.asyncio +async def test_sweep_deletes_stale_old_timestamp(tmp_path): + agent_id = "stale-old" + marker_path = _make_marker(tmp_path, agent_id, ts=_stale_ts()) + assert marker_path.is_file() + + mock_client = MagicMock() + agents = [_make_agent(agent_id, f"codeband-session-repo-{agent_id}")] + mock_client.human_api_agents.list_my_agents = AsyncMock( + return_value=MagicMock(data=agents) + ) + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + deleted = await sweep_stale_session_agents( + band_api_key="k", + rest_url="https://x.com", + sessions_dir=tmp_path, + ) + + assert agent_id in deleted + mock_client.human_api_agents.delete_my_agent.assert_awaited_once_with( + agent_id, force=True, + ) + # Marker file must be removed — genuine removal, not just a sweep call + assert not marker_path.is_file() + + +@pytest.mark.asyncio +async def test_sweep_deletes_agent_with_no_marker(tmp_path): + agent_id = "no-marker-agent" + # No marker file — missing marker is a real stale condition (crash with + # lost workspace, or agent that never wrote its first heartbeat). + assert not (tmp_path / f"{agent_id}.json").is_file() + + mock_client = MagicMock() + agents = [_make_agent(agent_id, f"codeband-session-repo-{agent_id}")] + mock_client.human_api_agents.list_my_agents = AsyncMock( + return_value=MagicMock(data=agents) + ) + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + deleted = await sweep_stale_session_agents( + band_api_key="k", + rest_url="https://x.com", + sessions_dir=tmp_path, + ) + + assert agent_id in deleted + mock_client.human_api_agents.delete_my_agent.assert_awaited_once_with( + agent_id, force=True, + ) + + +@pytest.mark.asyncio +async def test_sweep_deletes_dead_pid(tmp_path): + # Real dead-pid stale condition: marker has a fresh timestamp but the PID + # is provably not alive. This matches the crash-recovery scenario where + # the process died between heartbeat ticks (dead PID + recent timestamp). + agent_id = "dead-pid-agent" + marker_path = _make_marker(tmp_path, agent_id, pid=999999999) # definitely not alive + assert marker_path.is_file() + + mock_client = MagicMock() + agents = [_make_agent(agent_id, f"codeband-session-repo-{agent_id}")] + mock_client.human_api_agents.list_my_agents = AsyncMock( + return_value=MagicMock(data=agents) + ) + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + deleted = await sweep_stale_session_agents( + band_api_key="k", + rest_url="https://x.com", + sessions_dir=tmp_path, + ) + + assert agent_id in deleted + mock_client.human_api_agents.delete_my_agent.assert_awaited_once_with( + agent_id, force=True, + ) + # Genuine removal: marker must be gone from disk, not just the agent deleted + assert not marker_path.is_file() + + +@pytest.mark.asyncio +async def test_sweep_spares_fresh_marker(tmp_path): + agent_id = "fresh-agent" + _make_marker(tmp_path, agent_id, ts=_fresh_ts(), pid=os.getpid()) + + mock_client = MagicMock() + agents = [_make_agent(agent_id, f"codeband-session-repo-{agent_id}")] + mock_client.human_api_agents.list_my_agents = AsyncMock( + return_value=MagicMock(data=agents) + ) + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + deleted = await sweep_stale_session_agents( + band_api_key="k", + rest_url="https://x.com", + sessions_dir=tmp_path, + ) + + assert agent_id not in deleted + mock_client.human_api_agents.delete_my_agent.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_sweep_skips_current_session(tmp_path): + agent_id = "current-session" + _make_marker(tmp_path, agent_id, ts=_stale_ts()) # stale BUT is current + + mock_client = MagicMock() + agents = [_make_agent(agent_id, f"codeband-session-repo-{agent_id}")] + mock_client.human_api_agents.list_my_agents = AsyncMock( + return_value=MagicMock(data=agents) + ) + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + deleted = await sweep_stale_session_agents( + band_api_key="k", + rest_url="https://x.com", + current_agent_id=agent_id, + sessions_dir=tmp_path, + ) + + assert agent_id not in deleted + mock_client.human_api_agents.delete_my_agent.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_sweep_ignores_non_session_agents(tmp_path): + """Non-codeband-session-* agents are never touched.""" + mock_client = MagicMock() + agents = [ + _make_agent("fleet-agent", "conductor"), + _make_agent("other-agent", "codeband-coder-claude-0"), + ] + mock_client.human_api_agents.list_my_agents = AsyncMock( + return_value=MagicMock(data=agents) + ) + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + deleted = await sweep_stale_session_agents( + band_api_key="k", + rest_url="https://x.com", + sessions_dir=tmp_path, + ) + + assert deleted == [] + mock_client.human_api_agents.delete_my_agent.assert_not_awaited() + + +# ─── start_heartbeat_loop ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_heartbeat_loop_updates_marker(tmp_path): + """The loop writes the marker immediately on the first tick.""" + # Patch _HEARTBEAT_INTERVAL_SECONDS to 0 so the loop fires without sleeping + with patch("codeband.orchestration.session_agent._HEARTBEAT_INTERVAL_SECONDS", 0): + task = asyncio.create_task( + start_heartbeat_loop( + "loop-agent", "codeband-session-repo-test", "repo", + sessions_dir=tmp_path, + ) + ) + # Let the loop run one iteration + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + data = read_marker("loop-agent", sessions_dir=tmp_path) + assert data is not None + assert data["agent_id"] == "loop-agent" + + +# ─── kickoff enrollment path ────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_send_task_enrolls_session_agent(tmp_path, monkeypatch): + """Room creation WITH CODEBAND_SESSION_AGENT_KEY → session agent added as participant.""" + from codeband.orchestration import kickoff + + monkeypatch.setenv("BAND_API_KEY", "human-key") + monkeypatch.setenv("CODEBAND_SESSION_AGENT_KEY", "session-key") + + # Build a minimal config mock + config = MagicMock() + config.band.rest_url = "https://band.example.com" + config.repo.url = "https://github.com/org/repo" + config.repo.branch = "main" + config.workspace.path = str(tmp_path / "workspace") + + # Mock all REST client interactions + conductor_creds = MagicMock() + conductor_creds.api_key = "conductor-key" + conductor_creds.agent_id = "conductor-id" + + agent_config_mock = MagicMock() + agent_config_mock.get.return_value = conductor_creds + + conductor_identity_resp = MagicMock() + conductor_identity_resp.data.id = "conductor-id" + conductor_identity_resp.data.name = "Conductor" + + session_identity_resp = MagicMock() + session_identity_resp.data.id = "session-agent-id" + + room_resp = MagicMock() + room_resp.data.id = "room-uuid" + + profile_resp = MagicMock() + profile_resp.data.id = "human-owner-id" + profile_resp.data.name = "human" + + human_client = MagicMock() + human_client.human_api_chats.create_my_chat_room = AsyncMock(return_value=room_resp) + human_client.human_api_profile.get_my_profile = AsyncMock(return_value=profile_resp) + human_client.human_api_participants.add_my_chat_participant = AsyncMock() + human_client.human_api_messages.send_my_chat_message = AsyncMock() + + conductor_client = MagicMock() + conductor_client.agent_api_identity.get_agent_me = AsyncMock( + return_value=conductor_identity_resp + ) + + session_client = MagicMock() + session_client.agent_api_identity.get_agent_me = AsyncMock( + return_value=session_identity_resp + ) + + def _make_client(api_key, base_url): + if api_key == "human-key": + return human_client + if api_key == "conductor-key": + return conductor_client + if api_key == "session-key": + return session_client + return MagicMock() + + store_mock = MagicMock() + registration_mock = MagicMock() + registration_mock.superseded_task_id = None + store_mock.__enter__ = MagicMock(return_value=store_mock) + store_mock.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(kickoff, "load_agent_config", return_value=agent_config_mock), + patch("thenvoi_rest.AsyncRestClient", side_effect=_make_client), + patch("codeband.state.StateStore", return_value=store_mock), + patch("codeband.state.registration.register_task", return_value=registration_mock), + patch("codeband.orchestration.kickoff._cleanup_rooms", new=AsyncMock()), + ): + await kickoff.send_task(config, tmp_path, "Do the thing") + + # Should have been called twice: once for conductor, once for session agent + assert human_client.human_api_participants.add_my_chat_participant.await_count == 2 + calls = human_client.human_api_participants.add_my_chat_participant.call_args_list + participant_ids = [call.kwargs["participant"].participant_id for call in calls] + assert "conductor-id" in participant_ids + assert "session-agent-id" in participant_ids + + +@pytest.mark.asyncio +async def test_send_task_no_session_key_no_extra_participant(tmp_path, monkeypatch): + """Room creation WITHOUT CODEBAND_SESSION_AGENT_KEY → only conductor is added.""" + from codeband.orchestration import kickoff + + monkeypatch.setenv("BAND_API_KEY", "human-key") + monkeypatch.delenv("CODEBAND_SESSION_AGENT_KEY", raising=False) + + config = MagicMock() + config.band.rest_url = "https://band.example.com" + config.repo.url = "https://github.com/org/repo" + config.repo.branch = "main" + config.workspace.path = str(tmp_path / "workspace") + + conductor_creds = MagicMock() + conductor_creds.api_key = "conductor-key" + conductor_identity_resp = MagicMock() + conductor_identity_resp.data.id = "conductor-id" + conductor_identity_resp.data.name = "Conductor" + + room_resp = MagicMock() + room_resp.data.id = "room-uuid" + + profile_resp = MagicMock() + profile_resp.data.id = "human-id" + profile_resp.data.name = "human" + + human_client = MagicMock() + human_client.human_api_chats.create_my_chat_room = AsyncMock(return_value=room_resp) + human_client.human_api_profile.get_my_profile = AsyncMock(return_value=profile_resp) + human_client.human_api_participants.add_my_chat_participant = AsyncMock() + human_client.human_api_messages.send_my_chat_message = AsyncMock() + + conductor_client = MagicMock() + conductor_client.agent_api_identity.get_agent_me = AsyncMock( + return_value=conductor_identity_resp + ) + + agent_config_mock = MagicMock() + agent_config_mock.get.return_value = conductor_creds + + def _make_client(api_key, base_url): + if api_key == "human-key": + return human_client + return conductor_client + + store_mock = MagicMock() + registration_mock = MagicMock() + registration_mock.superseded_task_id = None + + with ( + patch.object(kickoff, "load_agent_config", return_value=agent_config_mock), + patch("thenvoi_rest.AsyncRestClient", side_effect=_make_client), + patch("codeband.state.StateStore", return_value=store_mock), + patch("codeband.state.registration.register_task", return_value=registration_mock), + patch("codeband.orchestration.kickoff._cleanup_rooms", new=AsyncMock()), + ): + await kickoff.send_task(config, tmp_path, "Do the thing") + + # Only conductor, no session agent + assert human_client.human_api_participants.add_my_chat_participant.await_count == 1 + call = human_client.human_api_participants.add_my_chat_participant.call_args + assert call.kwargs["participant"].participant_id == "conductor-id" + + +@pytest.mark.asyncio +async def test_send_task_enrollment_failure_raises(tmp_path, monkeypatch): + """Enrollment failure → raises loud, does not silently continue.""" + from codeband.orchestration import kickoff + + monkeypatch.setenv("BAND_API_KEY", "human-key") + monkeypatch.setenv("CODEBAND_SESSION_AGENT_KEY", "session-key") + + config = MagicMock() + config.band.rest_url = "https://band.example.com" + config.repo.url = "https://github.com/org/repo" + config.repo.branch = "main" + config.workspace.path = str(tmp_path / "workspace") + + conductor_creds = MagicMock() + conductor_creds.api_key = "conductor-key" + conductor_identity_resp = MagicMock() + conductor_identity_resp.data.id = "conductor-id" + conductor_identity_resp.data.name = "Conductor" + + session_identity_resp = MagicMock() + session_identity_resp.data.id = "session-agent-id" + + room_resp = MagicMock() + room_resp.data.id = "room-uuid" + + profile_resp = MagicMock() + profile_resp.data.id = "human-id" + profile_resp.data.name = "human" + + human_client = MagicMock() + human_client.human_api_chats.create_my_chat_room = AsyncMock(return_value=room_resp) + human_client.human_api_profile.get_my_profile = AsyncMock(return_value=profile_resp) + # First call (conductor) succeeds, second call (session agent) fails + human_client.human_api_participants.add_my_chat_participant = AsyncMock( + side_effect=[None, RuntimeError("participant add failed")] + ) + human_client.human_api_messages.send_my_chat_message = AsyncMock() + + conductor_client = MagicMock() + conductor_client.agent_api_identity.get_agent_me = AsyncMock( + return_value=conductor_identity_resp + ) + + session_client = MagicMock() + session_client.agent_api_identity.get_agent_me = AsyncMock( + return_value=session_identity_resp + ) + + agent_config_mock = MagicMock() + agent_config_mock.get.return_value = conductor_creds + + def _make_client(api_key, base_url): + if api_key == "human-key": + return human_client + if api_key == "conductor-key": + return conductor_client + if api_key == "session-key": + return session_client + return MagicMock() + + store_mock = MagicMock() + registration_mock = MagicMock() + registration_mock.superseded_task_id = None + + with ( + patch.object(kickoff, "load_agent_config", return_value=agent_config_mock), + patch("thenvoi_rest.AsyncRestClient", side_effect=_make_client), + patch("codeband.state.StateStore", return_value=store_mock), + patch("codeband.state.registration.register_task", return_value=registration_mock), + patch("codeband.orchestration.kickoff._cleanup_rooms", new=AsyncMock()), + ): + with pytest.raises(RuntimeError, match="enroll session agent"): + await kickoff.send_task(config, tmp_path, "Do the thing") + + +# ─── delete_session_agent ───────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_delete_session_agent_removes_agent_and_marker(tmp_path): + agent_id = "del-agent-01" + marker_path = write_heartbeat( + agent_id, "codeband-session-repo-del", pid=os.getpid(), repo="repo", + sessions_dir=tmp_path, + ) + assert marker_path.is_file() + + mock_client = MagicMock() + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + await delete_session_agent( + agent_id, + band_api_key="band-k", + rest_url="https://x.com", + sessions_dir=tmp_path, + ) + + mock_client.human_api_agents.delete_my_agent.assert_awaited_once_with( + agent_id, force=True, + ) + # Genuine removal: marker is gone from disk + assert not marker_path.is_file() + + +@pytest.mark.asyncio +async def test_delete_session_agent_tolerates_missing_marker(tmp_path): + """delete_session_agent succeeds even when the local marker was already removed.""" + agent_id = "del-agent-no-marker" + # No marker written — clean-exit path after crash reboot already swept it + + mock_client = MagicMock() + mock_client.human_api_agents.delete_my_agent = AsyncMock() + + with patch("thenvoi_rest.AsyncRestClient", return_value=mock_client): + await delete_session_agent( + agent_id, + band_api_key="band-k", + rest_url="https://x.com", + sessions_dir=tmp_path, + ) + + mock_client.human_api_agents.delete_my_agent.assert_awaited_once_with( + agent_id, force=True, + ) + + +# ─── enroll_session_agent_in_room ───────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_enroll_session_agent_in_room_adds_participant(tmp_path): + session_identity_resp = MagicMock() + session_identity_resp.data.id = "session-participant-id" + + session_client = MagicMock() + session_client.agent_api_identity.get_agent_me = AsyncMock( + return_value=session_identity_resp + ) + + human_client = MagicMock() + human_client.human_api_participants.add_my_chat_participant = AsyncMock() + + def _make_client(api_key, base_url): + if api_key == "session-key": + return session_client + return human_client + + with patch("thenvoi_rest.AsyncRestClient", side_effect=_make_client): + await enroll_session_agent_in_room( + session_agent_key="session-key", + band_api_key="human-key", + room_id="room-uuid-42", + rest_url="https://x.com", + ) + + human_client.human_api_participants.add_my_chat_participant.assert_awaited_once() + call_args = human_client.human_api_participants.add_my_chat_participant.call_args + assert call_args.args[0] == "room-uuid-42" + assert call_args.kwargs["participant"].participant_id == "session-participant-id" + + +@pytest.mark.asyncio +async def test_enroll_session_agent_in_room_propagates_failure(): + """Enrollment failure propagates so the caller can treat it as non-fatal.""" + session_identity_resp = MagicMock() + session_identity_resp.data.id = "session-participant-id" + + session_client = MagicMock() + session_client.agent_api_identity.get_agent_me = AsyncMock( + return_value=session_identity_resp + ) + + human_client = MagicMock() + human_client.human_api_participants.add_my_chat_participant = AsyncMock( + side_effect=RuntimeError("403 Forbidden") + ) + + def _make_client(api_key, base_url): + if api_key == "session-key": + return session_client + return human_client + + with patch("thenvoi_rest.AsyncRestClient", side_effect=_make_client): + with pytest.raises(RuntimeError, match="403"): + await enroll_session_agent_in_room( + session_agent_key="session-key", + band_api_key="human-key", + room_id="room-x", + rest_url="https://x.com", + ) diff --git a/tests/test_setup.py b/tests/test_setup.py index 41722da..0fa68e8 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass, field from unittest.mock import AsyncMock @@ -17,6 +18,7 @@ PoolEntry, RepoConfig, ReviewersConfig, + VerifiersConfig, ) @@ -70,6 +72,11 @@ def _make_config( agents=AgentsConfig( planners=FrameworkPool(claude_sdk=PoolEntry(count=1)), plan_reviewers=PlanReviewersConfig(codex=PoolEntry(count=1)), + # Verifiers pinned INERT: these tests exercise the registration / + # drift / delete *machinery* against a known 8-agent world, not the + # verifier feature (the active product default activates 2 verifiers + # → 10 agents; verifier registration is covered in test_verifier.py). + verifiers=VerifiersConfig(), coders=FrameworkPool( claude_sdk=PoolEntry(count=claude_coders), codex=PoolEntry(count=codex_coders), @@ -82,15 +89,17 @@ def _make_config( ) -# The default cross-model config has 8 agents: +# The `_make_config()` helper (verifiers pinned INERT) has 8 agents: # conductor, mergemaster, planner-claude_sdk-0, plan_reviewer-codex-0, # coder-claude_sdk-0, coder-codex-0, reviewer-claude_sdk-0, reviewer-codex-0. +# (The product default activates 2 verifiers on top of these → 10.) # # Build the platform-side fakes with the **expected** descriptions so drift # detection does not see spurious drift in tests that exercise the happy path. # Drift-specific tests construct their own fakes with intentionally divergent # descriptions. + def _platform_agents_for(config: CodebandConfig) -> list[FakeAgent]: """Build FakeAgent records that mirror what `_expected_agents` produces.""" from codeband.orchestration.setup import _expected_agents @@ -121,16 +130,19 @@ def _expected_desc_for(config: CodebandConfig, agent_name: str) -> str: return desc return "" -_DEFAULT_AGENT_CONFIG = AgentConfigFile(agents={ - "conductor": AgentCredentials(agent_id="cond-0", api_key="key-cond"), - "mergemaster": AgentCredentials(agent_id="mm-0", api_key="key-mm"), - "planner-claude_sdk-0": AgentCredentials(agent_id="pl-c-0", api_key="key-pl-c0"), - "plan_reviewer-codex-0": AgentCredentials(agent_id="pr-x-0", api_key="key-pr-x0"), - "coder-claude_sdk-0": AgentCredentials(agent_id="co-c-0", api_key="key-co-c0"), - "coder-codex-0": AgentCredentials(agent_id="co-x-0", api_key="key-co-x0"), - "reviewer-claude_sdk-0": AgentCredentials(agent_id="re-c-0", api_key="key-re-c0"), - "reviewer-codex-0": AgentCredentials(agent_id="re-x-0", api_key="key-re-x0"), -}) + +_DEFAULT_AGENT_CONFIG = AgentConfigFile( + agents={ + "conductor": AgentCredentials(agent_id="cond-0", api_key="key-cond"), + "mergemaster": AgentCredentials(agent_id="mm-0", api_key="key-mm"), + "planner-claude_sdk-0": AgentCredentials(agent_id="pl-c-0", api_key="key-pl-c0"), + "plan_reviewer-codex-0": AgentCredentials(agent_id="pr-x-0", api_key="key-pr-x0"), + "coder-claude_sdk-0": AgentCredentials(agent_id="co-c-0", api_key="key-co-c0"), + "coder-codex-0": AgentCredentials(agent_id="co-x-0", api_key="key-co-x0"), + "reviewer-claude_sdk-0": AgentCredentials(agent_id="re-c-0", api_key="key-re-c0"), + "reviewer-codex-0": AgentCredentials(agent_id="re-x-0", api_key="key-re-x0"), + } +) def test_expected_agent_descriptions_have_discovery_tokens(): @@ -179,9 +191,11 @@ def test_oversized_description_raises_at_build_time(monkeypatch): from codeband.orchestration import setup as setup_mod # Inflate one singleton's description past the limit. - bloated = ("X" * 600) + bloated = "X" * 600 monkeypatch.setitem( - setup_mod._SINGLETON_AGENTS, "conductor", ("Conductor", bloated), + setup_mod._SINGLETON_AGENTS, + "conductor", + ("Conductor", bloated), ) with pytest.raises(ValueError, match=r"exceeds Band.ai's 500-char limit"): @@ -224,18 +238,22 @@ async def test_registers_missing_agents(self, tmp_path): # descriptions so drift detection does not flag them for re-register. existing_agents = [ FakeAgent( - id="cond-0", name="Conductor", + id="cond-0", + name="Conductor", description=_expected_desc_for(config, "Conductor"), ), FakeAgent( - id="mm-0", name="Mergemaster", + id="mm-0", + name="Mergemaster", description=_expected_desc_for(config, "Mergemaster"), ), ] - existing_config = AgentConfigFile(agents={ - "conductor": AgentCredentials(agent_id="cond-0", api_key="key-cond"), - "mergemaster": AgentCredentials(agent_id="mm-0", api_key="key-mm"), - }) + existing_config = AgentConfigFile( + agents={ + "conductor": AgentCredentials(agent_id="cond-0", api_key="key-cond"), + "mergemaster": AgentCredentials(agent_id="mm-0", api_key="key-mm"), + } + ) existing_config.to_yaml(tmp_path / "agent_config.yaml") register_count = 0 @@ -251,9 +269,7 @@ async def fake_register(*, agent, **kwargs): ) client = AsyncMock() - client.human_api_agents.list_my_agents.return_value = FakeListResponse( - data=existing_agents - ) + client.human_api_agents.list_my_agents.return_value = FakeListResponse(data=existing_agents) client.human_api_agents.register_my_agent.side_effect = fake_register await register_all_agents(config, tmp_path, client=client) @@ -278,47 +294,53 @@ async def test_excess_coders_deleted(self, tmp_path): # is excess regardless of description; Watchdog is legacy by name). existing_agents = [ FakeAgent( - id="cond-0", name="Conductor", + id="cond-0", + name="Conductor", description=_expected_desc_for(config, "Conductor"), ), FakeAgent( - id="mm-0", name="Mergemaster", + id="mm-0", + name="Mergemaster", description=_expected_desc_for(config, "Mergemaster"), ), FakeAgent( - id="pl-c-0", name="Planner-Claude-0", + id="pl-c-0", + name="Planner-Claude-0", description=_expected_desc_for(config, "Planner-Claude-0"), ), FakeAgent( - id="pr-x-0", name="Plan-Reviewer-Codex-0", + id="pr-x-0", + name="Plan-Reviewer-Codex-0", description=_expected_desc_for(config, "Plan-Reviewer-Codex-0"), ), FakeAgent( - id="co-c-0", name="Coder-Claude-0", + id="co-c-0", + name="Coder-Claude-0", description=_expected_desc_for(config, "Coder-Claude-0"), ), FakeAgent(id="co-c-1", name="Coder-Claude-1"), # excess FakeAgent( - id="re-c-0", name="Reviewer-Claude-0", + id="re-c-0", + name="Reviewer-Claude-0", description=_expected_desc_for(config, "Reviewer-Claude-0"), ), - FakeAgent(id="wd-0", name="Watchdog"), # legacy + FakeAgent(id="wd-0", name="Watchdog"), # legacy ] - existing_config = AgentConfigFile(agents={ - "conductor": AgentCredentials(agent_id="cond-0", api_key="key-cond"), - "mergemaster": AgentCredentials(agent_id="mm-0", api_key="key-mm"), - "planner-claude_sdk-0": AgentCredentials(agent_id="pl-c-0", api_key="k"), - "plan_reviewer-codex-0": AgentCredentials(agent_id="pr-x-0", api_key="k"), - "coder-claude_sdk-0": AgentCredentials(agent_id="co-c-0", api_key="k"), - "coder-claude_sdk-1": AgentCredentials(agent_id="co-c-1", api_key="k"), - "reviewer-claude_sdk-0": AgentCredentials(agent_id="re-c-0", api_key="k"), - }) + existing_config = AgentConfigFile( + agents={ + "conductor": AgentCredentials(agent_id="cond-0", api_key="key-cond"), + "mergemaster": AgentCredentials(agent_id="mm-0", api_key="key-mm"), + "planner-claude_sdk-0": AgentCredentials(agent_id="pl-c-0", api_key="k"), + "plan_reviewer-codex-0": AgentCredentials(agent_id="pr-x-0", api_key="k"), + "coder-claude_sdk-0": AgentCredentials(agent_id="co-c-0", api_key="k"), + "coder-claude_sdk-1": AgentCredentials(agent_id="co-c-1", api_key="k"), + "reviewer-claude_sdk-0": AgentCredentials(agent_id="re-c-0", api_key="k"), + } + ) existing_config.to_yaml(tmp_path / "agent_config.yaml") client = AsyncMock() - client.human_api_agents.list_my_agents.return_value = FakeListResponse( - data=existing_agents - ) + client.human_api_agents.list_my_agents.return_value = FakeListResponse(data=existing_agents) client.human_api_agents.delete_my_agent.return_value = FakeDeleteResponse( id="deleted", name="deleted" ) @@ -338,13 +360,11 @@ async def test_excess_coders_deleted(self, tmp_path): class TestSetupDriftDetection: """When the registered description on Band.ai drifts from the canonical - description in setup.py, the platform agent must be deleted and a fresh - one registered (Band.ai has no in-place agent-update API, so re-registration - is the only way to fix drift). The local cred for that key is also dropped - so the new agent gets fresh credentials.""" + description in setup.py, setup-agents warns but preserves the live + platform agent and local credentials.""" @pytest.mark.asyncio - async def test_drift_triggers_delete_and_reregister(self, tmp_path): + async def test_drift_warns_and_preserves_credentials(self, tmp_path, caplog): from codeband.orchestration.setup import register_all_agents config = _make_config() @@ -364,60 +384,29 @@ async def test_drift_triggers_delete_and_reregister(self, tmp_path): _DEFAULT_AGENT_CONFIG.to_yaml(tmp_path / "agent_config.yaml") - register_count = 0 - - async def fake_register(*, agent, **kwargs): - nonlocal register_count - register_count += 1 - return FakeRegisterResponse( - data=FakeRegisterData( - agent=FakeAgent( - id=f"re-registered-{register_count}", - name=agent.name, - description=agent.description, - ), - credentials=FakeCredentials( - api_key=f"new-key-{register_count}", - ), - ) - ) - client = AsyncMock() client.human_api_agents.list_my_agents.return_value = FakeListResponse( data=platform, ) - client.human_api_agents.register_my_agent.side_effect = fake_register - client.human_api_agents.delete_my_agent.return_value = FakeDeleteResponse( - id="deleted", name="deleted", - ) - await register_all_agents(config, tmp_path, client=client) + with caplog.at_level(logging.WARNING, logger="codeband.orchestration.setup"): + await register_all_agents(config, tmp_path, client=client) - # Exactly the drifting agent should be deleted from Band.ai. - deleted_ids = { - call.args[0] - for call in client.human_api_agents.delete_my_agent.call_args_list - } - assert deleted_ids == {"co-x-0"} + # Description-only drift is non-destructive: the live agent stays in place. + client.human_api_agents.delete_my_agent.assert_not_called() + client.human_api_agents.register_my_agent.assert_not_called() - # And exactly one fresh registration happened (the replacement). - assert client.human_api_agents.register_my_agent.call_count == 1 - registered_name = client.human_api_agents.register_my_agent.call_args[1][ - "agent" - ].name - assert registered_name == "Coder-Codex-0" + assert any( + record.levelno == logging.WARNING + and record.name == "codeband.orchestration.setup" + and "Coder-Codex-0" in record.message + for record in caplog.records + ) - # The local agent_config now has the new ID and key for that role. + # The local agent_config keeps the existing ID and key for that role. result = AgentConfigFile.from_yaml(tmp_path / "agent_config.yaml") - assert result.agents["coder-codex-0"].agent_id != "co-x-0" - assert result.agents["coder-codex-0"].agent_id.startswith("re-registered-") - # The api_key must also be the freshly-issued one, not the old key. - # This proves `existing_config.agents.pop(matching_key, None)` actually - # ran. If a regression removed that pop, the agent_id assertion above - # would still pass (the main loop overwrites on register), but the - # api_key would silently leak through unchanged from the old creds. - assert result.agents["coder-codex-0"].api_key.startswith("new-key-") - assert result.agents["coder-codex-0"].api_key != "key-co-x0" + assert result.agents["coder-codex-0"].agent_id == "co-x-0" + assert result.agents["coder-codex-0"].api_key == "key-co-x0" # Other agents are unchanged. assert result.agents["conductor"].agent_id == "cond-0" assert result.agents["coder-claude_sdk-0"].agent_id == "co-c-0" @@ -471,7 +460,10 @@ async def test_detect_drift_false_skips_drift_correction(self, tmp_path): ) await register_all_agents( - config, tmp_path, client=client, detect_drift=False, + config, + tmp_path, + client=client, + detect_drift=False, ) # No delete and no re-register: the drifting agent stays as-is. @@ -485,6 +477,171 @@ async def test_detect_drift_false_skips_drift_correction(self, tmp_path): assert result.agents["coder-codex-0"].api_key == "key-co-x0" +class TestSetupDeleteFailure: + """When a CREDENTIAL_MISMATCH delete fails, registration must be skipped + and the failure surfaced — never silently re-attempting a doomed register.""" + + @pytest.mark.asyncio + async def test_mismatch_delete_failure_skips_registration_and_raises(self, tmp_path): + """Primary regression: mismatch + failed delete must not call _register_agent + for the contested name, and must raise RuntimeError mentioning that name.""" + from codeband.orchestration.setup import register_all_agents + + config = _make_config() + + # Platform has Coder-Claude-0 with id "platform-id"; + # local config has id "local-id" — credential mismatch. + mismatched_agent = FakeAgent( + id="platform-id", + name="Coder-Claude-0", + description=_expected_desc_for(config, "Coder-Claude-0"), + ) + other_agents = [a for a in _platform_agents_for(config) if a.name != "Coder-Claude-0"] + + mismatched_config = AgentConfigFile( + agents={ + **_DEFAULT_AGENT_CONFIG.agents, + "coder-claude_sdk-0": AgentCredentials(agent_id="local-id", api_key="key-co-c0"), + } + ) + mismatched_config.to_yaml(tmp_path / "agent_config.yaml") + + delete_error = RuntimeError("permission denied") + + async def fail_only_mismatch_delete(agent_id, **kwargs): + if agent_id == "platform-id": + raise delete_error + return FakeDeleteResponse(id=agent_id, name="deleted") + + client = AsyncMock() + client.human_api_agents.list_my_agents.return_value = FakeListResponse( + data=[mismatched_agent] + other_agents, + ) + client.human_api_agents.delete_my_agent.side_effect = fail_only_mismatch_delete + + with pytest.raises(RuntimeError, match="Coder-Claude-0"): + await register_all_agents(config, tmp_path, client=client) + + # Key assertion: _register_agent was NOT called for the contested name. + registered_names = [ + call[1]["agent"].name + for call in client.human_api_agents.register_my_agent.call_args_list + ] + assert "Coder-Claude-0" not in registered_names + + @pytest.mark.asyncio + async def test_mismatch_delete_failure_does_not_block_other_registrations(self, tmp_path): + """Other agents still succeed even when one mismatch-delete fails.""" + from codeband.orchestration.setup import register_all_agents + + config = _make_config() + + # Platform has Coder-Claude-0 (mismatch) but not Coder-Codex-0 (missing). + mismatched_agent = FakeAgent( + id="platform-id", + name="Coder-Claude-0", + description=_expected_desc_for(config, "Coder-Claude-0"), + ) + other_agents = [ + a + for a in _platform_agents_for(config) + if a.name not in {"Coder-Claude-0", "Coder-Codex-0"} + ] + + # Local config: Coder-Claude-0 has wrong id; Coder-Codex-0 entirely missing. + partial_config = AgentConfigFile( + agents={k: v for k, v in _DEFAULT_AGENT_CONFIG.agents.items() if k != "coder-codex-0"} + ) + partial_config.agents["coder-claude_sdk-0"] = AgentCredentials( + agent_id="local-id", + api_key="key-co-c0", + ) + partial_config.to_yaml(tmp_path / "agent_config.yaml") + + async def fail_only_mismatch_delete(agent_id, **kwargs): + if agent_id == "platform-id": + raise RuntimeError("permission denied") + return FakeDeleteResponse(id=agent_id, name="deleted") + + register_count = 0 + + async def fake_register(*, agent, **kwargs): + nonlocal register_count + register_count += 1 + return FakeRegisterResponse( + data=FakeRegisterData( + agent=FakeAgent(id=f"new-{register_count}", name=agent.name), + credentials=FakeCredentials(api_key=f"newkey-{register_count}"), + ) + ) + + client = AsyncMock() + client.human_api_agents.list_my_agents.return_value = FakeListResponse( + data=[mismatched_agent] + other_agents, + ) + client.human_api_agents.delete_my_agent.side_effect = fail_only_mismatch_delete + client.human_api_agents.register_my_agent.side_effect = fake_register + + with pytest.raises(RuntimeError, match="Coder-Claude-0"): + await register_all_agents(config, tmp_path, client=client) + + # Coder-Codex-0 was registered (it was genuinely missing). + registered_names = [ + call[1]["agent"].name + for call in client.human_api_agents.register_my_agent.call_args_list + ] + assert "Coder-Codex-0" in registered_names + assert "Coder-Claude-0" not in registered_names + + # The credential file reflects partial success: Coder-Codex-0 has a new id. + result = AgentConfigFile.from_yaml(tmp_path / "agent_config.yaml") + assert result.agents["coder-codex-0"].agent_id.startswith("new-") + # Coder-Claude-0 is absent from the file (was skipped). + assert "coder-claude_sdk-0" not in result.agents + + @pytest.mark.asyncio + async def test_mismatch_delete_failure_raises_in_detect_drift_false_mode(self, tmp_path): + """detect_drift=False (auto-bootstrap) also raises on mismatch + failed delete.""" + from codeband.orchestration.setup import register_all_agents + + config = _make_config() + + mismatched_agent = FakeAgent( + id="platform-id", + name="Coder-Claude-0", + description=_expected_desc_for(config, "Coder-Claude-0"), + ) + other_agents = [a for a in _platform_agents_for(config) if a.name != "Coder-Claude-0"] + + mismatched_config = AgentConfigFile( + agents={ + **_DEFAULT_AGENT_CONFIG.agents, + "coder-claude_sdk-0": AgentCredentials(agent_id="local-id", api_key="key-co-c0"), + } + ) + mismatched_config.to_yaml(tmp_path / "agent_config.yaml") + + async def fail_only_mismatch_delete(agent_id, **kwargs): + if agent_id == "platform-id": + raise RuntimeError("permission denied") + return FakeDeleteResponse(id=agent_id, name="deleted") + + client = AsyncMock() + client.human_api_agents.list_my_agents.return_value = FakeListResponse( + data=[mismatched_agent] + other_agents, + ) + client.human_api_agents.delete_my_agent.side_effect = fail_only_mismatch_delete + + with pytest.raises(RuntimeError, match="Coder-Claude-0"): + await register_all_agents(config, tmp_path, client=client, detect_drift=False) + + registered_names = [ + call[1]["agent"].name + for call in client.human_api_agents.register_my_agent.call_args_list + ] + assert "Coder-Claude-0" not in registered_names + + class TestSetupFreshInstall: """When no agents exist, register everything from scratch.""" @@ -513,7 +670,7 @@ async def fake_register(*, agent, **kwargs): await register_all_agents(config, tmp_path, client=client) - # Default config has 8 agents total. + # The `_make_config()` helper pins verifiers inert → 8 agents total. assert client.human_api_agents.register_my_agent.call_count == 8 result = AgentConfigFile.from_yaml(tmp_path / "agent_config.yaml") diff --git a/tests/test_state_store.py b/tests/test_state_store.py new file mode 100644 index 0000000..a161bc8 --- /dev/null +++ b/tests/test_state_store.py @@ -0,0 +1,734 @@ +"""Tests for the durable SQLite state store (RFC Workstream 1 / Phase 1).""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from codeband.state import StateStore, SubtaskRow, TaskRow + + +@pytest.fixture +def store(tmp_path: Path) -> StateStore: + """A StateStore backed by an isolated DB under tmp_path.""" + return StateStore(tmp_path / "state" / "orchestration.db") + + +def _table_names(db_path: Path) -> set[str]: + conn = sqlite3.connect(db_path) + try: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + finally: + conn.close() + return {name for (name,) in rows} + + +def test_schema_created_on_init(tmp_path: Path) -> None: + db_path = tmp_path / "state" / "orchestration.db" + StateStore(db_path) + + assert db_path.exists() + tables = _table_names(db_path) + assert {"tasks", "subtask_states", "transition_log"} <= tables + + +def test_init_is_idempotent(tmp_path: Path) -> None: + db_path = tmp_path / "state" / "orchestration.db" + StateStore(db_path) + # Re-opening the same DB must not raise (CREATE TABLE IF NOT EXISTS). + StateStore(db_path) + assert {"tasks", "subtask_states", "transition_log"} <= _table_names(db_path) + + +def test_create_task_then_get(store: StateStore) -> None: + store.create_task(task_id="room-1", description="do the thing", room_id="room-1") + + task = store.get_task("room-1") + assert isinstance(task, TaskRow) + assert task.task_id == "room-1" + assert task.description == "do the thing" + assert task.room_id == "room-1" + assert task.status == "active" + assert task.created_at # ISO-8601 UTC timestamp populated + + +def test_list_active_task_room_ids_filters_on_status(store: StateStore) -> None: + store.create_task(task_id="t-1", description="live", room_id="room-1") + store.create_task( + task_id="t-2", description="done", room_id="room-2", status="superseded", + ) + store.create_task(task_id="t-3", description="also live", room_id="room-3") + + assert sorted(store.list_active_task_room_ids()) == ["room-1", "room-3"] + + +def test_list_active_task_room_ids_empty_store(store: StateStore) -> None: + assert store.list_active_task_room_ids() == [] + + +def test_clear_task_records_resets_room_state_and_is_idempotent(store: StateStore) -> None: + store.create_task(task_id="old-room", description="old", room_id="old-room") + store.ensure_subtask("st-1", "old-room", state="in_progress") + + store.clear_task_records() + store.clear_task_records() + + assert store.get_task("old-room") is None + assert store.list_active_task_room_ids() == [] + assert store.list_active_subtasks() == [] + + store.create_task(task_id="fresh-room", description="fresh", room_id="fresh-room") + + assert store.list_active_task_room_ids() == ["fresh-room"] + + +def test_create_task_with_owner_id_round_trips(store: StateStore) -> None: + store.create_task( + task_id="room-1", + description="do the thing", + room_id="room-1", + owner_id="initiator-7", + ) + + task = store.get_task("room-1") + assert task is not None + assert task.owner_id == "initiator-7" + + +def test_create_task_owner_id_defaults_to_none(store: StateStore) -> None: + store.create_task(task_id="room-1", description="t", room_id="room-1") + + task = store.get_task("room-1") + assert task is not None + assert task.owner_id is None + + +def test_owner_id_migrated_onto_legacy_tasks_table(tmp_path: Path) -> None: + """A pre-existing tasks table without ``owner_id`` is migrated in place. + + ``CREATE TABLE IF NOT EXISTS`` is a no-op against the old table, so the + guarded ALTER must add the column; legacy rows then read back with + ``owner_id`` None and new writes can persist it. + """ + db_path = tmp_path / "state" / "orchestration.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE tasks (" + "task_id TEXT PRIMARY KEY, description TEXT NOT NULL, " + "room_id TEXT NOT NULL, created_at TEXT NOT NULL, " + "status TEXT NOT NULL DEFAULT 'active')" + ) + conn.execute( + "INSERT INTO tasks (task_id, description, room_id, created_at, status) " + "VALUES ('old-1', 'legacy', 'old-1', '2020-01-01T00:00:00+00:00', 'active')" + ) + conn.commit() + conn.close() + + store = StateStore(db_path) # runs the guarded migration + + legacy = store.get_task("old-1") + assert legacy is not None + assert legacy.owner_id is None # backfilled, no KeyError on a pre-column row + + store.create_task( + task_id="new-1", description="t", room_id="new-1", owner_id="owner-9", + ) + assert store.get_task("new-1").owner_id == "owner-9" + + +def test_required_verdicts_and_head_sha_migrated_onto_legacy_schema( + tmp_path: Path, +) -> None: + """Pre-existing tasks / transition_log tables gain the Stage-2 columns. + + ``CREATE TABLE IF NOT EXISTS`` is a no-op against old tables, so the + guarded ALTERs must add ``tasks.required_verdicts`` and + ``transition_log.head_sha``; legacy rows read back with both NULL (no + KeyError, read path untouched) and new writes can persist them. + """ + db_path = tmp_path / "state" / "orchestration.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE tasks (" + "task_id TEXT PRIMARY KEY, description TEXT NOT NULL, " + "room_id TEXT NOT NULL, created_at TEXT NOT NULL, " + "status TEXT NOT NULL DEFAULT 'active', " + "owner_id TEXT, owner_handle TEXT)" + ) + conn.execute( + "INSERT INTO tasks (task_id, description, room_id, created_at, status) " + "VALUES ('old-1', 'legacy', 'old-1', '2020-01-01T00:00:00+00:00', 'active')" + ) + conn.execute( + "CREATE TABLE transition_log (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "subtask_id TEXT NOT NULL, task_id TEXT NOT NULL, " + "from_state TEXT NOT NULL, to_state TEXT NOT NULL, " + "caller_role TEXT NOT NULL, timestamp TEXT NOT NULL, reason TEXT)" + ) + conn.execute( + "INSERT INTO transition_log " + "(subtask_id, task_id, from_state, to_state, caller_role, timestamp) " + "VALUES ('st-1', 'old-1', 'planned', 'assigned', 'conductor', " + "'2020-01-01T00:00:00+00:00')" + ) + conn.commit() + conn.close() + + store = StateStore(db_path) # runs the guarded migrations + + legacy = store.get_task("old-1") + assert legacy is not None + assert legacy.required_verdicts is None # legacy row tolerated as NULL + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + row = conn.execute("SELECT * FROM transition_log").fetchone() + assert row["head_sha"] is None # column added, legacy row NULL + finally: + conn.close() + + # New registration writes persist the snapshot through the migrated table. + store.register_task_atomic( + task_id="new-1", + description="t", + room_id="new-1", + owner_id="owner-9", + required_verdicts=["review"], + ) + assert store.get_task("new-1").required_verdicts == ["review"] + + +def test_merge_approval_columns_migrated_onto_legacy_schema( + tmp_path: Path, +) -> None: + """Pre-existing tasks / subtask_states tables gain the merge-leg columns. + + The guarded ALTERs must add ``tasks.merge_approval`` and the subtask grant + columns (``merge_approved_by`` / ``merge_approved_sha`` / + ``merge_approval_requested_sha``); legacy rows read back with all NULL and + the new writers persist through the migrated tables. + """ + db_path = tmp_path / "state" / "orchestration.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE tasks (" + "task_id TEXT PRIMARY KEY, description TEXT NOT NULL, " + "room_id TEXT NOT NULL, created_at TEXT NOT NULL, " + "status TEXT NOT NULL DEFAULT 'active', " + "owner_id TEXT, owner_handle TEXT, required_verdicts TEXT)" + ) + conn.execute( + "INSERT INTO tasks (task_id, description, room_id, created_at, status) " + "VALUES ('old-1', 'legacy', 'old-1', '2020-01-01T00:00:00+00:00', 'active')" + ) + conn.execute( + "CREATE TABLE subtask_states (" + "subtask_id TEXT NOT NULL, " + "task_id TEXT NOT NULL REFERENCES tasks(task_id), " + "state TEXT NOT NULL DEFAULT 'planned', assigned_worker TEXT, " + "pr_number INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, " + "metadata TEXT, review_round INTEGER NOT NULL DEFAULT 0, " + "verify_attempts INTEGER NOT NULL DEFAULT 0, " + "PRIMARY KEY (task_id, subtask_id))" + ) + conn.execute( + "INSERT INTO subtask_states " + "(subtask_id, task_id, state, created_at, updated_at) " + "VALUES ('st-1', 'old-1', 'planned', " + "'2020-01-01T00:00:00+00:00', '2020-01-01T00:00:00+00:00')" + ) + conn.commit() + conn.close() + + store = StateStore(db_path) # runs the guarded migrations + + assert store.get_task("old-1").merge_approval is None # legacy NULL + legacy_sub = store.get_subtask("st-1", "old-1") + assert legacy_sub.merge_approved_by is None + assert legacy_sub.merge_approved_sha is None + assert legacy_sub.merge_approval_requested_sha is None + + # New writes persist through the migrated tables. + store.set_pr_number("st-1", "old-1", 42) + store.record_merge_approval( + "st-1", "old-1", approved_by="owner", approved_sha="sha-1", + ) + store.mark_merge_approval_requested("st-1", "old-1", "sha-1") + sub = store.get_subtask("st-1", "old-1") + assert sub.pr_number == 42 + assert sub.merge_approved_by == "owner" + assert sub.merge_approved_sha == "sha-1" + assert sub.merge_approval_requested_sha == "sha-1" + + store.register_task_atomic( + task_id="new-1", description="t", room_id="new-1", owner_id="owner-9", + required_verdicts=["review"], merge_approval="human:yoni", + ) + assert store.get_task("new-1").merge_approval == "human:yoni" + + +def test_register_task_atomic_snapshot_roundtrip(store: StateStore) -> None: + # Insert persists the resolved list; an update (re-register) overwrites it. + store.register_task_atomic( + task_id="room-1", + description="t", + room_id="room-1", + owner_id="owner-1", + required_verdicts=["verify", "review"], + ) + assert store.get_task("room-1").required_verdicts == ["verify", "review"] + + outcome = store.register_task_atomic( + task_id="room-1", + description="ignored on update", + room_id="room-1", + owner_id="owner-2", + required_verdicts=[], + ) + assert outcome == "updated" + task = store.get_task("room-1") + assert task.required_verdicts == [] # empty snapshot is [] — not None + assert task.owner_id == "owner-2" + + +def _active_task_ids(store: StateStore) -> list[str]: + conn = sqlite3.connect(store.db_path) + try: + rows = conn.execute( + "SELECT task_id FROM tasks WHERE status = 'active' ORDER BY task_id" + ).fetchall() + finally: + conn.close() + return [task_id for (task_id,) in rows] + + +def _register(store: StateStore, task_id: str, *, supersede: str | None = None) -> str: + return store.register_task_atomic( + task_id=task_id, + description="t", + room_id=task_id, + owner_id="owner-1", + required_verdicts=["verify", "review"], + supersede_task_id=supersede, + ) + + +def test_reregistration_restores_superseded_task_to_active(store: StateStore) -> None: + """Re-registering a previously superseded room (the sanctioned + identity-rotation path) must restore status='active' — without it the + system is left with ZERO active tasks: the watchdog patrols nothing and + completion promotion never fires, while cb-phase keeps advancing subtasks. + """ + _register(store, "room-a") + _register(store, "room-b", supersede="room-a") + assert store.get_task("room-a").status == "superseded" + assert _active_task_ids(store) == ["room-b"] + + # Rotate back to room A: the existing-row UPDATE must reactivate it. + outcome = _register(store, "room-a", supersede="room-b") + assert outcome == "updated" + assert store.get_task("room-a").status == "active" + assert store.get_task("room-b").status == "superseded" + assert _active_task_ids(store) == ["room-a"] # exactly one active task + + +def test_supersede_never_rewrites_terminal_rows(store: StateStore) -> None: + """Finding 19 (triage annex A6): the supersede UPDATE is scoped + WHERE status='active' — a task that genuinely COMPLETED must never be + retroactively relabeled 'superseded' by a later registration that still + points at it.""" + _register(store, "room-a") + conn = sqlite3.connect(store.db_path) + try: + conn.execute( + "UPDATE tasks SET status = 'completed' WHERE task_id = 'room-a'" + ) + conn.commit() + finally: + conn.close() + + _register(store, "room-b", supersede="room-a") + + assert store.get_task("room-a").status == "completed" # ledger intact + assert store.get_task("room-b").status == "active" + assert _active_task_ids(store) == ["room-b"] + + +def test_reregistration_reactivates_completed_task(store: StateStore) -> None: + """Re-registering a 'completed' task's room reactivates it — intended + continue-work semantics: the owner is deliberately pointing new work at + the finished task's room (see register_task_atomic's docstring).""" + _register(store, "room-a") + conn = sqlite3.connect(store.db_path) + try: + with conn: + conn.execute( + "UPDATE tasks SET status = 'completed' WHERE task_id = ?", + ("room-a",), + ) + finally: + conn.close() + assert store.get_task("room-a").status == "completed" + + assert _register(store, "room-a") == "updated" + assert store.get_task("room-a").status == "active" + assert _active_task_ids(store) == ["room-a"] + + +def test_get_missing_task_returns_none(store: StateStore) -> None: + assert store.get_task("nope") is None + + +def test_create_task_is_idempotent(store: StateStore) -> None: + store.create_task(task_id="room-1", description="first", room_id="room-1") + # A retried kickoff against an existing DB must not raise or clobber. + store.create_task(task_id="room-1", description="second", room_id="room-1") + + task = store.get_task("room-1") + assert task is not None + assert task.description == "first" # INSERT OR IGNORE: original kept + + +def test_ensure_subtask_creates_row(store: StateStore) -> None: + store.create_task(task_id="room-1", description="t", room_id="room-1") + store.ensure_subtask("sub-1", "room-1") + + sub = store.get_subtask("sub-1", "room-1") + assert isinstance(sub, SubtaskRow) + assert sub.subtask_id == "sub-1" + assert sub.task_id == "room-1" + assert sub.state == "planned" # default + assert sub.assigned_worker is None + assert sub.pr_number is None + assert sub.created_at and sub.updated_at + + +def test_ensure_subtask_is_idempotent(store: StateStore) -> None: + store.create_task(task_id="room-1", description="t", room_id="room-1") + store.ensure_subtask("sub-1", "room-1") + # Calling again is a no-op: no duplicate, no error. + store.ensure_subtask("sub-1", "room-1") + + with sqlite3.connect(store.db_path) as conn: + (count,) = conn.execute( + "SELECT COUNT(*) FROM subtask_states WHERE subtask_id = ?", ("sub-1",) + ).fetchone() + assert count == 1 + + +def test_ensure_subtask_persists_metadata(store: StateStore) -> None: + store.create_task(task_id="room-1", description="t", room_id="room-1") + store.ensure_subtask( + "sub-1", "room-1", assigned_worker="coder-claude-1", metadata={"files": 3} + ) + + sub = store.get_subtask("sub-1", "room-1") + assert sub is not None + assert sub.assigned_worker == "coder-claude-1" + assert sub.metadata == {"files": 3} + + +def test_get_missing_subtask_returns_none(store: StateStore) -> None: + assert store.get_subtask("nope", "room-1") is None + + +def test_list_active_subtasks_excludes_terminal(store: StateStore) -> None: + store.create_task(task_id="room-1", description="t", room_id="room-1") + store.ensure_subtask("active-1", "room-1", state="in_progress") + store.ensure_subtask("active-2", "room-1", state="review_pending") + store.ensure_subtask("done-1", "room-1", state="merged") + store.ensure_subtask("dropped-1", "room-1", state="abandoned") + + active_ids = {s.subtask_id for s in store.list_active_subtasks()} + assert active_ids == {"active-1", "active-2"} + + +def test_list_active_subtasks_scoped_by_task(store: StateStore) -> None: + store.create_task(task_id="room-1", description="t", room_id="room-1") + store.create_task(task_id="room-2", description="t2", room_id="room-2") + store.ensure_subtask("a", "room-1", state="in_progress") + store.ensure_subtask("b", "room-2", state="in_progress") + + scoped = {s.subtask_id for s in store.list_active_subtasks(task_id="room-1")} + assert scoped == {"a"} + + +def test_concurrent_writers_do_not_corrupt(tmp_path: Path) -> None: + """Two StateStore handles on the same DB interleave writes without error.""" + db_path = tmp_path / "state" / "orchestration.db" + store_a = StateStore(db_path) + store_b = StateStore(db_path) + + store_a.create_task(task_id="room-1", description="t", room_id="room-1") + + # Interleave writes from both handles against the shared WAL-mode file. + for i in range(50): + store_a.ensure_subtask(f"a-{i}", "room-1", state="in_progress") + store_b.ensure_subtask(f"b-{i}", "room-1", state="in_progress") + + # Either handle sees every committed row; the DB is intact. + all_ids = {s.subtask_id for s in store_b.list_active_subtasks()} + expected = {f"a-{i}" for i in range(50)} | {f"b-{i}" for i in range(50)} + assert all_ids == expected + + with sqlite3.connect(db_path) as conn: + assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + + +def test_transition_log_index_on_fresh_db(tmp_path: Path) -> None: + """A fresh DB carries the (task_id, subtask_id) transition_log index (S8-F2).""" + store = StateStore(tmp_path / "state" / "orchestration.db") + conn = sqlite3.connect(store.db_path) + try: + names = { + r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'index'" + ).fetchall() + } + finally: + conn.close() + assert "idx_transition_log_task_subtask" in names + + +def test_transition_log_index_on_migrated_db(tmp_path: Path) -> None: + """A pre-index DB gains the index when StateStore is constructed against it. + + The ``CREATE INDEX IF NOT EXISTS`` in the schema script runs on every + construction, so it doubles as the migration path — verify it lands on an + old-schema DB and that the legacy rows stay readable. + """ + db_path = tmp_path / "state" / "orchestration.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE tasks (" + "task_id TEXT PRIMARY KEY, description TEXT NOT NULL, " + "room_id TEXT NOT NULL, created_at TEXT NOT NULL, " + "status TEXT NOT NULL DEFAULT 'active')" + ) + conn.execute( + "CREATE TABLE transition_log (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "subtask_id TEXT NOT NULL, task_id TEXT NOT NULL, " + "from_state TEXT NOT NULL, to_state TEXT NOT NULL, " + "caller_role TEXT NOT NULL, timestamp TEXT NOT NULL, reason TEXT)" + ) + conn.execute( + "INSERT INTO transition_log " + "(subtask_id, task_id, from_state, to_state, caller_role, timestamp) " + "VALUES ('st-1', 'old-1', 'planned', 'assigned', 'conductor', " + "'2020-01-01T00:00:00+00:00')" + ) + conn.commit() + conn.close() + + StateStore(db_path) # schema script runs CREATE INDEX IF NOT EXISTS + + conn = sqlite3.connect(db_path) + try: + names = { + r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'index'" + ).fetchall() + } + count = conn.execute("SELECT COUNT(*) FROM transition_log").fetchone()[0] + finally: + conn.close() + assert "idx_transition_log_task_subtask" in names + assert count == 1 # legacy rows untouched + + +def test_rebase_rounds_migrated_onto_legacy_subtask_table(tmp_path: Path) -> None: + """A pre-existing subtask_states table without ``rebase_rounds`` is migrated. + + Guarded ALTER, matching review_round's pattern: legacy rows backfill to 0 + and read back without KeyError. + """ + db_path = tmp_path / "state" / "orchestration.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE tasks (" + "task_id TEXT PRIMARY KEY, description TEXT NOT NULL, " + "room_id TEXT NOT NULL, created_at TEXT NOT NULL, " + "status TEXT NOT NULL DEFAULT 'active')" + ) + conn.execute( + "INSERT INTO tasks (task_id, description, room_id, created_at, status) " + "VALUES ('old-1', 'legacy', 'old-1', '2020-01-01T00:00:00+00:00', 'active')" + ) + conn.execute( + "CREATE TABLE subtask_states (" + "subtask_id TEXT NOT NULL, " + "task_id TEXT NOT NULL REFERENCES tasks(task_id), " + "state TEXT NOT NULL DEFAULT 'planned', " + "assigned_worker TEXT, pr_number INTEGER, " + "created_at TEXT NOT NULL, updated_at TEXT NOT NULL, metadata TEXT, " + "review_round INTEGER NOT NULL DEFAULT 0, " + "verify_attempts INTEGER NOT NULL DEFAULT 0, " + "merge_approved_by TEXT, merge_approved_sha TEXT, " + "merge_approval_requested_sha TEXT, " + "PRIMARY KEY (task_id, subtask_id))" + ) + conn.execute( + "INSERT INTO subtask_states " + "(subtask_id, task_id, created_at, updated_at) " + "VALUES ('st-1', 'old-1', '2020-01-01T00:00:00+00:00', " + "'2020-01-01T00:00:00+00:00')" + ) + conn.commit() + conn.close() + + store = StateStore(db_path) # runs the guarded migration + + legacy = store.get_subtask("st-1", "old-1") + assert legacy is not None + assert legacy.rebase_rounds == 0 # backfilled, no KeyError + + +# ── batch_latest_transitions ───────────────────────────────────────────────── + +def _insert_transition_log(store: StateStore, subtask_id: str, task_id: str, timestamp: str) -> None: + conn = sqlite3.connect(store.db_path) + conn.execute( + "INSERT INTO transition_log " + "(subtask_id, task_id, from_state, to_state, caller_role, timestamp) " + "VALUES (?, ?, 'planned', 'in_progress', 'conductor', ?)", + (subtask_id, task_id, timestamp), + ) + conn.commit() + conn.close() + + +def test_batch_latest_transitions_returns_max_per_subtask(store: StateStore) -> None: + store.create_task("t-1", "task one", "room-1") + store.create_task("t-2", "task two", "room-2") + store.ensure_subtask("st-a", "t-1", state="in_progress") + store.ensure_subtask("st-b", "t-1", state="review_pending") + store.ensure_subtask("st-c", "t-2", state="in_progress") + + _insert_transition_log(store, "st-a", "t-1", "2026-01-01T10:00:00+00:00") + _insert_transition_log(store, "st-a", "t-1", "2026-01-01T11:00:00+00:00") # newer + _insert_transition_log(store, "st-c", "t-2", "2026-01-01T09:00:00+00:00") + + keys = [("t-1", "st-a"), ("t-1", "st-b"), ("t-2", "st-c")] + result = store.batch_latest_transitions(keys) + + from datetime import timezone + assert ("t-1", "st-a") in result + assert result[("t-1", "st-a")].replace(tzinfo=timezone.utc) is not None + assert result[("t-1", "st-a")].hour == 11 # MAX picks the newer timestamp + + # st-b has no rows — key absent from result (no rows to GROUP BY on) + assert ("t-1", "st-b") not in result + + assert ("t-2", "st-c") in result + assert result[("t-2", "st-c")].hour == 9 + + +def test_batch_latest_transitions_scopes_by_task_id(store: StateStore) -> None: + """Same subtask_id in two tasks must not cross-contaminate results.""" + store.create_task("t-1", "task one", "room-1") + store.create_task("t-2", "task two", "room-2") + store.ensure_subtask("st-1", "t-1", state="in_progress") + store.ensure_subtask("st-1", "t-2", state="in_progress") + + _insert_transition_log(store, "st-1", "t-1", "2026-01-01T10:00:00+00:00") + _insert_transition_log(store, "st-1", "t-2", "2026-01-01T12:00:00+00:00") + + result_t1 = store.batch_latest_transitions([("t-1", "st-1")]) + result_t2 = store.batch_latest_transitions([("t-2", "st-1")]) + + assert result_t1[("t-1", "st-1")].hour == 10 + assert result_t2[("t-2", "st-1")].hour == 12 + + +def test_batch_latest_transitions_empty_input(store: StateStore) -> None: + assert store.batch_latest_transitions([]) == {} + + +def test_batch_latest_transitions_no_rows_returns_empty(store: StateStore) -> None: + store.create_task("t-1", "task one", "room-1") + store.ensure_subtask("st-a", "t-1", state="in_progress") + + result = store.batch_latest_transitions([("t-1", "st-a")]) + # No transition_log rows — subtask absent from result + assert ("t-1", "st-a") not in result + + +# ── item-0: assigned_reviewer column + identity setters ───────────────────── + +def test_assigned_reviewer_present_on_fresh_db(store: StateStore) -> None: + """A fresh schema includes assigned_reviewer, read back as NULL by default.""" + store.create_task("t-1", "task", "room-1") + store.ensure_subtask("st-1", "t-1", state="in_progress") + sub = store.get_subtask("st-1", "t-1") + assert sub.assigned_reviewer is None + + +def test_set_assigned_worker_and_reviewer_roundtrip(store: StateStore) -> None: + store.create_task("t-1", "task", "room-1") + store.ensure_subtask("st-1", "t-1", state="in_progress") + store.set_assigned_worker("st-1", "t-1", "agent-coder-7") + store.set_assigned_reviewer("st-1", "t-1", "agent-reviewer-3") + sub = store.get_subtask("st-1", "t-1") + assert sub.assigned_worker == "agent-coder-7" + assert sub.assigned_reviewer == "agent-reviewer-3" + + +def test_assigned_reviewer_migrated_onto_legacy_subtask_table(tmp_path: Path) -> None: + """A pre-existing subtask_states without assigned_reviewer gains it. + + Mirrors the merge-approval additive-migration: the guarded ALTER adds the + column, legacy rows read back NULL, and the new setter persists through the + migrated table. Re-opening the migrated DB is a no-op (idempotent). + """ + db_path = tmp_path / "state" / "orchestration.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE tasks (" + "task_id TEXT PRIMARY KEY, description TEXT NOT NULL, " + "room_id TEXT NOT NULL, created_at TEXT NOT NULL, " + "status TEXT NOT NULL DEFAULT 'active')" + ) + conn.execute( + "INSERT INTO tasks (task_id, description, room_id, created_at, status) " + "VALUES ('old-1', 'legacy', 'old-1', '2020-01-01T00:00:00+00:00', 'active')" + ) + conn.execute( + "CREATE TABLE subtask_states (" + "subtask_id TEXT NOT NULL, " + "task_id TEXT NOT NULL REFERENCES tasks(task_id), " + "state TEXT NOT NULL DEFAULT 'planned', assigned_worker TEXT, " + "pr_number INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, " + "metadata TEXT, PRIMARY KEY (task_id, subtask_id))" + ) + conn.execute( + "INSERT INTO subtask_states " + "(subtask_id, task_id, state, created_at, updated_at) " + "VALUES ('st-1', 'old-1', 'planned', " + "'2020-01-01T00:00:00+00:00', '2020-01-01T00:00:00+00:00')" + ) + conn.commit() + conn.close() + + store = StateStore(db_path) # runs the guarded migration + + assert store.get_subtask("st-1", "old-1").assigned_reviewer is None # legacy NULL + store.set_assigned_reviewer("st-1", "old-1", "agent-rev-9") + assert store.get_subtask("st-1", "old-1").assigned_reviewer == "agent-rev-9" + + # Idempotent: re-opening the already-migrated DB does not error or wipe data. + store2 = StateStore(db_path) + assert store2.get_subtask("st-1", "old-1").assigned_reviewer == "agent-rev-9" diff --git a/tests/test_task_scoped_identity.py b/tests/test_task_scoped_identity.py new file mode 100644 index 0000000..a9bf2ef --- /dev/null +++ b/tests/test_task_scoped_identity.py @@ -0,0 +1,255 @@ +"""Task-scoped subtask identity — composite key ``(task_id, subtask_id)``. + +Planners number subtasks ``st-1``, ``st-2``, … fresh per plan, so a bare +``subtask_id`` PRIMARY KEY guaranteed cross-task collisions in any reused +``orchestration.db``. Found live in the Task 2 shakedown: a stale +``review_passed`` st-1 from a prior task shadowed the new task's st-1 and +rejected ``cb-phase verify`` at entry-state validation. + +These tests mirror the existing store / handoff / watchdog test patterns: +LLM-free, real sqlite via ``StateStore``, mocked REST for the watchdog. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.cli import handoff +from codeband.config import WatchdogConfig +from codeband.state.fsm import transition +from codeband.state.store import StateStore + +TASK_A = "room-aaaa" +TASK_B = "room-bbbb" + + +@pytest.fixture +def store(tmp_path) -> StateStore: + """One shared DB with two tasks — the reused-orchestration.db scenario.""" + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(TASK_A, "task A", TASK_A, owner_id="owner-a") + s.create_task(TASK_B, "task B", TASK_B, owner_id="owner-b") + return s + + +def _drive_to_review_passed(store: StateStore, task_id: str) -> None: + for new_state, role in [ + ("assigned", "conductor"), + ("in_progress", "coder"), + ("verify_pending", "coder"), + ("review_pending", "coder"), + ("review_passed", "reviewer"), + ]: + transition("st-1", task_id, new_state, caller_role=role, store=store) + + +# ── (a) same subtask id, two tasks — fully independent state ───────────────── + +def test_same_subtask_id_in_two_tasks_is_independent(store): + transition("st-1", TASK_A, "assigned", caller_role="conductor", store=store) + transition("st-1", TASK_B, "assigned", caller_role="conductor", store=store) + transition("st-1", TASK_A, "in_progress", caller_role="coder", store=store) + + # Advancing task A's st-1 does not touch task B's st-1. + assert store.get_subtask("st-1", TASK_A).state == "in_progress" + assert store.get_subtask("st-1", TASK_B).state == "assigned" + + # Counters are independent too. + assert store.increment_verify_attempts("st-1", TASK_A) == 1 + assert store.get_subtask("st-1", TASK_A).verify_attempts == 1 + assert store.get_subtask("st-1", TASK_B).verify_attempts == 0 + + +def test_ensure_subtask_creates_one_row_per_task(store): + store.ensure_subtask("st-1", TASK_A, state="in_progress") + store.ensure_subtask("st-1", TASK_B, state="planned") + + with sqlite3.connect(store.db_path) as conn: + (count,) = conn.execute( + "SELECT COUNT(*) FROM subtask_states WHERE subtask_id = ?", ("st-1",) + ).fetchone() + assert count == 2 + assert store.get_subtask("st-1", TASK_A).state == "in_progress" + assert store.get_subtask("st-1", TASK_B).state == "planned" + + +# ── (b) THE EXACT TASK-2 REPRO ─────────────────────────────────────────────── + +def test_task2_repro_stale_review_passed_st1_does_not_shadow_new_task(store): + """Task A's st-1 rests at ``review_passed``; task B then creates its own + st-1. ``cb-phase verify`` entry-state validation on task B's st-1 must + succeed (the walk lands it at ``verify_pending``) and task A's row must be + untouched — exactly the case that rejected with "not a valid entry state" + when the lookup was unscoped. + """ + _drive_to_review_passed(store, TASK_A) + store.ensure_subtask("st-1", TASK_B) # the new task's fresh st-1 + + walk_result = handoff._walk_to_verify_pending( + "st-1", TASK_B, store, max_review_rounds=3, + ) + + assert walk_result is None # entry-state validation succeeded + assert store.get_subtask("st-1", TASK_B).state == "verify_pending" + assert store.get_subtask("st-1", TASK_A).state == "review_passed" # untouched + + +def test_task2_repro_through_cb_phase_main(store, monkeypatch): + """The same repro end-to-end through ``cb-phase verify`` with all gates + passing: task B's st-1 advances through verify_pending → review_pending + while task A's stale review_passed st-1 stays put. + """ + _drive_to_review_passed(store, TASK_A) + + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda project_dir, s, task_arg: (TASK_B, None), + ) + monkeypatch.setattr(handoff, "_verify_command", lambda project_dir, worktree: "true") + monkeypatch.setattr(handoff, "_max_verify_attempts", lambda project_dir: 20) + monkeypatch.setattr(handoff, "_max_review_rounds", lambda project_dir: 3) + monkeypatch.setattr(handoff, "_uncommitted_files", lambda worktree: []) + monkeypatch.setattr(handoff, "_current_branch", lambda worktree: "feat-x") + # PR-pinned verify outcomes: the PR head must match the worktree HEAD + # (and the PR head branch the worktree branch) — the one gh snapshot and + # the git seam stubbed to agree here. + monkeypatch.setattr(handoff, "_git_head", lambda worktree: "cafe1234") + monkeypatch.setattr( + handoff, "_verify_pr_snapshot", + lambda project_dir, pr: { + "state": "OPEN", "headRefName": "feat-x", "headRefOid": "cafe1234", + }, + ) + + assert handoff.main(["verify", "st-1", "--pr", "42"]) == 0 + assert store.get_subtask("st-1", TASK_B).state == "review_pending" + assert store.get_subtask("st-1", TASK_A).state == "review_passed" + + +# ── (c) transition_log rows are distinguishable by task_id ─────────────────── + +def test_transition_log_rows_distinguishable_by_task(store): + transition("st-1", TASK_A, "assigned", caller_role="conductor", store=store) + transition("st-1", TASK_B, "assigned", caller_role="conductor", store=store) + transition("st-1", TASK_B, "in_progress", caller_role="coder", store=store) + + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + "SELECT task_id, to_state FROM transition_log " + "WHERE subtask_id = ? ORDER BY id", + ("st-1",), + ).fetchall() + finally: + conn.close() + + by_task = {} + for r in rows: + by_task.setdefault(r["task_id"], []).append(r["to_state"]) + assert by_task == {TASK_A: ["assigned"], TASK_B: ["assigned", "in_progress"]} + + +# ── (d) watchdog: one escalation per (task, subtask) ───────────────────────── + +def _mock_rest(): + rest = MagicMock() + rest.agent_api_messages = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + return rest + + +def _write_room_pointer(store: StateStore, room_id: str) -> None: + (store.db_path.parent / ".codeband_room").write_text(room_id, encoding="utf-8") + + +@pytest.mark.asyncio +async def test_blocked_st1_in_two_tasks_escalates_once_per_task(store): + """Blocked st-1 in task A and blocked st-1 in task B → exactly two owner + escalations, one per task owner; escalate-once still holds within each + task across repeated patrols. + """ + from codeband.agents.watchdog import WatchdogDaemon + + for task_id in (TASK_A, TASK_B): + transition("st-1", task_id, "assigned", caller_role="conductor", store=store) + transition("st-1", task_id, "in_progress", caller_role="coder", store=store) + transition("st-1", task_id, "blocked", caller_role="coder", + reason=f"cap reached in {task_id}", store=store) + + rest = _mock_rest() + daemon = WatchdogDaemon( + config=WatchdogConfig(), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + ) + + now = datetime.now(UTC) + await daemon._check_blocked_subtasks(now) + await daemon._check_blocked_subtasks(now) # escalate-once: no re-fire + + calls = rest.agent_api_messages.create_agent_chat_message.call_args_list + assert len(calls) == 2 # one per task, not one global st-1 + mentioned = {m.id for c in calls for m in c.kwargs["message"].mentions} + assert mentioned == {"owner-a", "owner-b"} # each task's own owner + # Each message carries its own task's durable blocked reason. + contents = {c.kwargs["message"].content for c in calls} + assert any(f"cap reached in {TASK_A}" in c for c in contents) + assert any(f"cap reached in {TASK_B}" in c for c in contents) + assert daemon._owner_escalated == {(TASK_A, "st-1"), (TASK_B, "st-1")} + + +@pytest.mark.asyncio +async def test_progress_tracking_keyed_per_task(store, monkeypatch): + """The mechanical-progress health map remains task-scoped across current tasks. + + The watchdog now patrols only the task named by the room pointer, but the + remembered health map still keys by ``(task_id, subtask_id)``. Progress on + task A's st-1 must not reset task B's remembered stall counter. + """ + import subprocess as _subprocess + + from codeband.agents.watchdog import WatchdogDaemon + + for task_id in (TASK_A, TASK_B): + store.ensure_subtask("st-1", task_id, state="in_progress") + + # No git branch / PR metadata: the transition log is the only signal. + monkeypatch.setattr( + _subprocess, "run", + lambda *a, **k: _subprocess.CompletedProcess(a, 1, stdout="", stderr=""), + ) + + daemon = WatchdogDaemon( + config=WatchdogConfig(max_phase_visits=10), + rest_client=_mock_rest(), + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + ) + + now = datetime.now(UTC) + _write_room_pointer(store, TASK_A) + await daemon._check_subtask_progress(now) # task A baseline → 1 (no signals) + await daemon._check_subtask_progress(now) # task A → 2 + + _write_room_pointer(store, TASK_B) + await daemon._check_subtask_progress(now) # task B baseline → 1 + await daemon._check_subtask_progress(now) # task B → 2 + + # Progress on task A's st-1 only (a new transition_log row for TASK_A). + transition("st-1", TASK_A, "verify_pending", caller_role="coder", store=store) + _write_room_pointer(store, TASK_A) + await daemon._check_subtask_progress(now) + + health_a = daemon._subtask_state[(TASK_A, "st-1")] + health_b = daemon._subtask_state[(TASK_B, "st-1")] + assert health_a.patrol_visits_without_progress == 0 # reset by progress + assert health_b.patrol_visits_without_progress == 2 # untouched by task A diff --git a/tests/test_verifier.py b/tests/test_verifier.py new file mode 100644 index 0000000..b26d15b --- /dev/null +++ b/tests/test_verifier.py @@ -0,0 +1,472 @@ +"""Tests for the Verifier seat — config/pool/doctor wiring. + +Covers: +- VerifiersConfig pool shape and the ACTIVE default (count=1 per vendor) +- WorkerRole.VERIFIER identity string +- WorkerPool.acquire_verifier_for opposite-vendor pairing + fallback +- cb doctor check_verifier_pairing +- verify_acceptance is a known verdict, coupled to a *configured* verifier + +The seat is ACTIVE by default now that the Verifier runtime + dispatch exist to +produce the verdict (PR4). An explicitly-inert verifier pool (``VerifiersConfig()``, +count=0) opts back out — tested below alongside the active default. The verdict +leg itself, broken-chain interlock, claim-vs-store audit, merge gating, and role +gate live in test_verifier_acceptance.py. +""" + +from __future__ import annotations + +from pathlib import Path + + +from codeband.config import ( + AgentsConfig, + CodebandConfig, + Framework, + FrameworkPool, + PlanReviewersConfig, + PoolEntry, + RepoConfig, + ReviewersConfig, + VerifiersConfig, +) +from codeband.workers import WorkerId, WorkerPool, WorkerRole + + +def _claude_only_except_codex_verifier() -> CodebandConfig: + """A config where the ONLY Codex agent is the verifier — used to prove the + Codex-framework detectors (preflight + doctor) account for verifiers.""" + return CodebandConfig( + repo=RepoConfig(url="https://github.com/a/b.git"), + agents=AgentsConfig( + coders=FrameworkPool(claude_sdk=PoolEntry(count=1)), + reviewers=ReviewersConfig(claude_sdk=PoolEntry(count=1)), + planners=FrameworkPool(claude_sdk=PoolEntry(count=1)), + plan_reviewers=PlanReviewersConfig(claude_sdk=PoolEntry(count=1)), + verifiers=VerifiersConfig(codex=PoolEntry(count=1)), + ), + ) + + +# ─── VerifiersConfig ──────────────────────────────────────────────────────── + +class TestVerifiersConfig: + def test_default_count_is_one_per_vendor(self): + """Verifier seat is ACTIVE by default — one verifier per vendor.""" + config = CodebandConfig(repo=RepoConfig(url="https://github.com/a/b.git")) + v = config.agents.verifiers + assert v.claude_sdk.count == 1 + assert v.codex.count == 1 + + def test_explicit_inert_count_is_zero(self): + """A bare ``VerifiersConfig()`` is the explicit opt-out — both counts 0.""" + v = VerifiersConfig() + assert v.claude_sdk.count == 0 + assert v.codex.count == 0 + + def test_default_models_are_set(self): + """Default models are pre-configured for when the seat is activated.""" + config = CodebandConfig(repo=RepoConfig(url="https://github.com/a/b.git")) + v = config.agents.verifiers + assert v.claude_sdk.model == "claude-opus-4-7" + assert v.codex.model == "gpt-5.5" + + def test_active_frameworks_both_by_default(self): + config = CodebandConfig(repo=RepoConfig(url="https://github.com/a/b.git")) + assert config.agents.verifiers.active_frameworks() == [ + Framework.CLAUDE_SDK, Framework.CODEX, + ] + + def test_active_frameworks_empty_when_explicitly_inert(self): + assert VerifiersConfig().active_frameworks() == [] + + def test_active_frameworks_when_enabled(self): + v = VerifiersConfig( + claude_sdk=PoolEntry(count=1), + codex=PoolEntry(count=1), + ) + assert v.active_frameworks() == [Framework.CLAUDE_SDK, Framework.CODEX] + + def test_entry_for(self): + v = VerifiersConfig( + claude_sdk=PoolEntry(count=2), + codex=PoolEntry(count=3), + ) + assert v.entry_for(Framework.CLAUDE_SDK).count == 2 + assert v.entry_for(Framework.CODEX).count == 3 + + def test_total_count_two_by_default(self): + config = CodebandConfig(repo=RepoConfig(url="https://github.com/a/b.git")) + assert config.agents.verifiers.total_count() == 2 + + def test_total_count_zero_when_explicitly_inert(self): + assert VerifiersConfig().total_count() == 0 + + def test_yaml_roundtrip(self, tmp_path: Path): + """Verifier pool survives YAML serialization.""" + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/a/b.git"), + agents=AgentsConfig( + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=0, model="claude-opus-4-7"), + codex=PoolEntry(count=1, model="gpt-5.5"), + ), + ), + ) + yaml_path = tmp_path / "codeband.yaml" + config.to_yaml(yaml_path) + loaded = CodebandConfig.from_yaml(yaml_path) + assert loaded.agents.verifiers.claude_sdk.count == 0 + assert loaded.agents.verifiers.claude_sdk.model == "claude-opus-4-7" + assert loaded.agents.verifiers.codex.count == 1 + assert loaded.agents.verifiers.codex.model == "gpt-5.5" + + def test_total_agent_count_reflects_verifier_seats(self): + """total_agent_count counts active verifier seats (independent of the + default: compare an explicitly-inert pool against an active one).""" + inert = AgentsConfig(verifiers=VerifiersConfig()) + active = AgentsConfig( + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=1), codex=PoolEntry(count=1), + ), + ) + assert active.total_agent_count() == inert.total_agent_count() + 2 + + +# ─── Verifier runtime (runner classes + factory) ───────────────────────────── + +class TestVerifierRuntime: + """Permission/sandbox wiring for the Verifier runner classes — a clean + mirror of the Code Reviewer runtime (isolated scratch dir + gh network). + """ + + def test_claude_verifier_bypasses_permissions(self, tmp_path: Path): + """Claude verifier bypasses global settings so its gh calls aren't denied.""" + from codeband.agents.verifier import ClaudeVerifierRunner + + adapter = ClaudeVerifierRunner(workspace=str(tmp_path)).adapter + assert adapter.permission_mode == "bypassPermissions" + assert adapter.cwd == str(tmp_path) + + def test_codex_verifier_uses_full_access_sandbox(self, tmp_path: Path): + """Codex verifier needs full access for gh CLI network calls.""" + from codeband.agents.verifier import CodexVerifierRunner + + config = CodexVerifierRunner(workspace=str(tmp_path)).adapter.config + assert config.cwd == str(tmp_path) + assert config.sandbox == "danger-full-access" + assert config.approval_policy == "never" + + def test_codex_verifier_threads_turn_timeout(self, tmp_path: Path): + """The Codex whole-turn budget is carried through, like the reviewer.""" + from codeband.agents.verifier import CodexVerifierRunner + + runner = CodexVerifierRunner(workspace=str(tmp_path), turn_timeout_seconds=1234) + assert runner.adapter.config.turn_timeout_s == 1234.0 + + def test_factory_selects_runner_and_model_per_framework(self, tmp_path: Path): + """_create_verifier maps each framework to its runner + the configured model.""" + from codeband.orchestration.runner import _create_verifier + + config = CodebandConfig(repo=RepoConfig(url="https://github.com/a/b.git")) + claude = _create_verifier( + config, workspace=str(tmp_path), framework=Framework.CLAUDE_SDK, + ) + assert claude.permission_mode == "bypassPermissions" + assert claude.model == "claude-opus-4-7" # default verifier model (config) + + codex_cfg = _create_verifier( + config, workspace=str(tmp_path), framework=Framework.CODEX, + ).config + assert codex_cfg.sandbox == "danger-full-access" + assert codex_cfg.model == "gpt-5.5" + + +# ─── distributed dispatch + framework-detection wiring ─────────────────────── + +class TestVerifierDispatchWiring: + """The verifier is a first-class pool role in the runtime plumbing — role + derivation (distributed ``run_agent``) and Codex-framework detection + (preflight + doctor) must recognize it, or a verifier would either fail to + dispatch or have its Codex auth left unchecked (silent-idle risk).""" + + def test_role_from_key_recognizes_verifier(self): + from codeband.orchestration.runner import _role_from_key + + assert _role_from_key("verifier-claude_sdk-0") == "verifier" + assert _role_from_key("verifier-codex-1") == "verifier" + + def test_framework_from_key_for_verifier(self): + from codeband.orchestration.runner import _framework_from_key + + assert _framework_from_key("verifier-codex-0") == Framework.CODEX + assert _framework_from_key("verifier-claude_sdk-0") == Framework.CLAUDE_SDK + + def test_preflight_detects_codex_verifier_only_config(self): + """A Codex verifier alone must trigger the Codex auth preflight — + otherwise a broken-auth verifier idles silently.""" + from codeband.preflight import _config_uses_codex + + assert _config_uses_codex(_claude_only_except_codex_verifier()) is True + + def test_doctor_needs_codex_for_codex_verifier_only_config(self): + from codeband.doctor import Context, _needs_codex + + ctx = Context( + project_dir=Path("/tmp"), config=_claude_only_except_codex_verifier(), + ) + assert _needs_codex(ctx) is True + + +# ─── WorkerRole.VERIFIER identity ─────────────────────────────────────────── + +class TestVerifierWorkerId: + def test_worker_role_value(self): + assert WorkerRole.VERIFIER.value == "verifier" + + def test_worker_id_string_claude(self): + wid = WorkerId(WorkerRole.VERIFIER, Framework.CLAUDE_SDK, 0) + assert str(wid) == "verifier-claude_sdk-0" + + def test_worker_id_string_codex(self): + wid = WorkerId(WorkerRole.VERIFIER, Framework.CODEX, 2) + assert str(wid) == "verifier-codex-2" + + +# ─── WorkerPool.acquire_verifier_for ──────────────────────────────────────── + +class TestAcquireVerifierFor: + def _pool_with_verifiers(self, claude_count=1, codex_count=1) -> WorkerPool: + p = WorkerPool() + if claude_count: + p.register(WorkerRole.VERIFIER, Framework.CLAUDE_SDK, claude_count) + if codex_count: + p.register(WorkerRole.VERIFIER, Framework.CODEX, codex_count) + return p + + def test_opposite_vendor_for_claude_coder(self): + """Claude coder → Codex verifier (opposite framework).""" + pool = self._pool_with_verifiers() + wid = pool.acquire_verifier_for(Framework.CLAUDE_SDK) + assert wid is not None + assert wid.role == WorkerRole.VERIFIER + assert wid.framework == Framework.CODEX + + def test_opposite_vendor_for_codex_coder(self): + """Codex coder → Claude verifier (opposite framework).""" + pool = self._pool_with_verifiers() + wid = pool.acquire_verifier_for(Framework.CODEX) + assert wid is not None + assert wid.framework == Framework.CLAUDE_SDK + + def test_fallback_same_vendor_when_opposite_exhausted(self): + """Falls back to same-framework when no opposite verifier is idle.""" + pool = WorkerPool() + pool.register(WorkerRole.VERIFIER, Framework.CLAUDE_SDK, 1) + # Only Claude verifiers — Codex coder falls back to Claude + wid = pool.acquire_verifier_for(Framework.CODEX) + assert wid is not None + assert wid.framework == Framework.CLAUDE_SDK + + def test_returns_none_when_no_verifiers_registered(self): + """Returns None when the verifier pool is explicitly opted out.""" + pool = WorkerPool() + assert pool.acquire_verifier_for(Framework.CLAUDE_SDK) is None + assert pool.acquire_verifier_for(Framework.CODEX) is None + + def test_acquired_slot_is_busy(self): + pool = self._pool_with_verifiers() + wid = pool.acquire_verifier_for(Framework.CLAUDE_SDK) + assert wid is not None + # Acquiring again should find the other framework or None + wid2 = pool.acquire_verifier_for(Framework.CLAUDE_SDK) + # The Codex slot is now taken; no Claude slot for Claude coder + assert wid2 is not None or pool.idle_count(WorkerRole.VERIFIER) == 0 + + def test_release_makes_verifier_idle_again(self): + pool = WorkerPool() + pool.register(WorkerRole.VERIFIER, Framework.CODEX, 1) + wid = pool.acquire_verifier_for(Framework.CLAUDE_SDK) + assert wid is not None + pool.release(wid) + wid2 = pool.acquire_verifier_for(Framework.CLAUDE_SDK) + assert wid2 is not None + + def test_task_id_attached(self): + pool = self._pool_with_verifiers() + wid = pool.acquire_verifier_for(Framework.CLAUDE_SDK, task_id="task-99") + assert wid is not None + snap = {s["worker_id"]: s for s in pool.snapshot()} + assert snap[str(wid)]["current_task"] == "task-99" + + +# ─── setup.py registration ─────────────────────────────────────────────────── + +class TestVerifierAgentRegistration: + def test_verifier_in_expected_agents_by_default(self): + """With the active default, verifier keys appear in expected_agents.""" + from codeband.orchestration.setup import _expected_agents + + config = CodebandConfig(repo=RepoConfig(url="https://github.com/a/b.git")) + expected = _expected_agents(config) + assert "verifier-claude_sdk-0" in expected + assert "verifier-codex-0" in expected + + def test_verifier_absent_from_expected_agents_when_inert(self): + """An explicitly-inert verifier pool registers no verifier agents.""" + from codeband.orchestration.setup import _expected_agents + + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/a/b.git"), + agents=AgentsConfig(verifiers=VerifiersConfig()), + ) + expected = _expected_agents(config) + assert not any(k.startswith("verifier-") for k in expected) + + def test_verifier_in_expected_agents_when_enabled(self): + """With count=1, verifier keys and display names appear.""" + from codeband.orchestration.setup import _expected_agents, _is_codeband_agent + + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/a/b.git"), + agents=AgentsConfig( + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=1), + codex=PoolEntry(count=1), + ), + ), + ) + expected = _expected_agents(config) + assert "verifier-claude_sdk-0" in expected + assert "verifier-codex-0" in expected + claude_name, _ = expected["verifier-claude_sdk-0"] + codex_name, _ = expected["verifier-codex-0"] + assert claude_name == "Verifier-Claude-0" + assert codex_name == "Verifier-Codex-0" + assert _is_codeband_agent(claude_name) + assert _is_codeband_agent(codex_name) + assert not _is_codeband_agent("Verification-Claude-0") + assert not _is_codeband_agent("VerifierClaude-0") + + +# ─── cb doctor check_verifier_pairing ──────────────────────────────────────── + +class TestDoctorVerifierPairing: + def _ctx(self, tmp_path, **agents_kwargs): + from codeband.doctor import Context + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/a/b.git"), + agents=AgentsConfig(**agents_kwargs), + ) + return Context(project_dir=tmp_path, config=config) + + def test_ok_by_default(self, tmp_path): + """The active default pairs both vendors → doctor reports OK.""" + from codeband.doctor import Status, check_verifier_pairing + ctx = self._ctx(tmp_path) + result = check_verifier_pairing(ctx) + assert result.status == Status.OK + + def test_skips_when_verifiers_inert(self, tmp_path): + """No warning when the verifier seat is explicitly inert (count=0).""" + from codeband.doctor import Status, check_verifier_pairing + ctx = self._ctx(tmp_path, verifiers=VerifiersConfig()) + result = check_verifier_pairing(ctx) + assert result.status == Status.SKIP + + def test_ok_when_both_vendors_present(self, tmp_path): + """OK when verifiers can pair opposite-vendor to any coder framework.""" + from codeband.doctor import Status, check_verifier_pairing + ctx = self._ctx( + tmp_path, + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=1), + codex=PoolEntry(count=1), + ), + ) + result = check_verifier_pairing(ctx) + assert result.status == Status.OK + + def test_warns_single_vendor_claude_only(self, tmp_path): + """WARN when coders and verifiers share the only vendor (claude_sdk).""" + from codeband.doctor import Status, check_verifier_pairing + ctx = self._ctx( + tmp_path, + verifiers=VerifiersConfig(claude_sdk=PoolEntry(count=1)), + ) + result = check_verifier_pairing(ctx) + assert result.status == Status.WARN + assert "claude_sdk" in result.message + assert "same-vendor" in result.message + assert result.remediation is not None + + def test_warns_single_vendor_codex_only(self, tmp_path): + """WARN when verifiers and coders are both Codex-only.""" + from codeband.doctor import Status, check_verifier_pairing + ctx = self._ctx( + tmp_path, + verifiers=VerifiersConfig(codex=PoolEntry(count=1)), + ) + result = check_verifier_pairing(ctx) + assert result.status == Status.WARN + assert "codex" in result.message + + def test_skips_when_no_config(self, tmp_path): + from codeband.doctor import Context, Status, check_verifier_pairing + result = check_verifier_pairing(Context(project_dir=tmp_path, config=None)) + assert result.status == Status.SKIP + + def test_in_checks_registry(self): + """Verifier pairing check is registered in _CHECKS.""" + from codeband.doctor import _CHECKS, check_verifier_pairing + names = [c.name for c in _CHECKS] + assert "Verifier pairing" in names + fns = [c.run for c in _CHECKS] + assert check_verifier_pairing in fns + + +# ─── verify_acceptance verdict coupling (leg wired, active default) ─────────── + +class TestVerifierVerdictCoupling: + def test_known_verdicts_includes_verify_acceptance(self): + """The verdict leg is wired → verify_acceptance is a known verdict.""" + from codeband.state.registration import KNOWN_VERDICTS + assert KNOWN_VERDICTS == frozenset( + {"verify", "review", "verify_acceptance"} + ) + + def test_default_required_verdicts_add_acceptance_when_verifier_configured(self): + """Resolved verdicts add verify_acceptance iff a verifier is configured. + + This test pins the verifier pool explicitly so it exercises the + iff-configured coupling independent of future default-shape changes. + resolve_required_verdicts enforces that handoff_verify_command is set + when 'verify' is in the list, so we supply one to isolate the verdict + content check from the precondition check. + """ + from codeband.state.registration import resolve_required_verdicts + agents = AgentsConfig( + handoff_verify_command="make test", + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=1), codex=PoolEntry(count=1) + ), + ) + result = resolve_required_verdicts(agents) + assert set(result) == {"verify", "review", "verify_acceptance"} + + def test_default_required_verdicts_include_acceptance(self): + """The ACTIVE default couples verify_acceptance into the snapshot.""" + from codeband.state.registration import resolve_required_verdicts + # Default AgentsConfig now has a verifier per vendor → acceptance couples. + agents = AgentsConfig(handoff_verify_command="make test") + result = resolve_required_verdicts(agents) + assert set(result) == {"verify", "review", "verify_acceptance"} + + def test_required_verdicts_pair_when_verifiers_explicitly_inert(self): + """An explicitly-inert verifier pool keeps the verify/review pair.""" + from codeband.state.registration import resolve_required_verdicts + agents = AgentsConfig( + handoff_verify_command="make test", verifiers=VerifiersConfig(), + ) + result = resolve_required_verdicts(agents) + assert set(result) == {"verify", "review"} diff --git a/tests/test_verifier_acceptance.py b/tests/test_verifier_acceptance.py new file mode 100644 index 0000000..1325c18 --- /dev/null +++ b/tests/test_verifier_acceptance.py @@ -0,0 +1,444 @@ +"""Tests for the Verifier acceptance gate (PR2). + +The activation of the Verifier seat: the ``cb-phase verify-acceptance`` leg, the +``verify_acceptance`` merge verdict, the broken-chain interlock, the +claim-vs-store audit, the role gate, and the registration coupling that makes +acceptance required exactly when a verifier is configured. These tests configure +the verifier pool explicitly (active or inert) so each path is self-contained +and independent of the product default. + +Deterministic throughout — real SQLite, real FSM, no network. The ``cb-phase`` +leg tests monkeypatch only the store/task/PR-head resolvers, exactly like the +review-leg tests in ``test_handoff.py``. +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from codeband.cli import handoff +from codeband.config import AgentsConfig, Framework, PoolEntry, VerifiersConfig +from codeband.state.fsm import ( + MAX_REVIEW_ROUNDS, + InvalidTransitionError, + check_merge_eligibility, + transition, +) +from codeband.state.registration import register_task, resolve_required_verdicts +from codeband.state.store import StateStore +from codeband.workers import WorkerPool, WorkerRole + + +# ─── fixtures / helpers ─────────────────────────────────────────────────────── + + +def _verifier_agents(**overrides) -> AgentsConfig: + """AgentsConfig with an executable verify leg and an active verifier. + + Pins the verifier pool explicitly (1 per vendor) so the acceptance gate is + exercised independently of the product default. Callers can override + ``verifiers`` to drive the no-verifier / single-vendor paths. + """ + overrides.setdefault("handoff_verify_command", "true") + overrides.setdefault( + "verifiers", + VerifiersConfig( + claude_sdk=PoolEntry(count=1), codex=PoolEntry(count=1) + ), + ) + return AgentsConfig(**overrides) + + +@pytest.fixture +def store(tmp_path) -> StateStore: + """A store with ``st-1`` at ``review_passed``, verify+review pinned to sha-1. + + The acceptance verdict (and the merge gate) read these same + ``transition_log`` rows, so the fixture drives real transitions with + ``head_sha`` pinned exactly as ``cb-phase`` does. + """ + s = StateStore(tmp_path / "state" / "orchestration.db") + s.register_task_atomic( + task_id="room-1", + description="demo", + room_id="room-1", + owner_id="owner-1", + required_verdicts=["verify", "review", "verify_acceptance"], + ) + for new_state, role, sha in [ + ("assigned", "conductor", None), + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-1"), + ("review_passed", "reviewer", "sha-1"), + ]: + transition("st-1", "room-1", new_state, caller_role=role, store=s, head_sha=sha) + return s + + +def _run_acceptance(monkeypatch, store, *flags, claim=None, head="sha-1"): + monkeypatch.setattr(handoff, "_resolve_store", lambda project_dir: store) + monkeypatch.setattr( + handoff, "_resolve_task_id", + lambda project_dir, s, task_arg: ("room-1", None), + ) + monkeypatch.setattr(handoff, "_pr_head_sha", lambda project_dir, pr: head) + argv = ["verify-acceptance", "st-1", "--task", "room-1", "--pr", "42", *flags] + if claim is not None: + argv += ["--claim", claim] + return handoff.main(argv) + + +# ─── merge gating: verify_acceptance is a required, SHA-pinned verdict ──────── + + +def test_acceptance_gates_merge_like_review(store): + """Without a passing acceptance verdict, a verifier task is not merge-eligible.""" + # verify + review are pinned to sha-1, but no acceptance verdict yet. + before = check_merge_eligibility("room-1", "st-1", "sha-1", store=store) + assert before.eligible is False + assert any(r.startswith("missing_verdict verify_acceptance") for r in before.reasons) + + # The Verifier accepts at the same head → all three verdicts pinned to sha-1. + transition( + "st-1", "room-1", "acceptance_passed", + caller_role="verifier", store=store, head_sha="sha-1", + ) + after = check_merge_eligibility("room-1", "st-1", "sha-1", store=store) + assert after.eligible is True + assert after.reasons == [] + + +def test_acceptance_pinned_to_wrong_sha_is_stale(store): + """An acceptance verdict at a different head does not satisfy the merge SHA.""" + transition( + "st-1", "room-1", "acceptance_passed", + caller_role="verifier", store=store, head_sha="sha-2", + ) + result = check_merge_eligibility("room-1", "st-1", "sha-1", store=store) + assert result.eligible is False + assert any(r.startswith("stale_verdict verify_acceptance") for r in result.reasons) + + +# ─── the cb-phase verify-acceptance leg ─────────────────────────────────────── + + +def test_accept_advances_to_acceptance_passed(store, monkeypatch): + assert _run_acceptance(monkeypatch, store, "--accept") == 0 + assert store.get_subtask("st-1", "room-1").state == "acceptance_passed" + + +def test_accept_pins_the_pr_head_sha(store, monkeypatch): + _run_acceptance(monkeypatch, store, "--accept", head="sha-1") + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT * FROM transition_log WHERE to_state = 'acceptance_passed' " + "ORDER BY id DESC LIMIT 1", + ).fetchone() + finally: + conn.close() + assert row["head_sha"] == "sha-1" + assert row["caller_role"] == "verifier" + + +def test_reject_advances_to_review_failed_riding_the_cap(store, monkeypatch): + assert _run_acceptance(monkeypatch, store, "--reject") == 0 + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_failed" + assert sub.review_round == 1 # an acceptance reject is one review round + + +def test_accept_illegal_from_non_review_passed_writes_nothing(store, monkeypatch, capsys): + # Move the subtask off review_passed via a legal edge (a merge-gate + # send-back), so the acceptance edge is no longer available. + transition( + "st-1", "room-1", "needs_rebase", + caller_role="mergemaster", store=store, head_sha="sha-1", + ) + assert _run_acceptance(monkeypatch, store, "--accept") == 1 + assert store.get_subtask("st-1", "room-1").state == "needs_rebase" + assert "acceptance verdict rejected" in capsys.readouterr().err + + +def test_requires_an_explicit_verdict(): + with pytest.raises(SystemExit): + handoff.main(["verify-acceptance", "st-1", "--task", "room-1", "--pr", "42"]) + + +def test_head_unresolved_records_nothing(store, monkeypatch, capsys): + code = _run_acceptance(monkeypatch, store, "--accept", head=None) + assert code == handoff.EXIT_HEAD_UNRESOLVED + assert store.get_subtask("st-1", "room-1").state == "review_passed" + assert "head_unresolved" in capsys.readouterr().err + + +# ─── broken-chain interlock ─────────────────────────────────────────────────── + + +def _break_chain(store: StateStore) -> None: + """Tamper a hashed business column without recomputing row_hash.""" + conn = sqlite3.connect(store.db_path) + try: + conn.execute( + "UPDATE transition_log SET reason = 'TAMPERED' WHERE id = " + "(SELECT MIN(id) FROM transition_log)", + ) + conn.commit() + finally: + conn.close() + + +def test_broken_chain_blocks_passing_verdict(store, monkeypatch, capsys): + _break_chain(store) + code = _run_acceptance(monkeypatch, store, "--accept") + assert code == handoff.EXIT_CHAIN_BROKEN + # The verdict was NOT issued — subtask still rests at review_passed. + assert store.get_subtask("st-1", "room-1").state == "review_passed" + assert "chain_broken" in capsys.readouterr().err + + +def test_broken_chain_does_not_block_a_reject(store, monkeypatch): + # A reject is not a passing verdict, so the interlock does not apply — the + # subtask can still be sent back even over a compromised ledger. + _break_chain(store) + assert _run_acceptance(monkeypatch, store, "--reject") == 0 + assert store.get_subtask("st-1", "room-1").state == "review_failed" + + +# ─── claim-vs-store audit ───────────────────────────────────────────────────── + + +def test_claim_divergence_blocks_acceptance(store, monkeypatch, capsys): + # The agent claimed "merged" but the store FSM state is review_passed. + code = _run_acceptance(monkeypatch, store, "--accept", claim="merged") + assert code == handoff.EXIT_CLAIM_MISMATCH + assert store.get_subtask("st-1", "room-1").state == "review_passed" + assert "claim_divergence" in capsys.readouterr().err + + +def test_claim_approved_without_grant_diverges(store, monkeypatch): + # "approved" asserts a SHA-pinned merge grant exists; none does here. + code = _run_acceptance(monkeypatch, store, "--accept", claim="approved") + assert code == handoff.EXIT_CLAIM_MISMATCH + assert store.get_subtask("st-1", "room-1").state == "review_passed" + + +def test_matching_claim_passes(store, monkeypatch): + # A truthful claim at acceptance time is review_passed. + assert _run_acceptance(monkeypatch, store, "--accept", claim="review_passed") == 0 + assert store.get_subtask("st-1", "room-1").state == "acceptance_passed" + + +def test_no_claim_skips_the_audit(store, monkeypatch): + assert _run_acceptance(monkeypatch, store, "--accept") == 0 + assert store.get_subtask("st-1", "room-1").state == "acceptance_passed" + + +# ─── role gate: only the verifier role may run the leg ──────────────────────── + + +def test_role_gate_allows_verifier(store, monkeypatch): + monkeypatch.setenv("CODEBAND_ROLE", "verifier") + assert _run_acceptance(monkeypatch, store, "--accept") == 0 + assert store.get_subtask("st-1", "room-1").state == "acceptance_passed" + + +def test_role_gate_blocks_non_verifier(store, monkeypatch, capsys): + monkeypatch.setenv("CODEBAND_ROLE", "reviewer") + code = _run_acceptance(monkeypatch, store, "--accept") + assert code == handoff.EXIT_ROLE_MISMATCH + # The gate fires before dispatch — nothing written. + assert store.get_subtask("st-1", "room-1").state == "review_passed" + assert "role_mismatch" in capsys.readouterr().err + + +def test_role_gate_unset_role_allowed(store, monkeypatch): + monkeypatch.delenv("CODEBAND_ROLE", raising=False) + assert _run_acceptance(monkeypatch, store, "--accept") == 0 + + +def test_verify_acceptance_in_role_allowlist(): + assert handoff._ROLE_ALLOWED["verify-acceptance"] == frozenset({"verifier"}) + + +# ─── registration coupling: required with a verifier, loud without ──────────── + + +def test_explicit_acceptance_without_verifier_fails(tmp_path): + store = StateStore(tmp_path / "state" / "orchestration.db") + agents = _verifier_agents( + required_verdicts=["verify", "review", "verify_acceptance"], + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=0), codex=PoolEntry(count=0) + ), + ) + with pytest.raises(ValueError, match="no verifier is configured"): + register_task( + room_id="room-1", description="t", owner_id="owner-1", + agents=agents, project_dir=tmp_path, store=store, + ) + assert store.get_task("room-1") is None + + +def test_acceptance_snapshotted_when_verifier_active(tmp_path): + store = StateStore(tmp_path / "state" / "orchestration.db") + register_task( + room_id="room-1", description="t", owner_id="owner-1", + agents=_verifier_agents(), project_dir=tmp_path, store=store, + ) + task = store.get_task("room-1") + assert task is not None + assert "verify_acceptance" in task.required_verdicts + + +def test_resolve_includes_acceptance_only_when_verifier_present(): + with_verifier = resolve_required_verdicts(_verifier_agents()) + assert "verify_acceptance" in with_verifier + + without = resolve_required_verdicts( + _verifier_agents( + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=0), codex=PoolEntry(count=0) + ), + ) + ) + assert "verify_acceptance" not in without + + +# ─── vendor pairing: opposite-vendor produces the verdict, single-vendor warns ─ + + +def _pool(claude: int, codex: int) -> WorkerPool: + pool = WorkerPool() + if claude: + pool.register(WorkerRole.VERIFIER, Framework.CLAUDE_SDK, claude) + if codex: + pool.register(WorkerRole.VERIFIER, Framework.CODEX, codex) + return pool + + +def test_opposite_vendor_verifier_acquired_then_records_verdict(store, monkeypatch): + # A Claude coder pairs with the opposite-vendor (Codex) verifier... + pool = _pool(claude=1, codex=1) + vid = pool.acquire_verifier_for(Framework.CLAUDE_SDK) + assert vid is not None + assert vid.framework == Framework.CODEX + + # ...and that verifier renders the SHA-pinned verdict through the leg. + monkeypatch.setenv("CODEBAND_ROLE", "verifier") + assert _run_acceptance(monkeypatch, store, "--accept") == 0 + assert store.get_subtask("st-1", "room-1").state == "acceptance_passed" + + +def test_single_vendor_degrades_with_doctor_warn_not_failure(tmp_path): + from codeband.config import CodebandConfig, FrameworkPool, RepoConfig + from codeband.doctor import Context, Status, check_verifier_pairing + + # All-Claude coders + Claude-only verifiers: same-vendor checking. This is a + # degrade (doctor WARN), never a hard fail — registration still succeeds. + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/a/b.git"), + agents=AgentsConfig( + coders=FrameworkPool( + claude_sdk=PoolEntry(count=1), codex=PoolEntry(count=0) + ), + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=1), codex=PoolEntry(count=0) + ), + ), + ) + result = check_verifier_pairing(Context(project_dir=tmp_path, config=config)) + assert result.status == Status.WARN + + # And the required-verdict resolution still couples on (no fail-loud). + resolved = resolve_required_verdicts( + _verifier_agents( + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=1), codex=PoolEntry(count=0) + ), + ) + ) + assert "verify_acceptance" in resolved + + +# ─── end-to-end producer path: review_passed → verifier → acceptance → merge ── + + +def test_acceptance_passed_to_merge_pending_edge_exists(): + """The FSM edge acceptance_passed → merge_pending is defined for the + Mergemaster (the lane an accepted verifier task takes to merge), and the + review_passed → merge_pending edge remains intact as the no-verifier bypass. + """ + from codeband.state.fsm import VALID_TRANSITIONS + + assert "merge_pending" in VALID_TRANSITIONS[("acceptance_passed", "mergemaster")] + assert "merge_pending" in VALID_TRANSITIONS[("review_passed", "mergemaster")] + + +def test_e2e_accept_path_review_passed_to_merge_pending(store, monkeypatch): + """Full producer path: a review_passed subtask is picked up by an + opposite-vendor verifier, the verifier accepts, and the now-acceptance_passed + subtask clears the merge gate into merge_pending (and on to merged). An + accepted task does NOT gate-stall under the active default — verify, review, + and verify_acceptance are all pinned to the same head. + """ + # An opposite-vendor (Codex) verifier is acquired for the Claude coder. + pool = _pool(claude=1, codex=1) + vid = pool.acquire_verifier_for(Framework.CLAUDE_SDK) + assert vid is not None and vid.framework == Framework.CODEX + + # That verifier renders --accept through the leg → acceptance_passed. + monkeypatch.setenv("CODEBAND_ROLE", "verifier") + assert _run_acceptance(monkeypatch, store, "--accept") == 0 + assert store.get_subtask("st-1", "room-1").state == "acceptance_passed" + + # The Mergemaster queues it: acceptance_passed → merge_pending passes the + # gate (all three verdicts pinned to sha-1) rather than stalling. + transition( + "st-1", "room-1", "merge_pending", + caller_role="mergemaster", store=store, head_sha="sha-1", + ) + assert store.get_subtask("st-1", "room-1").state == "merge_pending" + + # And it lands. + transition( + "st-1", "room-1", "merged", + caller_role="mergemaster", store=store, head_sha="sha-1", + ) + assert store.get_subtask("st-1", "room-1").state == "merged" + + +def test_e2e_reject_rides_review_round_cap_to_blocked(store, monkeypatch): + """Acceptance --reject reuses the review-round counter + cap: repeated + rejects bounce the subtask back through review_failed until the cap forces + blocked — no new dispute mechanism, no Conductor adjudication. + """ + monkeypatch.setenv("CODEBAND_ROLE", "verifier") + for round_n in range(1, MAX_REVIEW_ROUNDS + 1): + # Rests at review_passed (the fixture for round 1; re-earned below). + assert store.get_subtask("st-1", "room-1").state == "review_passed" + assert _run_acceptance(monkeypatch, store, "--reject") == 0 + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_failed" + assert sub.review_round == round_n # each reject is one review round + if round_n < MAX_REVIEW_ROUNDS: + # Coder reworks and re-earns verify + review at the head. + for ns, role, sha in [ + ("in_progress", "coder", None), + ("verify_pending", "coder", None), + ("review_pending", "coder", "sha-1"), + ("review_passed", "reviewer", "sha-1"), + ]: + transition( + "st-1", "room-1", ns, caller_role=role, store=store, head_sha=sha, + ) + + # At the cap, another rework is illegal — blocked is the only legal escape. + with pytest.raises(InvalidTransitionError): + transition("st-1", "room-1", "in_progress", caller_role="coder", store=store) + transition("st-1", "room-1", "blocked", caller_role="coder", store=store) + assert store.get_subtask("st-1", "room-1").state == "blocked" diff --git a/tests/test_verify_gate_integration.py b/tests/test_verify_gate_integration.py new file mode 100644 index 0000000..1d94298 --- /dev/null +++ b/tests/test_verify_gate_integration.py @@ -0,0 +1,376 @@ +"""Integration tests for the FSM lifecycle gap fix (WS1). + +Tests that ``cb-phase verify`` works from ``in_progress`` (first submit), +``review_failed`` (rework), and ``verify_pending`` (retry), walking only +legal FSM edges. Also tests cap escalation from both entry paths. + +Also covers the lifecycle seam: ``cb-phase start`` seeds a subtask into +``in_progress`` at pickup, and ``cb-phase verify`` self-seeds from a +missing/``planned``/``assigned`` subtask so a skipped ``start`` degrades +gracefully instead of dead-ending. + +Uses the same real-git + real-sqlite pattern as ``test_rails_integration.py``. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from codeband.cli import handoff +from codeband.config import AgentsConfig, CodebandConfig, RepoConfig, WorkspaceConfig +from codeband.state import StateStore +from codeband.state.fsm import MAX_REVIEW_ROUNDS, transition + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, text=True, check=True, + ) + return result.stdout.strip() + + +def _init_repo(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["git", "init", "-b", "main", str(path)], + check=True, capture_output=True, text=True, + ) + _git(path, "config", "user.email", "test@example.com") + _git(path, "config", "user.name", "Test") + (path / "README.md").write_text("seed\n", encoding="utf-8") + _git(path, "add", "README.md") + _git(path, "commit", "-m", "initial commit") + return path + + +def _new_store(tmp_path: Path) -> StateStore: + return StateStore(tmp_path / "state" / "orchestration.db") + + +def _project(tmp_path, *, verify_command=None): + project_dir = tmp_path / "project" + project_dir.mkdir() + workspace = tmp_path / "workspace" + cfg = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", branch="main"), + agents=AgentsConfig(handoff_verify_command=verify_command), + workspace=WorkspaceConfig(path=str(workspace)), + ) + cfg.to_yaml(project_dir / "codeband.yaml") + # Active-room pointer: cb-phase resolves the authoritative task_id (room + # UUID) from this, not from the --task label the agents pass. + (project_dir / ".codeband_room").write_text("room-1", encoding="utf-8") + store = StateStore(workspace / "state" / "orchestration.db") + store.create_task("room-1", "demo", "room-1") + return project_dir, store + + +def _stub_pr_snapshot(monkeypatch, repo: Path, *, state: str = "OPEN") -> None: + """Stub verify's ONE PR snapshot (gh) to track the repo's real state. + + PR-pinned verify outcomes require worktree HEAD == PR head and the PR's + head branch == the worktree branch; these tests exercise the real git + side, so the gh side is made to agree (lazily — evaluated per call, so a + commit made mid-test moves the stubbed PR head too). + """ + monkeypatch.setattr( + handoff, "_verify_pr_snapshot", + lambda project_dir, pr: { + "state": state, + "headRefName": _git(repo, "rev-parse", "--abbrev-ref", "HEAD"), + "headRefOid": _git(repo, "rev-parse", "HEAD"), + }, + ) + + +def _run_verify(project_dir: Path, worktree: Path) -> int: + return handoff.main([ + "verify", "st-1", + "--task", "room-1", + "--pr", "42", + "--worktree", str(worktree), + "--project-dir", str(project_dir), + ]) + + +def _run_start(project_dir: Path, worktree: Path) -> int: + return handoff.main([ + "start", "st-1", + "--task", "room-1", + "--worktree", str(worktree), + "--project-dir", str(project_dir), + ]) + + +def _seed_in_progress(store: StateStore) -> None: + transition("st-1", "room-1", "assigned", caller_role="conductor", store=store) + transition("st-1", "room-1", "in_progress", caller_role="coder", store=store) + + +def _seed_review_failed(store: StateStore) -> None: + _seed_in_progress(store) + transition("st-1", "room-1", "verify_pending", caller_role="coder", store=store) + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + transition("st-1", "room-1", "review_failed", caller_role="reviewer", store=store) + + +class TestVerifyFromInProgress: + """``cb-phase verify`` from ``in_progress`` (first submit).""" + + def test_happy_path_advances_to_review_pending(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path, verify_command="exit 0") + _seed_in_progress(store) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + assert _run_verify(project_dir, repo) == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + def test_dirty_tree_rejects_at_verify_pending(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path) + _seed_in_progress(store) + repo = _init_repo(tmp_path / "repo") + (repo / "uncommitted.txt").write_text("dirty\n", encoding="utf-8") + + assert _run_verify(project_dir, repo) == handoff.EXIT_DIRTY_TREE + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + def test_no_pr_rejects_at_verify_pending(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path) + _seed_in_progress(store) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo, state="CLOSED") + + assert _run_verify(project_dir, repo) == handoff.EXIT_NO_PR + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + def test_verify_command_failure_rejects(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path, verify_command="exit 1") + _seed_in_progress(store) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + assert _run_verify(project_dir, repo) == handoff.EXIT_VERIFY_FAILED + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + +class TestVerifyFromReviewFailed: + """``cb-phase verify`` from ``review_failed`` (rework).""" + + def test_rework_advances_to_review_pending(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path, verify_command="exit 0") + _seed_review_failed(store) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + assert _run_verify(project_dir, repo) == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + def test_rework_gate_rejection_lands_at_verify_pending(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path) + _seed_review_failed(store) + repo = _init_repo(tmp_path / "repo") + (repo / "uncommitted.txt").write_text("dirty\n", encoding="utf-8") + + assert _run_verify(project_dir, repo) == handoff.EXIT_DIRTY_TREE + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + +class TestVerifyAttemptCapFromInProgress: + """Verify-attempt cap fires correctly when entering from ``in_progress``.""" + + def test_cap_fires_after_walk(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path, verify_command="exit 0") + _seed_in_progress(store) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + monkeypatch.setattr(handoff, "_max_verify_attempts", lambda project_dir: 3) + + for _ in range(3): + store.increment_verify_attempts("st-1", "room-1") + + assert _run_verify(project_dir, repo) == handoff.EXIT_CAP_REACHED + assert store.get_subtask("st-1", "room-1").state == "blocked" + + +class TestReviewRoundCapEscalation: + """Review-round cap during ``review_failed → in_progress`` walk.""" + + def test_review_cap_escalates_to_blocked(self, tmp_path, monkeypatch, capsys): + project_dir, store = _project(tmp_path, verify_command="exit 0") + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + _seed_review_failed(store) + for _ in range(MAX_REVIEW_ROUNDS - 1): + transition("st-1", "room-1", "in_progress", caller_role="coder", store=store) + transition("st-1", "room-1", "verify_pending", caller_role="coder", store=store) + transition("st-1", "room-1", "review_pending", caller_role="coder", store=store) + transition("st-1", "room-1", "review_failed", caller_role="reviewer", store=store) + + assert store.get_subtask("st-1", "room-1").review_round == MAX_REVIEW_ROUNDS + assert store.get_subtask("st-1", "room-1").state == "review_failed" + + assert _run_verify(project_dir, repo) == handoff.EXIT_CAP_REACHED + assert store.get_subtask("st-1", "room-1").state == "blocked" + err = capsys.readouterr().err + assert "BLOCKED [review_cap_reached]" in err + + +class TestVerifyCountDurability: + """Verify-attempt count survives store reopen.""" + + def test_count_survives_store_reopen(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path, verify_command="exit 1") + _seed_in_progress(store) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + assert _run_verify(project_dir, repo) != 0 + assert store.get_subtask("st-1", "room-1").verify_attempts == 1 + + db_path = store.db_path + del store + reopened = StateStore(db_path) + assert reopened.get_subtask("st-1", "room-1").verify_attempts == 1 + assert reopened.get_subtask("st-1", "room-1").state == "verify_pending" + + +class TestNonCapTransitionErrorNotMisclassified: + """A non-cap ``InvalidTransitionError`` on ``review_failed → in_progress`` + must NOT escalate to ``blocked`` with ``review_cap_reached``.""" + + def test_non_cap_error_does_not_block(self, tmp_path, monkeypatch, capsys): + from unittest.mock import patch as mock_patch + + from codeband.state.fsm import InvalidTransitionError as ITE + + project_dir, store = _project(tmp_path, verify_command="exit 0") + _seed_review_failed(store) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + assert store.get_subtask("st-1", "room-1").review_round == 1 + assert store.get_subtask("st-1", "room-1").review_round < MAX_REVIEW_ROUNDS + + original_transition = handoff.transition + + def _failing_transition(subtask_id, task_id, new_state, **kwargs): + if new_state == "in_progress": + raise ITE("concurrent state mutation (simulated)") + return original_transition(subtask_id, task_id, new_state, **kwargs) + + with mock_patch.object(handoff, "transition", side_effect=_failing_transition): + exit_code = _run_verify(project_dir, repo) + + assert exit_code == 1 + assert exit_code != handoff.EXIT_CAP_REACHED + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_failed" + assert sub.state != "blocked" + err = capsys.readouterr().err + assert "review_cap_reached" not in err + assert "transition rejected" in err + + +class TestStartSeedsLifecycle: + """``cb-phase start`` seeds the subtask the Conductor never advances.""" + + def test_start_on_nonexistent_subtask_lands_in_progress(self, tmp_path): + project_dir, store = _project(tmp_path) + repo = _init_repo(tmp_path / "repo") + assert store.get_subtask("st-1", "room-1") is None + + assert _run_start(project_dir, repo) == 0 + assert store.get_subtask("st-1", "room-1").state == "in_progress" + + def test_start_is_idempotent_and_non_regressing(self, tmp_path): + project_dir, store = _project(tmp_path) + repo = _init_repo(tmp_path / "repo") + + assert _run_start(project_dir, repo) == 0 + assert _run_start(project_dir, repo) == 0 # twice → still in_progress + assert store.get_subtask("st-1", "room-1").state == "in_progress" + + def test_start_never_rewinds_a_later_state(self, tmp_path): + project_dir, store = _project(tmp_path) + repo = _init_repo(tmp_path / "repo") + _seed_review_failed(store) # st-1 at review_failed (round 1) + + assert _run_start(project_dir, repo) == 0 + sub = store.get_subtask("st-1", "room-1") + assert sub.state == "review_failed" # not moved backward + assert sub.review_round == 1 # start touched no counters + + def test_full_happy_path_start_then_verify(self, tmp_path, monkeypatch): + # start (pickup) → clean tree + open PR + passing verify → review_pending. + project_dir, store = _project(tmp_path, verify_command="exit 0") + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + assert _run_start(project_dir, repo) == 0 + assert store.get_subtask("st-1", "room-1").state == "in_progress" + assert _run_verify(project_dir, repo) == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + +class TestVerifySelfSeedsFromMissingOrPlanned: + """REGRESSION: a skipped ``cb-phase start`` no longer dead-ends verify. + + A missing/``planned``/``assigned`` subtask self-seeds to ``in_progress``, + then runs the existing gate — reaching ``review_pending`` (gate passes) or + ``verify_pending`` (gate rejects), never the old "not a valid entry state". + """ + + def test_verify_on_nonexistent_subtask_self_seeds_and_passes( + self, tmp_path, monkeypatch + ): + project_dir, store = _project(tmp_path, verify_command="exit 0") + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + assert store.get_subtask("st-1", "room-1") is None # nothing ran start + + assert _run_verify(project_dir, repo) == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + def test_verify_on_planned_self_seeds_then_gate_rejects( + self, tmp_path, monkeypatch + ): + project_dir, store = _project(tmp_path) + store.ensure_subtask("st-1", "room-1") # row exists at 'planned' + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo, state="CLOSED") + + # Self-seeds past planned, runs the gate, lands at verify_pending — the + # gate's no_pr rejection, NOT the old "not a valid entry state" exit. + assert _run_verify(project_dir, repo) == handoff.EXIT_NO_PR + assert store.get_subtask("st-1", "room-1").state == "verify_pending" + + def test_verify_on_assigned_self_seeds_and_passes(self, tmp_path, monkeypatch): + project_dir, store = _project(tmp_path, verify_command="exit 0") + transition("st-1", "room-1", "assigned", caller_role="conductor", store=store) + repo = _init_repo(tmp_path / "repo") + _stub_pr_snapshot(monkeypatch, repo) + + assert _run_verify(project_dir, repo) == 0 + assert store.get_subtask("st-1", "room-1").state == "review_pending" + + +class TestInvalidEntryState: + """``cb-phase verify`` from a genuinely-invalid state still exits 1. + + Self-seeding covers missing/planned/assigned; a state past the verify gate + with no legal walk back (e.g. ``blocked``) must still dead-end cleanly. + """ + + def test_blocked_state_rejected(self, tmp_path, monkeypatch, capsys): + project_dir, store = _project(tmp_path) + _seed_in_progress(store) + transition("st-1", "room-1", "blocked", caller_role="watchdog", store=store) + repo = _init_repo(tmp_path / "repo") + + assert _run_verify(project_dir, repo) == 1 + err = capsys.readouterr().err + assert "not a valid entry state" in err diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py index b5f5057..af49ea9 100644 --- a/tests/test_watchdog.py +++ b/tests/test_watchdog.py @@ -1056,16 +1056,8 @@ async def test_escalation_does_not_mention_conductor( assert "agent-p0" in mention_ids assert "Coder-Claude-0" in msg.content - @pytest.mark.asyncio - async def test_escalation_flag_set_even_on_send_failure( - self, watchdog_config, mock_rest_client, - ): - """``state.escalated`` must flip to True once an escalation is attempted. - - Regression: previously the flag was set only after a successful send, - so any 422 left escalation unflipped and the Watchdog retried the - same escalation on every patrol interval forever. - """ + def _stale_escalation_daemon(self, watchdog_config, mock_rest_client): + """A daemon with one stale, already-nudged agent — ripe for escalation.""" from codeband.agents.watchdog import AgentHealthState, WatchdogDaemon mock_rest_client.agent_api_chats.list_agent_chats = AsyncMock( @@ -1084,9 +1076,6 @@ async def test_escalation_flag_set_even_on_send_failure( ("agent-p0", "Coder-Claude-0"), ]), ) - mock_rest_client.agent_api_messages.create_agent_chat_message = AsyncMock( - side_effect=RuntimeError("simulated server rejection"), - ) daemon = WatchdogDaemon( config=watchdog_config, @@ -1099,19 +1088,75 @@ async def test_escalation_flag_set_even_on_send_failure( nudged_at=datetime.now(UTC) - timedelta(seconds=120), nudge_count=1, ) + return daemon + + @pytest.mark.asyncio + async def test_escalation_transient_failure_retries_then_burns( + self, watchdog_config, mock_rest_client, + ): + """A transient send failure leaves escalate-once unburned; the next + patrol retries, and the successful send burns it — exactly one + successful escalation total (marker-after-send). + """ + mock_rest_client.agent_api_messages.create_agent_chat_message = AsyncMock( + side_effect=[RuntimeError("simulated transient failure"), None], + ) + daemon = self._stale_escalation_daemon(watchdog_config, mock_rest_client) + + await daemon._patrol() + assert daemon._state["agent-p0"].escalated is False, ( + "transient send failure must not burn the escalate-once marker" + ) + # Second patrol retries; the send succeeds and burns the marker. await daemon._patrol() assert daemon._state["agent-p0"].escalated is True + assert ( + mock_rest_client.agent_api_messages.create_agent_chat_message.call_count + == 2 + ) - # Second patrol under the same conditions must not attempt another send. - call_count_after_first = ( + # Third patrol: escalate-once holds on the success path — no more sends. + await daemon._patrol() + assert ( mock_rest_client.agent_api_messages.create_agent_chat_message.call_count + == 2 + ), "escalation re-sent after success — escalate-once invariant broken" + + @pytest.mark.asyncio + async def test_escalation_422_burns_marker_and_logs_critical( + self, watchdog_config, mock_rest_client, caplog, + ): + """A 422 mention rejection is permanent: burn the marker anyway, log + at CRITICAL, and never retry on subsequent patrols. + """ + import logging + + from thenvoi_rest.core.api_error import ApiError + + mock_rest_client.agent_api_messages.create_agent_chat_message = AsyncMock( + side_effect=ApiError( + status_code=422, headers={}, + body="mentioned_participant_not_in_room", + ), ) + daemon = self._stale_escalation_daemon(watchdog_config, mock_rest_client) + + with caplog.at_level(logging.CRITICAL, logger="codeband.agents.watchdog"): + await daemon._patrol() + + assert daemon._state["agent-p0"].escalated is True + critical = [r for r in caplog.records if r.levelno == logging.CRITICAL] + assert len(critical) == 1 + assert "not mentionable" in critical[0].getMessage() + assert "escalation undeliverable" in critical[0].getMessage() + + # No retry: the marker is burned despite the failed send. await daemon._patrol() assert ( mock_rest_client.agent_api_messages.create_agent_chat_message.call_count - == call_count_after_first - ), "escalation retried after failure — escalate-once invariant broken" + == 1 + ) @pytest.mark.asyncio async def test_skips_own_messages(self, watchdog_config, mock_rest_client): diff --git a/tests/test_watchdog_acceptance_advance_rung.py b/tests/test_watchdog_acceptance_advance_rung.py new file mode 100644 index 0000000..e1ffa18 --- /dev/null +++ b/tests/test_watchdog_acceptance_advance_rung.py @@ -0,0 +1,433 @@ +"""Tests for the acceptance_passed → Mergemaster auto-advance rung. + +Covers: +- _maybe_acceptance_advance_renudge: no-store path, within-window path, + window-elapsed-fires path, post-fire marker dedup, cap-hit path, + re-entry re-arms independent of old markers. +- _check_one_subtask integration: acceptance_passed + window elapsed → + fires once, writes marker, early-returns (no stall counter advance). +- Renudge cap reached → returns False (releases to stall→blocked path). +- merge_pending (#96) leg unaffected: merge_pending subtasks route through + the original backstop, not the new rung. +""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.config import WatchdogConfig +from codeband.state import StateStore + +TASK_ID = "task-aa-1" +SUBTASK_ID = "st-aa-1" +ROOM_ID = "room-aa-1" +PR_NUMBER = 55 + + +# ── fixtures ────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def store(tmp_path: Path) -> StateStore: + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(TASK_ID, "acceptance advance test", ROOM_ID, owner_id="owner-1") + (s.db_path.parent / ".codeband_room").write_text(ROOM_ID, encoding="utf-8") + s.ensure_subtask(SUBTASK_ID, TASK_ID, state="in_progress") + return s + + +def _set_acceptance_passed(store: StateStore, *, minutes_ago: float = 10.0) -> str: + """Put the subtask in acceptance_passed and return its updated_at string.""" + ts = (datetime.now(UTC) - timedelta(minutes=minutes_ago)).isoformat() + conn = sqlite3.connect(store.db_path) + conn.execute( + "UPDATE subtask_states SET state = 'acceptance_passed', " + "updated_at = ?, pr_number = ?, metadata = ? " + "WHERE subtask_id = ? AND task_id = ?", + ( + ts, + PR_NUMBER, + json.dumps({"branch": "feature-aa"}), + SUBTASK_ID, + TASK_ID, + ), + ) + conn.commit() + conn.close() + return ts + + +def _insert_audit_row( + store: StateStore, + event_type: str, + *, + ts: str, + payload: dict | None = None, +) -> None: + conn = sqlite3.connect(store.db_path) + payload_json = json.dumps(payload) if payload is not None else None + conn.execute( + "INSERT INTO audit_log " + "(ts, event_type, task_id, subtask_id, payload, " + "actor_cwd, actor_pid, actor_role, prev_hash, row_hash) " + "VALUES (?, ?, ?, ?, ?, '', 0, '', '', '')", + (ts, event_type, TASK_ID, SUBTASK_ID, payload_json), + ) + conn.commit() + conn.close() + + +def _mock_rest(mm_id: str = "agent-mm") -> MagicMock: + rest = MagicMock() + rest.agent_api_messages = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + rest.agent_api_memories = MagicMock() + rest.agent_api_memories.create_agent_memory = AsyncMock() + return rest + + +def _daemon( + store: StateStore, + *, + rest: MagicMock | None = None, + config: WatchdogConfig | None = None, + mm_id: str = "agent-mm", +) -> "WatchdogDaemon": # noqa: F821 + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=config or WatchdogConfig( + max_phase_visits=5, + git_progress_check=True, + acceptance_advance_backstop_seconds=240, + acceptance_advance_max_renudges=1, + ), + rest_client=rest or _mock_rest(mm_id), + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + agent_id_to_role={mm_id: "mergemaster"}, + ) + + +def _ts(minutes_ago: float = 0.0) -> str: + dt = datetime.now(UTC) - timedelta(minutes=minutes_ago) + return dt.isoformat() + + +def _sub_row( + *, + state: str = "acceptance_passed", + updated_at: str | None = None, + pr_number: int = PR_NUMBER, +) -> MagicMock: + sub = MagicMock() + sub.task_id = TASK_ID + sub.subtask_id = SUBTASK_ID + sub.state = state + sub.pr_number = pr_number + sub.updated_at = updated_at or _ts(10) # 10 min ago by default + sub.merge_approved_sha = None + sub.metadata = {"branch": "feature-aa"} + return sub + + +# ── _maybe_acceptance_advance_renudge ───────────────────────────────────────── + + +class TestMaybeAcceptanceAdvanceRenudge: + @pytest.mark.asyncio + async def test_no_store_returns_false(self) -> None: + """When no state_store is injected the rung returns False (safe degradation).""" + from codeband.agents.watchdog import WatchdogDaemon + + daemon = WatchdogDaemon( + config=WatchdogConfig(acceptance_advance_max_renudges=1), + rest_client=_mock_rest(), + agent_id="agent-wd", + conductor_id="agent-cond", + ) + result = await daemon._maybe_acceptance_advance_renudge( + _sub_row(), datetime.now(UTC), + ) + assert result is False + + @pytest.mark.asyncio + async def test_within_window_returns_true_no_send( + self, store: StateStore, + ) -> None: + """Entry < window → return True, no send (rung owns patrol).""" + sub = _sub_row(updated_at=_ts(1)) # 1 min ago, window=4 min + rest = _mock_rest() + daemon = _daemon(store, rest=rest) + result = await daemon._maybe_acceptance_advance_renudge(sub, datetime.now(UTC)) + assert result is True + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_window_elapsed_fires_once_writes_marker( + self, store: StateStore, + ) -> None: + """Entry past staleness window → sends renudge, writes marker, returns True.""" + sub = _sub_row(updated_at=_ts(10)) # 10 min ago, window=4 min + rest = _mock_rest() + daemon = _daemon(store, rest=rest) + now = datetime.now(UTC) + result = await daemon._maybe_acceptance_advance_renudge(sub, now) + assert result is True + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("acceptance_advance_nudge",), + ) + assert len(rows) == 1 + assert rows[0][2]["pr_number"] == PR_NUMBER + + @pytest.mark.asyncio + async def test_after_fire_within_inter_nudge_window_owns_patrol( + self, store: StateStore, + ) -> None: + """After first fire, within inter-nudge window (cap=2): owns patrol, no send.""" + entry_ts = _ts(10) + # One nudge marker already written 1 min ago (within 4-min window) + _insert_audit_row( + store, "acceptance_advance_nudge", ts=_ts(1), + payload={"pr_number": PR_NUMBER}, + ) + config = WatchdogConfig( + acceptance_advance_backstop_seconds=240, + acceptance_advance_max_renudges=2, # cap=2, 1 used → within-window + ) + sub = _sub_row(updated_at=entry_ts) + rest = _mock_rest() + daemon = _daemon(store, rest=rest, config=config) + result = await daemon._maybe_acceptance_advance_renudge(sub, datetime.now(UTC)) + assert result is True # anchor=1 min ago < 4 min window → no send + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_cap_hit_returns_false(self, store: StateStore) -> None: + """Nudge count >= cap → returns False (releases patrol to stall path).""" + entry_ts = _ts(20) + _insert_audit_row( + store, "acceptance_advance_nudge", ts=_ts(10), + payload={"pr_number": PR_NUMBER}, + ) + sub = _sub_row(updated_at=entry_ts) + rest = _mock_rest() + daemon = _daemon(store, rest=rest) # cap=1 + result = await daemon._maybe_acceptance_advance_renudge(sub, datetime.now(UTC)) + assert result is False + + @pytest.mark.asyncio + async def test_reentry_rearms_independent_of_old_markers( + self, store: StateStore, + ) -> None: + """Markers from a prior acceptance_passed visit don't count against new cap.""" + # Old nudge from the prior visit (before the new entry timestamp) + _insert_audit_row( + store, "acceptance_advance_nudge", ts=_ts(25), + payload={"pr_number": PR_NUMBER}, + ) + # Subtask re-entered acceptance_passed 5 min ago (new visit) + new_entry = _ts(5) + sub = _sub_row(updated_at=new_entry) + rest = _mock_rest() + daemon = _daemon(store, rest=rest) # cap=1 + now = datetime.now(UTC) + result = await daemon._maybe_acceptance_advance_renudge(sub, now) + # window=4min, new entry 5min ago → fires (old marker doesn't count) + assert result is True + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + + @pytest.mark.asyncio + async def test_invalid_updated_at_returns_false(self, store: StateStore) -> None: + """Unparseable updated_at → returns False (safe degradation).""" + sub = _sub_row(updated_at="not-a-timestamp") + daemon = _daemon(store) + result = await daemon._maybe_acceptance_advance_renudge(sub, datetime.now(UTC)) + assert result is False + + +# ── _check_one_subtask integration ──────────────────────────────────────────── + + +class TestCheckOneSubtaskAcceptanceAdvance: + @pytest.mark.asyncio + async def test_acceptance_passed_stalled_fires_nudge_writes_marker( + self, store: StateStore, monkeypatch, + ) -> None: + """acceptance_passed + window elapsed → fires once, writes marker. + + The rung pre-empts the first stall patrol by returning True. After the + cap (1 nudge) is consumed the stall path resumes; with max_phase_visits=2 + the subtask eventually escalates to blocked. The key invariants: + - nudge sent exactly once (not multiple times); + - durable audit marker written; + - stall path resumes correctly after cap. + """ + _set_acceptance_passed(store, minutes_ago=10) + + rest = _mock_rest() + config = WatchdogConfig( + max_phase_visits=5, + git_progress_check=True, + acceptance_advance_backstop_seconds=240, + acceptance_advance_max_renudges=1, + ) + daemon = _daemon(store, rest=rest, config=config) + # git HEAD for branch "feature-aa" — unchanged across patrols + monkeypatch.setattr( + "subprocess.run", + lambda cmd, **kw: type("R", (), {"returncode": 0, "stdout": "abc1234\n"})(), + ) + + now = datetime.now(UTC) + # 3 patrols: patrol-1 fires nudge; patrol-2 cap-hit + git baseline seen + # (progressed=True, visits=0); patrol-3 visits=1. + # With max_phase_visits=5, no blocked escalation fires yet — isolates the + # acceptance-advance nudge as the only message sent. + for _ in range(3): + await daemon._check_subtask_progress(now) + + # Exactly one renudge message sent (acceptance-advance only, not blocked) + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + + # Durable marker written exactly once + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("acceptance_advance_nudge",), + ) + assert len(rows) == 1 + assert rows[0][2]["pr_number"] == PR_NUMBER + + # Subtask stays in acceptance_passed (rung pre-empted first stall patrol; + # stall path resumes after cap but max_phase_visits=5 not yet reached) + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.state == "acceptance_passed" + + @pytest.mark.asyncio + async def test_within_window_no_fire_no_stall( + self, store: StateStore, monkeypatch, + ) -> None: + """acceptance_passed within window → rung owns patrol, no send.""" + _set_acceptance_passed(store, minutes_ago=1) # 1 min ago, window=4 min + + rest = _mock_rest() + config = WatchdogConfig( + max_phase_visits=5, + git_progress_check=True, + acceptance_advance_backstop_seconds=240, + acceptance_advance_max_renudges=1, + ) + daemon = _daemon(store, rest=rest, config=config) + monkeypatch.setattr( + "subprocess.run", + lambda cmd, **kw: type("R", (), {"returncode": 0, "stdout": "abc1234\n"})(), + ) + + now = datetime.now(UTC) + for _ in range(3): + await daemon._check_subtask_progress(now) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.state == "acceptance_passed" + + @pytest.mark.asyncio + async def test_cap_hit_falls_through_to_blocked( + self, store: StateStore, monkeypatch, + ) -> None: + """After cap exhausted → stall counter advances → subtask blocked.""" + _set_acceptance_passed(store, minutes_ago=20) + # Pre-exhaust the cap (1 nudge already sent) + _insert_audit_row( + store, "acceptance_advance_nudge", ts=_ts(10), + payload={"pr_number": PR_NUMBER}, + ) + + rest = _mock_rest() + config = WatchdogConfig( + max_phase_visits=2, + git_progress_check=True, + acceptance_advance_backstop_seconds=240, + acceptance_advance_max_renudges=1, + ) + daemon = _daemon(store, rest=rest, config=config) + monkeypatch.setattr( + "subprocess.run", + lambda cmd, **kw: type("R", (), {"returncode": 0, "stdout": "abc1234\n"})(), + ) + + now = datetime.now(UTC) + for _ in range(5): + await daemon._check_subtask_progress(now) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.state == "blocked" + + @pytest.mark.asyncio + async def test_merge_pending_rung_unaffected( + self, store: StateStore, monkeypatch, + ) -> None: + """merge_pending + matching HEAD + grant present still routes through + the original #96 backstop, not the new acceptance-advance rung.""" + approved_sha = "abc1234" + conn = sqlite3.connect(store.db_path) + conn.execute( + "UPDATE subtask_states SET state = 'merge_pending', " + "merge_approved_sha = ?, pr_number = ?, metadata = ? " + "WHERE subtask_id = ? AND task_id = ?", + ( + approved_sha, + PR_NUMBER, + json.dumps({"branch": "feature-aa"}), + SUBTASK_ID, + TASK_ID, + ), + ) + conn.commit() + conn.close() + + _insert_audit_row( + store, "approval_grant", ts=_ts(10), + payload={"approved_sha": approved_sha}, + ) + + rest = _mock_rest() + config = WatchdogConfig( + max_phase_visits=5, + git_progress_check=True, + merge_approval_backstop_seconds=240, + merge_approval_backstop_max_renudges=1, + acceptance_advance_backstop_seconds=240, + acceptance_advance_max_renudges=1, + ) + daemon = _daemon(store, rest=rest, config=config) + monkeypatch.setattr( + "subprocess.run", + lambda cmd, **kw: type("R", (), { + "returncode": 0, "stdout": approved_sha + "\n", + })(), + ) + + now = datetime.now(UTC) + for _ in range(4): + await daemon._check_subtask_progress(now) + + # The #96 backstop owned the patrol — subtask stays merge_pending + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.state == "merge_pending" + # acceptance_advance_nudge must NOT have been written + aa_rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("acceptance_advance_nudge",), + ) + assert aa_rows == [] diff --git a/tests/test_watchdog_backstop_rung.py b/tests/test_watchdog_backstop_rung.py new file mode 100644 index 0000000..9a00698 --- /dev/null +++ b/tests/test_watchdog_backstop_rung.py @@ -0,0 +1,474 @@ +"""Tests for the approval→merge durable-state backstop rung. + +Covers: +- StateStore.latest_audit_events: ordering, event_type filter, payload decode, + empty/invalid payload handling. +- WatchdogDaemon._maybe_backstop_renudge: no-grant path, within-window path, + window-elapsed-fires path, post-fire marker dedup, cap-hit path, new-SHA + re-arm. +- _check_one_subtask integration: grant-present merge_pending early-returns + (no stall counter advance), falls through after cap. +""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.config import WatchdogConfig +from codeband.state import StateStore + +TASK_ID = "task-backstop-1" +SUBTASK_ID = "st-1" +ROOM_ID = "room-backstop-1" +APPROVED_SHA = "abc1234" + + +# ── fixtures ───────────────────────────────────────────────────────────────── + + +@pytest.fixture +def store(tmp_path: Path) -> StateStore: + s = StateStore(tmp_path / "state" / "orchestration.db") + s.create_task(TASK_ID, "backstop test task", ROOM_ID, owner_id="owner-1") + (s.db_path.parent / ".codeband_room").write_text(ROOM_ID, encoding="utf-8") + s.ensure_subtask(SUBTASK_ID, TASK_ID, state="in_progress") + return s + + +def _set_merge_pending(store: StateStore, *, approved_sha: str) -> None: + conn = sqlite3.connect(store.db_path) + conn.execute( + "UPDATE subtask_states SET state = 'merge_pending', " + "merge_approved_sha = ?, metadata = ? WHERE subtask_id = ? AND task_id = ?", + (approved_sha, json.dumps({"branch": "feature-backstop"}), SUBTASK_ID, TASK_ID), + ) + conn.commit() + conn.close() + + +def _insert_audit_row( + store: StateStore, + event_type: str, + *, + ts: str, + payload: dict | None = None, +) -> None: + """Directly insert an audit_log row (bypasses hash chain — for tests only).""" + conn = sqlite3.connect(store.db_path) + payload_json = json.dumps(payload) if payload is not None else None + conn.execute( + "INSERT INTO audit_log " + "(ts, event_type, task_id, subtask_id, payload, " + "actor_cwd, actor_pid, actor_role, prev_hash, row_hash) " + "VALUES (?, ?, ?, ?, ?, '', 0, '', '', '')", + (ts, event_type, TASK_ID, SUBTASK_ID, payload_json), + ) + conn.commit() + conn.close() + + +def _mock_rest(mm_id: str = "agent-mm") -> MagicMock: + rest = MagicMock() + rest.agent_api_messages = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + rest.agent_api_memories = MagicMock() + rest.agent_api_memories.create_agent_memory = AsyncMock() + return rest + + +def _daemon( + store: StateStore, + *, + rest: MagicMock | None = None, + config: WatchdogConfig | None = None, + mm_id: str = "agent-mm", +) -> "WatchdogDaemon": # noqa: F821 + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=config or WatchdogConfig( + max_phase_visits=5, + git_progress_check=True, + merge_approval_backstop_seconds=240, + merge_approval_backstop_max_renudges=1, + ), + rest_client=rest or _mock_rest(mm_id), + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + agent_id_to_role={mm_id: "mergemaster"}, + ) + + +def _ts(minutes_ago: float = 0.0) -> str: + dt = datetime.now(UTC) - timedelta(minutes=minutes_ago) + return dt.isoformat() + + +# ── StateStore.latest_audit_events ─────────────────────────────────────────── + + +class TestLatestAuditEvents: + def test_returns_empty_when_no_rows(self, store: StateStore) -> None: + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("approval_grant",), + ) + assert rows == [] + + def test_filters_event_type(self, store: StateStore) -> None: + _insert_audit_row( + store, "approval_grant", ts=_ts(10), + payload={"approved_sha": APPROVED_SHA}, + ) + _insert_audit_row( + store, "other_event", ts=_ts(5), + payload={"foo": "bar"}, + ) + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("approval_grant",), + ) + assert len(rows) == 1 + assert rows[0][0] == "approval_grant" + + def test_returns_newest_first(self, store: StateStore) -> None: + _insert_audit_row( + store, "approval_grant", ts=_ts(20), + payload={"approved_sha": "sha1"}, + ) + _insert_audit_row( + store, "merge_backstop_nudge", ts=_ts(5), + payload={"approved_sha": "sha2"}, + ) + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("approval_grant", "merge_backstop_nudge"), + ) + assert len(rows) == 2 + assert rows[0][0] == "merge_backstop_nudge" # newer first + assert rows[1][0] == "approval_grant" + + def test_decodes_payload_dict(self, store: StateStore) -> None: + _insert_audit_row( + store, "approval_grant", ts=_ts(5), + payload={"approved_sha": APPROVED_SHA, "approved_by": "human-1"}, + ) + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("approval_grant",), + ) + assert rows[0][2] == {"approved_sha": APPROVED_SHA, "approved_by": "human-1"} + + def test_null_payload_returns_none(self, store: StateStore) -> None: + _insert_audit_row(store, "approval_grant", ts=_ts(5), payload=None) + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("approval_grant",), + ) + assert rows[0][2] is None + + def test_invalid_json_payload_returns_none(self, store: StateStore) -> None: + # Directly write a broken payload string + conn = sqlite3.connect(store.db_path) + conn.execute( + "INSERT INTO audit_log " + "(ts, event_type, task_id, subtask_id, payload, " + "actor_cwd, actor_pid, actor_role, prev_hash, row_hash) " + "VALUES (?, 'approval_grant', ?, ?, 'NOT JSON', '', 0, '', '', '')", + (_ts(3), TASK_ID, SUBTASK_ID), + ) + conn.commit() + conn.close() + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("approval_grant",), + ) + assert rows[0][2] is None + + def test_empty_event_types_returns_empty(self, store: StateStore) -> None: + _insert_audit_row(store, "approval_grant", ts=_ts(5)) + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=(), + ) + assert rows == [] + + def test_scoped_to_task_and_subtask(self, store: StateStore) -> None: + store.create_task("other-task", "other", "other-room") + store.ensure_subtask("other-sub", "other-task", state="in_progress") + _insert_audit_row( + store, "approval_grant", ts=_ts(5), + payload={"approved_sha": APPROVED_SHA}, + ) + # Rows for other task/subtask must not appear + conn = sqlite3.connect(store.db_path) + conn.execute( + "INSERT INTO audit_log " + "(ts, event_type, task_id, subtask_id, payload, " + "actor_cwd, actor_pid, actor_role, prev_hash, row_hash) " + "VALUES (?, 'approval_grant', 'other-task', 'other-sub', NULL, '', 0, '', '', '')", + (_ts(2),), + ) + conn.commit() + conn.close() + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("approval_grant",), + ) + assert len(rows) == 1 + assert rows[0][0] == "approval_grant" + + +# ── _maybe_backstop_renudge ─────────────────────────────────────────────────── + + +class TestMaybeBackstopRenudge: + @pytest.mark.asyncio + async def test_no_grant_returns_false(self, store: StateStore) -> None: + """No audit_grant row → rung returns False (stall path owns it).""" + sub = _sub_row(approved_sha=APPROVED_SHA) + daemon = _daemon(store) + now = datetime.now(UTC) + result = await daemon._maybe_backstop_renudge(sub, APPROVED_SHA, now) + assert result is False + + @pytest.mark.asyncio + async def test_within_window_returns_true_no_send( + self, store: StateStore, + ) -> None: + """Grant present but within the staleness window → return True, no send.""" + _insert_audit_row( + store, "approval_grant", ts=_ts(1), # 1 min ago, window=4 min + payload={"approved_sha": APPROVED_SHA}, + ) + rest = _mock_rest() + daemon = _daemon(store, rest=rest) + now = datetime.now(UTC) + result = await daemon._maybe_backstop_renudge(sub=_sub_row(), approved_sha=APPROVED_SHA, now=now) + assert result is True + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_window_elapsed_fires_once_writes_marker( + self, store: StateStore, + ) -> None: + """Grant past the staleness window → sends renudge, writes marker, True.""" + _insert_audit_row( + store, "approval_grant", ts=_ts(10), # 10 min ago, window=4 min + payload={"approved_sha": APPROVED_SHA}, + ) + rest = _mock_rest() + daemon = _daemon(store, rest=rest) + now = datetime.now(UTC) + result = await daemon._maybe_backstop_renudge(_sub_row(), APPROVED_SHA, now) + assert result is True + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + + # durable marker written + rows = store.latest_audit_events( + task_id=TASK_ID, subtask_id=SUBTASK_ID, + event_types=("merge_backstop_nudge",), + ) + assert len(rows) == 1 + assert rows[0][2]["approved_sha"] == APPROVED_SHA + + @pytest.mark.asyncio + async def test_after_fire_within_window_owns_patrol( + self, store: StateStore, + ) -> None: + """After the first fire, a second call within the inter-nudge window + returns True (no send) when the cap allows another nudge.""" + # cap=2: one nudge already sent, one remaining — within-window check applies + _insert_audit_row( + store, "approval_grant", ts=_ts(10), + payload={"approved_sha": APPROVED_SHA}, + ) + # pre-insert one nudge marker from 1 min ago (within 4-min window) + _insert_audit_row( + store, "merge_backstop_nudge", ts=_ts(1), + payload={"pr_number": 42, "approved_sha": APPROVED_SHA}, + ) + rest = _mock_rest() + config = WatchdogConfig( + merge_approval_backstop_seconds=240, + merge_approval_backstop_max_renudges=2, # cap=2, 1 used → within-window + ) + daemon = _daemon(store, rest=rest, config=config) + now = datetime.now(UTC) + result = await daemon._maybe_backstop_renudge(_sub_row(), APPROVED_SHA, now) + assert result is True # anchor=1 min ago < 4 min window → own patrol, no send + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_cap_hit_returns_false(self, store: StateStore) -> None: + """Nudge count >= cap → returns False (releases patrol to stall path).""" + _insert_audit_row( + store, "approval_grant", ts=_ts(20), + payload={"approved_sha": APPROVED_SHA}, + ) + # 1 nudge already sent (cap=1) + _insert_audit_row( + store, "merge_backstop_nudge", ts=_ts(10), + payload={"pr_number": 42, "approved_sha": APPROVED_SHA}, + ) + rest = _mock_rest() + daemon = _daemon(store, rest=rest) + now = datetime.now(UTC) + result = await daemon._maybe_backstop_renudge(_sub_row(), APPROVED_SHA, now) + assert result is False + + @pytest.mark.asyncio + async def test_new_sha_rearms_independent_of_old_markers( + self, store: StateStore, + ) -> None: + """Markers for an old SHA don't count against a new SHA's nudge cap.""" + old_sha = "old1234" + new_sha = "new5678" + _insert_audit_row( + store, "approval_grant", ts=_ts(30), + payload={"approved_sha": old_sha}, + ) + # Nudge for old SHA already consumed + _insert_audit_row( + store, "merge_backstop_nudge", ts=_ts(20), + payload={"pr_number": 42, "approved_sha": old_sha}, + ) + # New grant for new SHA + _insert_audit_row( + store, "approval_grant", ts=_ts(10), + payload={"approved_sha": new_sha}, + ) + rest = _mock_rest() + daemon = _daemon(store, rest=rest) + now = datetime.now(UTC) + result = await daemon._maybe_backstop_renudge(_sub_row(approved_sha=new_sha), new_sha, now) + # window=4min, grant 10min ago → fires + assert result is True + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + + @pytest.mark.asyncio + async def test_no_store_returns_false(self) -> None: + """When no state_store is injected the rung returns False (safe degradation).""" + from codeband.agents.watchdog import WatchdogDaemon + + daemon = WatchdogDaemon( + config=WatchdogConfig(merge_approval_backstop_max_renudges=1), + rest_client=_mock_rest(), + agent_id="agent-wd", + conductor_id="agent-cond", + # state_store intentionally absent + ) + result = await daemon._maybe_backstop_renudge( + _sub_row(), APPROVED_SHA, datetime.now(UTC), + ) + assert result is False + + +# ── _check_one_subtask integration ──────────────────────────────────────────── + + +class TestCheckOneSubtaskIntegration: + @pytest.mark.asyncio + async def test_grant_present_no_stall_counter_advance( + self, store: StateStore, monkeypatch, + ) -> None: + """merge_pending + matching HEAD + grant within window → early-return, + patrol_visits_without_progress NOT incremented.""" + _set_merge_pending(store, approved_sha=APPROVED_SHA) + _insert_audit_row( + store, "approval_grant", ts=_ts(1), # within 4-min window + payload={"approved_sha": APPROVED_SHA}, + ) + + rest = _mock_rest() + config = WatchdogConfig( + max_phase_visits=2, + git_progress_check=True, + merge_approval_backstop_seconds=240, + merge_approval_backstop_max_renudges=1, + ) + daemon = _daemon(store, rest=rest, config=config) + monkeypatch.setattr( + "subprocess.run", + lambda cmd, **kw: type("R", (), {"returncode": 0, "stdout": APPROVED_SHA + "\n"})(), + ) + + now = datetime.now(UTC) + # Drive multiple patrols — stall counter must stay 0 + for _ in range(4): + await daemon._check_subtask_progress(now) + + key = (TASK_ID, SUBTASK_ID) + health = daemon._subtask_state.get(key) + # stall counter should be 0 (rung owned every patrol via early-return) + assert health is None or health.patrol_visits_without_progress == 0 + # subtask must NOT have been escalated to blocked + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.state == "merge_pending" + # no blocked escalation message sent + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_falls_through_after_cap( + self, store: StateStore, monkeypatch, + ) -> None: + """After renudge cap exhausted → falls through to stall counter → blocked.""" + _set_merge_pending(store, approved_sha=APPROVED_SHA) + _insert_audit_row( + store, "approval_grant", ts=_ts(20), + payload={"approved_sha": APPROVED_SHA}, + ) + # Pre-exhaust the cap (1 nudge) + _insert_audit_row( + store, "merge_backstop_nudge", ts=_ts(10), + payload={"pr_number": 42, "approved_sha": APPROVED_SHA}, + ) + + rest = _mock_rest() + config = WatchdogConfig( + max_phase_visits=2, + git_progress_check=True, + merge_approval_backstop_seconds=240, + merge_approval_backstop_max_renudges=1, + ) + daemon = _daemon(store, rest=rest, config=config) + monkeypatch.setattr( + "subprocess.run", + lambda cmd, **kw: type("R", (), {"returncode": 0, "stdout": APPROVED_SHA + "\n"})(), + ) + + now = datetime.now(UTC) + # With cap=1 exhausted the backstop returns False; the stall counter + # advances. Drive enough patrols to hit the cap (max_phase_visits=2) + # and trigger a blocked escalation. patrol 1 establishes baseline + # (counts as progress), patrols 2-3 are stale → cap crossed. + for _ in range(5): + await daemon._check_subtask_progress(now) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.state == "blocked" + + +# ── helper ──────────────────────────────────────────────────────────────────── + + +def _sub_row( + *, + approved_sha: str = APPROVED_SHA, + pr_number: int = 42, +) -> MagicMock: + sub = MagicMock() + sub.task_id = TASK_ID + sub.subtask_id = SUBTASK_ID + sub.state = "merge_pending" + sub.merge_approved_sha = approved_sha + sub.pr_number = pr_number + sub.branch = "feature-backstop" + return sub diff --git a/tests/test_watchdog_done_callback.py b/tests/test_watchdog_done_callback.py new file mode 100644 index 0000000..32c315a --- /dev/null +++ b/tests/test_watchdog_done_callback.py @@ -0,0 +1,95 @@ +"""Tests for the watchdog task done-callback (T-16). + +Verifies that an unexpected watchdog crash logs loudly, while normal +cancellation (shutdown) is silent. +""" + +from __future__ import annotations + +import asyncio +import logging + + +from codeband.orchestration.runner import _make_watchdog_done_callback + + +class _FakeActivity: + def __init__(self): + self.events: list[tuple[str, str, str]] = [] + + def log(self, event_type: str, actor: str, summary: str) -> None: + self.events.append((event_type, actor, summary)) + + +async def test_exception_logs_error_and_records_activity(caplog): + """An unexpected exception in the watchdog task logs at ERROR level.""" + activity = _FakeActivity() + callback = _make_watchdog_done_callback(activity) + + async def _raise(): + raise RuntimeError("sentinel boom") + + t = asyncio.get_event_loop().create_task(_raise()) + await asyncio.sleep(0) + + with caplog.at_level(logging.ERROR, logger="codeband.orchestration.runner"): + callback(t) + + errors = [ + r for r in caplog.records + if r.name == "codeband.orchestration.runner" and r.levelno == logging.ERROR + ] + assert errors, "expected at least one ERROR log" + assert "watchdog" in errors[0].getMessage().lower() + assert "RuntimeError" in errors[0].getMessage() + assert "sentinel boom" in errors[0].getMessage() + + crash_events = [e for e in activity.events if e[0] == "WATCHDOG_CRASH"] + assert crash_events, "expected a WATCHDOG_CRASH activity event" + assert "RuntimeError" in crash_events[0][2] + + +async def test_cancellation_is_silent(caplog): + """Normal cancellation (shutdown path) must not produce any log output.""" + activity = _FakeActivity() + callback = _make_watchdog_done_callback(activity) + + async def _sleep_forever(): + await asyncio.sleep(3600) + + t = asyncio.get_event_loop().create_task(_sleep_forever()) + t.cancel() + try: + await t + except asyncio.CancelledError: + pass + + with caplog.at_level(logging.DEBUG, logger="codeband.orchestration.runner"): + callback(t) + + assert not [ + r for r in caplog.records + if r.name.startswith("codeband") and r.levelno >= logging.WARNING + ], "cancellation should not produce any warning/error logs" + assert not activity.events, "cancellation should not record any activity events" + + +async def test_normal_exit_is_silent(caplog): + """A watchdog that exits cleanly (no exception, not cancelled) is also silent.""" + activity = _FakeActivity() + callback = _make_watchdog_done_callback(activity) + + async def _ok(): + return 42 + + t = asyncio.get_event_loop().create_task(_ok()) + await asyncio.sleep(0) + + with caplog.at_level(logging.DEBUG, logger="codeband.orchestration.runner"): + callback(t) + + assert not [ + r for r in caplog.records + if r.name.startswith("codeband") and r.levelno >= logging.ERROR + ] + assert not activity.events diff --git a/tests/test_watchdog_stall_discriminator.py b/tests/test_watchdog_stall_discriminator.py new file mode 100644 index 0000000..03adcdd --- /dev/null +++ b/tests/test_watchdog_stall_discriminator.py @@ -0,0 +1,277 @@ +"""Tests for the stall->blocked mailbox discriminator (PR-2 of 2). + +The watchdog stall rung counts an absent transition + an absent git-HEAD change +across N patrols as a substantive stall. A transport pin on the FSM-expected +actor presents the same symptoms, so without the discriminator a pinned agent +gets marked ``blocked`` — poisoning recovery because ``blocked`` has no auto- +resume edge. These tests cover the discriminator and the active-notify +upgrade for unhealable-pin escalation. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.config import WatchdogConfig + +SUBTASK_ID = "sub-1" +TASK_ID = "task-1" +ROOM_ID = "room-1" + + +# ── seeding helpers ───────────────────────────────────────────────────────── + +def _seed_store(tmp_path, *, state: str = "in_progress"): + """Store with one subtask in ``state`` — no PR, no branch (so the stall + rung's mechanical probes don't fire git/gh subprocess.run during tests).""" + from codeband.state import StateStore + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id="owner-1") + store.ensure_subtask(SUBTASK_ID, TASK_ID, state=state) + return store + + +def _mock_rest() -> MagicMock: + rest = MagicMock() + rest.agent_api_messages = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + return rest + + +def _make_message(message_id: str, inserted_at: datetime) -> MagicMock: + msg = MagicMock() + msg.id = message_id + msg.inserted_at = inserted_at + return msg + + +def _make_messages_response(messages: list) -> MagicMock: + resp = MagicMock() + resp.data = messages + return resp + + +def _agent_rest_with( + *, + processing: list | None = None, + pending: list | None = None, + raise_on_pending: Exception | None = None, + raise_on_processing: Exception | None = None, +) -> MagicMock: + """Build a per-agent REST client whose list_agent_messages returns the + given processing / pending payloads (or raises). Mirrors the heal-rung + helper in test_watchdog_transport_heal.py but trimmed to what the + discriminator probe needs (no verify re-list).""" + client = MagicMock() + client.agent_api_messages = MagicMock() + + async def _list_side_effect(*args: object, **kwargs: object) -> MagicMock: + status = kwargs.get("status", "") + if status == "processing": + if raise_on_processing is not None: + raise raise_on_processing + return _make_messages_response(processing or []) + if status == "pending": + if raise_on_pending is not None: + raise raise_on_pending + return _make_messages_response(pending or []) + return _make_messages_response([]) + + client.agent_api_messages.list_agent_messages = AsyncMock( + side_effect=_list_side_effect, + ) + return client + + +def _daemon( + store, + rest, + *, + role_map: dict[str, str], + agent_rest_clients: dict[str, MagicMock], + owner_id: str | None = "owner-1", + owner_handle: str | None = "Owner", + config: WatchdogConfig | None = None, +): + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=config or WatchdogConfig(transport_pin_threshold_seconds=600), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + owner_id=owner_id, + owner_handle=owner_handle, + agent_id_to_role=role_map, + agent_rest_clients=agent_rest_clients, + ) + + +# ── discriminator ─────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_discriminator_defers_block_when_expected_actor_is_pinned(tmp_path): + """Subtask state=in_progress (expected actor: coder); the coder's + pending head in the room is older than threshold → block DEFERRED: + no FSM transition, no escalation post.""" + store = _seed_store(tmp_path, state="in_progress") + now = datetime.now(UTC) + pinned_head = _make_message("msg-pinned", now - timedelta(seconds=900)) # > T + coder_client = _agent_rest_with(pending=[pinned_head]) + + rest = _mock_rest() + daemon = _daemon( + store, rest, + role_map={"coder-1": "coder"}, + agent_rest_clients={"coder-1": coder_client}, + ) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + result = await daemon._send_blocked_escalation(sub) + + assert result is False + # No FSM transition — subtask still in_progress. + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "in_progress" + # No escalation post on the watchdog's REST client. + rest.agent_api_messages.create_agent_chat_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_discriminator_proceeds_when_expected_actor_mailbox_is_clear(tmp_path): + """Subtask state=in_progress, coder's mailbox empty → block PROCEEDS as + today: FSM transition to blocked and the alert posts.""" + store = _seed_store(tmp_path, state="in_progress") + coder_client = _agent_rest_with(pending=[], processing=[]) + + rest = _mock_rest() + daemon = _daemon( + store, rest, + role_map={"coder-1": "coder"}, + agent_rest_clients={"coder-1": coder_client}, + ) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + result = await daemon._send_blocked_escalation(sub) + + assert result is True + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "blocked" + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discriminator_defers_on_probe_exception(tmp_path): + """Probe raises (e.g. 429 storm) → fail toward defer: block DEFERRED, no + FSM transition, no escalation post.""" + from thenvoi_rest.core.api_error import ApiError + + store = _seed_store(tmp_path, state="review_pending") + # Reviewer is the expected actor for review_pending. Make its probe blow up. + reviewer_client = _agent_rest_with( + raise_on_processing=ApiError(status_code=429, headers={}, body="rate limited"), + ) + + rest = _mock_rest() + daemon = _daemon( + store, rest, + role_map={"reviewer-1": "reviewer"}, + agent_rest_clients={"reviewer-1": reviewer_client}, + ) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + result = await daemon._send_blocked_escalation(sub) + + assert result is False + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "review_pending" + rest.agent_api_messages.create_agent_chat_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_discriminator_review_passed_defers_when_mergemaster_pinned(tmp_path): + """review_passed expects {verifier, mergemaster} — the v1 approximation + checks BOTH and defers if either is pinned. Verifier clear, mergemaster + pinned → DEFERRED (documents the over-defer behavior; over-defer is a + bounded delay, never a wrong block).""" + store = _seed_store(tmp_path, state="review_passed") + now = datetime.now(UTC) + pinned_head = _make_message("msg-mm-pinned", now - timedelta(seconds=900)) + verifier_client = _agent_rest_with(pending=[], processing=[]) + mm_client = _agent_rest_with(pending=[pinned_head]) + + rest = _mock_rest() + daemon = _daemon( + store, rest, + role_map={ + "verifier-1": "verifier", + "mergemaster-1": "mergemaster", + }, + agent_rest_clients={ + "verifier-1": verifier_client, + "mergemaster-1": mm_client, + }, + ) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + result = await daemon._send_blocked_escalation(sub) + + assert result is False + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "review_passed" + rest.agent_api_messages.create_agent_chat_message.assert_not_called() + + +# ── unhealable-pin escalation: active owner notify ────────────────────────── + +@pytest.mark.asyncio +async def test_unhealable_pin_escalation_mentions_owner(): + """_escalate_unhealable_pin includes the owner in the structured mention + list and the @handle prefix in the message text (active notify), so the + human is woken instead of relying on them seeing a passive post.""" + from codeband.agents.watchdog import WatchdogDaemon + + rest = _mock_rest() + daemon = WatchdogDaemon( + config=WatchdogConfig(transport_pin_threshold_seconds=600), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + owner_id="owner-1", + owner_handle="Owner", + ) + + await daemon._escalate_unhealable_pin( + agent_id="coder-1", room_id=ROOM_ID, message_id="msg-stubborn", attempts=3, + ) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert [m.id for m in msg.mentions] == ["owner-1"] + assert "@Owner" in msg.content + + +@pytest.mark.asyncio +async def test_unhealable_pin_escalation_degrades_without_owner(): + """When no owner_id is configured the escalation degrades to mention-less + (preserves the pre-upgrade behavior so no None id reaches the server).""" + from codeband.agents.watchdog import WatchdogDaemon + + rest = _mock_rest() + daemon = WatchdogDaemon( + config=WatchdogConfig(transport_pin_threshold_seconds=600), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + owner_id=None, + owner_handle=None, + ) + + await daemon._escalate_unhealable_pin( + agent_id="coder-1", room_id=ROOM_ID, message_id="msg-stubborn", attempts=3, + ) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert msg.mentions == [] diff --git a/tests/test_watchdog_transport_heal.py b/tests/test_watchdog_transport_heal.py new file mode 100644 index 0000000..bc7080e --- /dev/null +++ b/tests/test_watchdog_transport_heal.py @@ -0,0 +1,586 @@ +"""Tests for the watchdog's transport-health (turn-boundary 422 pin) heal rung.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.config import WatchdogConfig + + +def _make_chat_room(room_id: str) -> MagicMock: + room = MagicMock() + room.id = room_id + return room + + +def _make_chats_response(rooms: list) -> MagicMock: + resp = MagicMock() + resp.data = rooms + return resp + + +def _make_message(message_id: str, inserted_at: datetime) -> MagicMock: + msg = MagicMock() + msg.id = message_id + msg.sender_id = "someone-else" + msg.inserted_at = inserted_at + return msg + + +def _make_messages_response(messages: list) -> MagicMock: + resp = MagicMock() + resp.data = messages + return resp + + +def _make_participant(pid: str, name: str | None = None) -> MagicMock: + p = MagicMock() + p.id = pid + p.name = name or pid + p.type = "Agent" + return p + + +def _make_participants_response(items: list) -> MagicMock: + resp = MagicMock() + resp.data = [_make_participant(*i) if isinstance(i, tuple) else _make_participant(i) + for i in items] + return resp + + +def _make_conductor_rest_client() -> MagicMock: + """REST client for the Watchdog's borrowed Conductor credentials.""" + client = MagicMock() + client.agent_api_chats = MagicMock() + client.agent_api_chats.list_agent_chats = AsyncMock( + return_value=_make_chats_response([_make_chat_room("room-1")]), + ) + client.agent_api_messages = MagicMock() + client.agent_api_messages.list_agent_messages = AsyncMock( + return_value=_make_messages_response([]), + ) + client.agent_api_messages.create_agent_chat_message = AsyncMock() + client.agent_api_participants = MagicMock() + client.agent_api_participants.list_agent_chat_participants = AsyncMock( + return_value=_make_participants_response( + ["agent-cond", "agent-coder"], + ), + ) + return client + + +def _make_agent_rest_client( + processing_messages: list, + pending_messages: list | None = None, + verify_pending_messages: list | None = None, +) -> MagicMock: + """REST client for one agent's own deliveries (used by the heal rung). + + processing_messages: returned for status="processing" probes. + pending_messages: returned for the first status="pending" probe (detection). + verify_pending_messages: returned for subsequent pending probes (verify re-list). + Defaults to empty lists so existing tests that only pass processing_messages work. + """ + client = MagicMock() + client.agent_api_messages = MagicMock() + + _pending = pending_messages or [] + _verify = verify_pending_messages if verify_pending_messages is not None else [] + _pending_call_count: dict[str, int] = {"n": 0} + + async def _list_side_effect(*args: object, **kwargs: object) -> MagicMock: + status = kwargs.get("status", "") + if status == "processing": + return _make_messages_response(processing_messages) + if status == "pending": + _pending_call_count["n"] += 1 + if _pending_call_count["n"] == 1: + return _make_messages_response(_pending) + return _make_messages_response(_verify) + return _make_messages_response([]) + + client.agent_api_messages.list_agent_messages = AsyncMock( + side_effect=_list_side_effect, + ) + client.agent_api_messages.mark_agent_message_processed = AsyncMock() + client.agent_api_messages.mark_agent_message_processing = AsyncMock() + return client + + +@pytest.fixture +def watchdog_config() -> WatchdogConfig: + return WatchdogConfig( + # T is the pin threshold; default would be 1800s but tests use a + # shorter horizon so the timestamps below stay readable. + transport_pin_threshold_seconds=600, + transport_heal_max_attempts=3, + ) + + +def _make_daemon( + watchdog_config: WatchdogConfig, + conductor_rest: MagicMock, + agent_rest_clients: dict[str, MagicMock], +): + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=watchdog_config, + rest_client=conductor_rest, + agent_id="agent-cond", + conductor_id="agent-cond", + agent_rest_clients=agent_rest_clients, + ) + + +@pytest.mark.asyncio +async def test_stuck_processing_past_threshold_is_healed(watchdog_config): + """A delivery stuck in processing past T → mark_processed called once.""" + now = datetime.now(UTC) + stuck = _make_message("msg-pinned", now - timedelta(seconds=900)) # > T (600) + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client([stuck]) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + agent_client.agent_api_messages.list_agent_messages.assert_any_await( + chat_id="room-1", status="processing", page_size=100, + ) + agent_client.agent_api_messages.mark_agent_message_processed.assert_awaited_once_with( + chat_id="room-1", id="msg-pinned", + ) + # Successful heal clears tracking. + assert ("agent-coder", "msg-pinned") not in daemon._pin_heal_attempts + assert ("agent-coder", "msg-pinned") not in daemon._pin_escalated + + +@pytest.mark.asyncio +async def test_fresh_processing_below_threshold_is_left_alone(watchdog_config): + """A processing delivery younger than T → no heal (mid-turn safety).""" + now = datetime.now(UTC) + fresh = _make_message("msg-fresh", now - timedelta(seconds=60)) # < T + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client([fresh]) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + agent_client.agent_api_messages.mark_agent_message_processed.assert_not_called() + + +@pytest.mark.asyncio +async def test_idempotent_already_processed_returns_422_treated_as_noop( + watchdog_config, +): + """Re-asserting processed on an already-processed delivery returns 422 + ("no active processing attempt") — treated as a successful no-op.""" + from thenvoi_rest.core.api_error import ApiError + + now = datetime.now(UTC) + stuck = _make_message("msg-already-done", now - timedelta(seconds=900)) + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client([stuck]) + agent_client.agent_api_messages.mark_agent_message_processed = AsyncMock( + side_effect=ApiError( + status_code=422, headers={}, + body="no active processing attempt", + ), + ) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + # The 422 is swallowed — no tracking added, no escalation. + pin_key = ("agent-coder", "msg-already-done") + assert pin_key not in daemon._pin_heal_attempts + assert pin_key not in daemon._pin_escalated + # And no owner-escalation post was made on the watchdog's own client. + conductor_rest.agent_api_messages.create_agent_chat_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_max_attempts_escalates_and_stops_healing(watchdog_config): + """After N non-422 failures, escalate once and stop healing this pin.""" + from thenvoi_rest.core.api_error import ApiError + + now = datetime.now(UTC) + stuck = _make_message("msg-bad-server", now - timedelta(seconds=900)) + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client([stuck]) + agent_client.agent_api_messages.mark_agent_message_processed = AsyncMock( + side_effect=ApiError(status_code=500, headers={}, body="server error"), + ) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + # Three failing patrols → escalation on the 3rd. + for _ in range(3): + await daemon._patrol() + + pin_key = ("agent-coder", "msg-bad-server") + assert pin_key in daemon._pin_escalated + assert daemon._pin_heal_attempts[pin_key] == 3 + # Exactly 3 heal attempts. + assert agent_client.agent_api_messages.mark_agent_message_processed.await_count == 3 + # Owner-style escalation post on the watchdog's own (Conductor) client. + posts = conductor_rest.agent_api_messages.create_agent_chat_message + assert posts.await_count == 1 + + # Fourth patrol: no further heal calls, no further escalations. + await daemon._patrol() + assert agent_client.agent_api_messages.mark_agent_message_processed.await_count == 3 + assert posts.await_count == 1 + + +@pytest.mark.asyncio +async def test_kill_switch_disables_rung_entirely(watchdog_config): + """transport_heal_enabled=False → no list-processing or mark-processed calls.""" + watchdog_config = WatchdogConfig( + transport_heal_enabled=False, + transport_pin_threshold_seconds=600, + ) + now = datetime.now(UTC) + stuck = _make_message("msg-pinned", now - timedelta(seconds=900)) + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client([stuck]) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + agent_client.agent_api_messages.list_agent_messages.assert_not_called() + agent_client.agent_api_messages.mark_agent_message_processed.assert_not_called() + + +@pytest.mark.asyncio +async def test_existing_nudge_path_unaffected_when_no_pins(watchdog_config): + """When no transport pins exist, the chat-recency nudge path runs normally. + + A stale-by-chat agent still gets nudged via the existing path; the new + rung adds no calls and does not suppress the legacy behavior. + """ + now = datetime.now(UTC) + old_msg = MagicMock() + old_msg.sender_id = "agent-coder" + old_msg.inserted_at = now - timedelta(minutes=20) # past stale threshold + old_msg.content = "" + + conductor_rest = _make_conductor_rest_client() + conductor_rest.agent_api_messages.list_agent_messages = AsyncMock( + return_value=_make_messages_response([old_msg]), + ) + # No processing messages — pin rung finds nothing. + agent_client = _make_agent_rest_client([]) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + # Pin rung made the read but no heal. + agent_client.agent_api_messages.list_agent_messages.assert_awaited() + agent_client.agent_api_messages.mark_agent_message_processed.assert_not_called() + # Nudge was sent through the regular path (one create_agent_chat_message + # on the watchdog's own client). + nudges = conductor_rest.agent_api_messages.create_agent_chat_message + assert nudges.await_count == 1 + + +@pytest.mark.asyncio +async def test_conductor_own_pinned_cursor_is_healed(watchdog_config): + """The watchdog's own (Conductor) credential entry in agent_rest_clients is + now swept by the heal loop — the Conductor is the most common 422-pin victim + and must not be excluded from the rung that heals pins.""" + now = datetime.now(UTC) + stuck = _make_message("msg-pinned", now - timedelta(seconds=900)) # > T (600) + conductor_rest = _make_conductor_rest_client() + # Entry under the conductor's own id — must be healed, not skipped. + own_client = _make_agent_rest_client([stuck]) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-cond": own_client}, + ) + + await daemon._patrol() + + own_client.agent_api_messages.list_agent_messages.assert_any_await( + chat_id="room-1", status="processing", page_size=100, + ) + own_client.agent_api_messages.mark_agent_message_processed.assert_awaited_once_with( + chat_id="room-1", id="msg-pinned", + ) + + +@pytest.mark.asyncio +async def test_conductor_own_fresh_delivery_not_healed(watchdog_config): + """The Conductor's own delivery younger than T is left alone — the threshold + guard (not the removed agent-id skip) protects live turns.""" + now = datetime.now(UTC) + fresh = _make_message("msg-fresh", now - timedelta(seconds=60)) # < T (600) + conductor_rest = _make_conductor_rest_client() + own_client = _make_agent_rest_client([fresh]) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-cond": own_client}, + ) + + await daemon._patrol() + + own_client.agent_api_messages.mark_agent_message_processed.assert_not_called() + own_client.agent_api_messages.mark_agent_message_processing.assert_not_called() + + +@pytest.mark.asyncio +async def test_empty_clients_map_is_noop(watchdog_config): + """No per-agent clients → rung is a no-op (degrades gracefully).""" + conductor_rest = _make_conductor_rest_client() + daemon = _make_daemon(watchdog_config, conductor_rest, {}) + + await daemon._patrol() + + # No external probes triggered by the new rung; only the patrol's own + # listing calls fire. + conductor_rest.agent_api_messages.create_agent_chat_message.assert_not_called() + + +# --------------------------------------------------------------------------- +# Pending-bucket (post-turn 422 class) tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pending_head_older_than_threshold_is_healed(watchdog_config): + """Pending head older than T → 2-step heal + verify → AGENT_PIN_HEALED, tracking cleared.""" + now = datetime.now(UTC) + head = _make_message("msg-pending-head", now - timedelta(seconds=900)) # > T (600) + conductor_rest = _make_conductor_rest_client() + # First pending probe returns [head]; verify re-list returns [] (cursor advanced). + agent_client = _make_agent_rest_client( + processing_messages=[], + pending_messages=[head], + verify_pending_messages=[], + ) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + # Both 2-step calls must fire, in order. + processing_call = agent_client.agent_api_messages.mark_agent_message_processing + processed_call = agent_client.agent_api_messages.mark_agent_message_processed + processing_call.assert_awaited_once_with(chat_id="room-1", id="msg-pending-head") + processed_call.assert_awaited_once_with(chat_id="room-1", id="msg-pending-head") + + # Successful verify → tracking cleared. + pin_key = ("agent-coder", "msg-pending-head") + assert pin_key not in daemon._pin_heal_attempts + assert pin_key not in daemon._pin_escalated + + +@pytest.mark.asyncio +async def test_pending_heal_verify_same_head_increments_and_escalates(watchdog_config): + """When verify re-list still shows the same head, increment attempts; escalate at max.""" + now = datetime.now(UTC) + head = _make_message("msg-stubborn", now - timedelta(seconds=900)) + conductor_rest = _make_conductor_rest_client() + # Both detection and verify always return [head] — heal never advances cursor. + agent_client = _make_agent_rest_client( + processing_messages=[], + pending_messages=[head], + verify_pending_messages=[head], + ) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + pin_key = ("agent-coder", "msg-stubborn") + posts = conductor_rest.agent_api_messages.create_agent_chat_message + + # Two failing patrols — no escalation yet. + for _ in range(2): + await daemon._patrol() + + assert pin_key not in daemon._pin_escalated + assert daemon._pin_heal_attempts[pin_key] == 2 + # No AGENT_PIN_HEALED activity logged. + posts.assert_not_called() + + # Third patrol — hits max_attempts (3). + await daemon._patrol() + + assert pin_key in daemon._pin_escalated + assert daemon._pin_heal_attempts[pin_key] == 3 + # Exactly one escalation post. + assert posts.await_count == 1 + + # Fourth patrol — heal skipped entirely (pin already in _pin_escalated). + await daemon._patrol() + assert posts.await_count == 1 + # mark_agent_message_processing was called 3 times (once per healing patrol). + assert agent_client.agent_api_messages.mark_agent_message_processing.await_count == 3 + + +@pytest.mark.asyncio +async def test_processing_head_is_one_step_unchanged(watchdog_config): + """Processing head older than T → 1-step processed only; mark_agent_message_processing + must NOT be called — processing-bucket behavior is byte-for-byte unchanged.""" + now = datetime.now(UTC) + stuck = _make_message("msg-crash-recovery", now - timedelta(seconds=900)) + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client(processing_messages=[stuck]) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + agent_client.agent_api_messages.mark_agent_message_processed.assert_awaited_once_with( + chat_id="room-1", id="msg-crash-recovery", + ) + agent_client.agent_api_messages.mark_agent_message_processing.assert_not_called() + + +@pytest.mark.asyncio +async def test_pending_head_younger_than_threshold_no_heal(watchdog_config): + """Pending head younger than T → no heal calls (mid-turn safety).""" + now = datetime.now(UTC) + fresh = _make_message("msg-fresh-pending", now - timedelta(seconds=60)) # < T + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client( + processing_messages=[], + pending_messages=[fresh], + ) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + agent_client.agent_api_messages.mark_agent_message_processing.assert_not_called() + agent_client.agent_api_messages.mark_agent_message_processed.assert_not_called() + + +@pytest.mark.asyncio +async def test_only_pending_head_is_healed_not_non_head_entries(watchdog_config): + """Three pending records → only data[0] (head) healed; non-head get no /processing call.""" + now = datetime.now(UTC) + head = _make_message("msg-head", now - timedelta(seconds=900)) + msg2 = _make_message("msg-second", now - timedelta(seconds=800)) + msg3 = _make_message("msg-third", now - timedelta(seconds=700)) + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client( + processing_messages=[], + pending_messages=[head, msg2, msg3], + verify_pending_messages=[], + ) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + # /processing called exactly once, only for the head. + processing_calls = agent_client.agent_api_messages.mark_agent_message_processing + processing_calls.assert_awaited_once_with(chat_id="room-1", id="msg-head") + + # /processed also called once for the head. + agent_client.agent_api_messages.mark_agent_message_processed.assert_awaited_once_with( + chat_id="room-1", id="msg-head", + ) + + # Non-head ids never appear in any call. + all_processing_calls = [str(c) for c in processing_calls.await_args_list] + assert not any("msg-second" in c or "msg-third" in c for c in all_processing_calls) + + +@pytest.mark.asyncio +async def test_pending_probe_api_error_no_crash_patrol_continues(watchdog_config): + """Pending probe raises ApiError → no crash, no heal; patrol completes normally.""" + from thenvoi_rest.core.api_error import ApiError + + conductor_rest = _make_conductor_rest_client() + agent_client = MagicMock() + agent_client.agent_api_messages = MagicMock() + + async def _list_side_effect(*args: object, **kwargs: object) -> MagicMock: + status = kwargs.get("status", "") + if status == "processing": + return _make_messages_response([]) + raise ApiError(status_code=429, headers={}, body="rate limited") + + agent_client.agent_api_messages.list_agent_messages = AsyncMock( + side_effect=_list_side_effect, + ) + agent_client.agent_api_messages.mark_agent_message_processed = AsyncMock() + agent_client.agent_api_messages.mark_agent_message_processing = AsyncMock() + + daemon = _make_daemon(watchdog_config, conductor_rest, {"agent-coder": agent_client}) + + # Must not raise. + await daemon._patrol() + + agent_client.agent_api_messages.mark_agent_message_processing.assert_not_called() + agent_client.agent_api_messages.mark_agent_message_processed.assert_not_called() + + +@pytest.mark.asyncio +async def test_pending_probe_generic_exception_no_crash(watchdog_config): + """Pending probe raises a generic Exception → no crash, no heal.""" + conductor_rest = _make_conductor_rest_client() + agent_client = MagicMock() + agent_client.agent_api_messages = MagicMock() + + async def _list_side_effect(*args: object, **kwargs: object) -> MagicMock: + status = kwargs.get("status", "") + if status == "processing": + return _make_messages_response([]) + raise RuntimeError("network gone") + + agent_client.agent_api_messages.list_agent_messages = AsyncMock( + side_effect=_list_side_effect, + ) + agent_client.agent_api_messages.mark_agent_message_processed = AsyncMock() + agent_client.agent_api_messages.mark_agent_message_processing = AsyncMock() + + daemon = _make_daemon(watchdog_config, conductor_rest, {"agent-coder": agent_client}) + + await daemon._patrol() + + agent_client.agent_api_messages.mark_agent_message_processing.assert_not_called() + agent_client.agent_api_messages.mark_agent_message_processed.assert_not_called() + + +@pytest.mark.asyncio +async def test_kill_switch_disables_both_probes(watchdog_config): + """transport_heal_enabled=False → neither pending nor processing probe runs.""" + watchdog_config = WatchdogConfig( + transport_heal_enabled=False, + transport_pin_threshold_seconds=600, + ) + now = datetime.now(UTC) + head = _make_message("msg-pending-head", now - timedelta(seconds=900)) + conductor_rest = _make_conductor_rest_client() + agent_client = _make_agent_rest_client( + processing_messages=[head], + pending_messages=[head], + ) + daemon = _make_daemon( + watchdog_config, conductor_rest, {"agent-coder": agent_client}, + ) + + await daemon._patrol() + + agent_client.agent_api_messages.list_agent_messages.assert_not_called() + agent_client.agent_api_messages.mark_agent_message_processing.assert_not_called() + agent_client.agent_api_messages.mark_agent_message_processed.assert_not_called() diff --git a/tests/test_watchdog_upgrade.py b/tests/test_watchdog_upgrade.py new file mode 100644 index 0000000..25e7783 --- /dev/null +++ b/tests/test_watchdog_upgrade.py @@ -0,0 +1,2060 @@ +"""Tests for the watchdog mechanical-progress upgrade (RFC WS4 / Phase 3). + +These exercise the new git-HEAD / PR-state / transition-log progress signals +and the per-subtask cycle cap. They seed a real ``StateStore`` directly (no +dependency on the Workstream-2 FSM) and mock ``subprocess.run`` for the git / +gh shell-outs. +""" + +from __future__ import annotations + +import json +import sqlite3 +import subprocess +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from codeband.config import WatchdogConfig + +SUBTASK_ID = "sub-1" +TASK_ID = "task-1" +ROOM_ID = "room-1" +BASELINE_PR_TS = "2026-05-31T00:00:00+00:00" + + +# ── seeding helpers ───────────────────────────────────────────────────────── + +def _write_room_pointer(store, room_id: str = ROOM_ID) -> None: + (store.db_path.parent / ".codeband_room").write_text(room_id, encoding="utf-8") + + +def _seed_store( + tmp_path, + *, + state: str = "in_progress", + branch: str | None = "feature-x", + pr_number: int | None = 42, +): + """Create a real StateStore with one in-flight subtask row.""" + from codeband.state import StateStore + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID) + _write_room_pointer(store) + metadata = {"branch": branch} if branch is not None else None + store.ensure_subtask(SUBTASK_ID, TASK_ID, state=state, metadata=metadata) + if pr_number is not None: + conn = sqlite3.connect(store.db_path) + conn.execute( + "UPDATE subtask_states SET pr_number = ? WHERE subtask_id = ?", + (pr_number, SUBTASK_ID), + ) + conn.commit() + conn.close() + return store + + +def _insert_transition(store, *, timestamp: str) -> None: + """Append a transition_log row so MAX(timestamp) advances.""" + conn = sqlite3.connect(store.db_path) + conn.execute( + "INSERT INTO transition_log " + "(subtask_id, task_id, from_state, to_state, caller_role, timestamp) " + "VALUES (?, ?, ?, ?, ?, ?)", + (SUBTASK_ID, TASK_ID, "planned", "in_progress", "conductor", timestamp), + ) + conn.commit() + conn.close() + + +def _make_run(signals: dict): + """Return a fake ``subprocess.run`` driven by a mutable signals dict. + + ``signals['head']`` is the git rev-parse output; ``signals['pr_updated']`` + is the PR ``updatedAt``. Mutate the dict between patrols to simulate + progress. + """ + def _run(cmd, *args, **kwargs): + if cmd[0] == "git": + return subprocess.CompletedProcess(cmd, 0, stdout=signals["head"], stderr="") + if cmd[0] == "gh": + payload = json.dumps( + {"state": "OPEN", "updatedAt": signals["pr_updated"]}, + ) + return subprocess.CompletedProcess(cmd, 0, stdout=payload, stderr="") + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") + + return _run + + +def _mock_rest(): + rest = MagicMock() + rest.agent_api_messages = MagicMock() + rest.agent_api_messages.create_agent_chat_message = AsyncMock() + return rest + + +def _daemon(store, *, config: WatchdogConfig, rest=None, activity=None): + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=config, + rest_client=rest if rest is not None else _mock_rest(), + agent_id="agent-wd", + conductor_id="agent-cond", + activity=activity, + state_store=store, + ) + + +# ── cycle-cap escalation ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_cycle_cap_marks_blocked_after_no_progress(tmp_path, monkeypatch): + """No git-HEAD change and no new transition across N patrols → blocked.""" + store = _seed_store(tmp_path) + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + rest = _mock_rest() + activity = MagicMock() + daemon = _daemon( + store, config=WatchdogConfig(max_phase_visits=3), rest=rest, activity=activity, + ) + + now = datetime.now(UTC) + # Patrol 1 establishes the baseline (counts as progress); patrols 2-4 are + # stale, so the cap (3) is crossed on the 4th patrol. + for _ in range(6): + await daemon._check_subtask_progress(now) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + events = [call.args[0] for call in activity.log.call_args_list] + assert "SUBTASK_BLOCKED" in events + + # The FSM applies the blocked transition, so the alert carries no deferral suffix + # and the subtask is durably blocked. + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert SUBTASK_ID in msg.content + assert "could not be applied" not in msg.content + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "blocked" + + +@pytest.mark.asyncio +async def test_merge_pending_subtask_is_patrolled(tmp_path, monkeypatch): + """A stale ``merge_pending`` subtask escalates like any patrolled state. + + Stage-2 chunk 2b adds ``merge_pending`` to the patrolled set: a subtask + resting in the merge queue with no mechanical progress (e.g. an approval + request nobody acted on) crosses the standard stall cap and is blocked + + escalated once. The watchdog performs no merge reconciliation — that is + ``cb-phase merge``'s job. + """ + store = _seed_store(tmp_path, state="merge_pending") + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + rest = _mock_rest() + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2), rest=rest) + now = datetime.now(UTC) + for _ in range(5): + await daemon._check_subtask_progress(now) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "blocked" + + +@pytest.mark.asyncio +async def test_git_head_change_resets_counter(tmp_path, monkeypatch): + """A git-HEAD change resets patrol_visits_without_progress to 0.""" + store = _seed_store(tmp_path) + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=10)) + now = datetime.now(UTC) + + await daemon._check_subtask_progress(now) # baseline + await daemon._check_subtask_progress(now) # stale → 1 + await daemon._check_subtask_progress(now) # stale → 2 + health = daemon._subtask_state[(TASK_ID, SUBTASK_ID)] + assert health.patrol_visits_without_progress == 2 + + signals["head"] = "def456" # progress + await daemon._check_subtask_progress(now) + assert health.patrol_visits_without_progress == 0 + + +@pytest.mark.asyncio +async def test_new_transition_resets_counter(tmp_path, monkeypatch): + """A newer transition_log entry counts as progress and resets the counter.""" + store = _seed_store(tmp_path) + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=10)) + now = datetime.now(UTC) + + await daemon._check_subtask_progress(now) # baseline + await daemon._check_subtask_progress(now) # stale → 1 + health = daemon._subtask_state[(TASK_ID, SUBTASK_ID)] + assert health.patrol_visits_without_progress == 1 + + _insert_transition(store, timestamp="2026-06-01T00:00:00+00:00") + await daemon._check_subtask_progress(now) + assert health.patrol_visits_without_progress == 0 + + +@pytest.mark.asyncio +async def test_fsm_transition_called_when_present(tmp_path, monkeypatch): + """The stall→blocked transition is applied by the REAL FSM, not a mock. + + No FSM mock here: this exercises the real caller-role authorization and the + ``store=`` plumbing end-to-end. If the ``(any non-terminal, watchdog) → + blocked`` edge or the ``store=`` argument is missing, the real transition + raises, the subtask stays ``in_progress``, and this test fails — exactly the + regression we want guarded. + """ + store = _seed_store(tmp_path) # subtask seeded 'in_progress' + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + rest = _mock_rest() + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2), rest=rest) + now = datetime.now(UTC) + for _ in range(3): + await daemon._check_subtask_progress(now) + + # Durable, real effect: the subtask is actually blocked and audit-logged. + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "blocked" + conn = sqlite3.connect(store.db_path) + conn.row_factory = sqlite3.Row + rows = conn.execute( + "SELECT to_state, caller_role FROM transition_log WHERE subtask_id = ?", + (SUBTASK_ID,), + ).fetchall() + conn.close() + assert any( + r["to_state"] == "blocked" and r["caller_role"] == "watchdog" for r in rows + ) + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert "could not be applied" not in msg.content + + +# ── graceful degradation ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_no_store_skips_progress(monkeypatch): + """With no store, the mechanical path is a no-op (no subprocess, no crash).""" + calls: list = [] + monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(1)) + + daemon = _daemon(None, config=WatchdogConfig()) + await daemon._check_subtask_progress(datetime.now(UTC)) + assert calls == [] + + +@pytest.mark.asyncio +async def test_git_progress_check_disabled(tmp_path, monkeypatch): + """git_progress_check=False disables the mechanical signals entirely.""" + store = _seed_store(tmp_path) + calls: list = [] + monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(1)) + + daemon = _daemon(store, config=WatchdogConfig(git_progress_check=False)) + await daemon._check_subtask_progress(datetime.now(UTC)) + assert calls == [] + + +@pytest.mark.asyncio +async def test_terminal_subtask_ignored(tmp_path, monkeypatch): + """Merged/planned subtasks are not tracked for mechanical progress.""" + store = _seed_store(tmp_path, state="planned") + calls: list = [] + monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(1)) + + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2)) + await daemon._check_subtask_progress(datetime.now(UTC)) + assert (TASK_ID, SUBTASK_ID) not in daemon._subtask_state + assert calls == [] + + +# ── existing chat-recency behavior preserved ──────────────────────────────── + +def _chats_resp(rooms): + resp = MagicMock() + resp.data = rooms + return resp + + +def _msg(sender_id, minutes_ago): + m = MagicMock() + m.sender_id = sender_id + m.inserted_at = datetime.now(UTC) - timedelta(minutes=minutes_ago) + m.content = "working" + return m + + +def _participant(pid, name): + p = MagicMock() + p.id = pid + p.name = name + p.type = "Agent" + return p + + +@pytest.mark.asyncio +async def test_patrol_still_nudges_stale_agent_with_store(tmp_path, monkeypatch): + """The chat-recency nudge path is unaffected by the new subtask check. + + Runs a full ``_patrol`` with a store present but no in-flight subtasks; a + stale agent must still get nudged exactly as before. + """ + store = _seed_store(tmp_path, state="merged") # terminal → no progress work + monkeypatch.setattr(subprocess, "run", lambda *a, **k: None) + + room = MagicMock() + room.id = ROOM_ID + + rest = _mock_rest() + rest.agent_api_chats = MagicMock() + rest.agent_api_chats.list_agent_chats = AsyncMock( + return_value=_chats_resp([room]), + ) + # 10 minutes: stale against the 300s threshold, comfortably inside the + # client-side read window (2 x max threshold = 30min) the agent-API path + # now applies after fetch. + rest.agent_api_messages.list_agent_messages = AsyncMock( + return_value=MagicMock(data=[_msg("agent-p0", minutes_ago=10)]), + ) + parts = MagicMock() + parts.data = [ + _participant("agent-cond", "Conductor"), + _participant("agent-p0", "Coder-Claude-0"), + ] + rest.agent_api_participants = MagicMock() + rest.agent_api_participants.list_agent_chat_participants = AsyncMock( + return_value=parts, + ) + + from codeband.agents.watchdog import WatchdogDaemon + + daemon = WatchdogDaemon( + config=WatchdogConfig(stale_threshold_seconds=300), + rest_client=rest, + agent_id="agent-cond", + conductor_id="agent-cond", + state_store=store, + ) + await daemon._patrol() + + rest.agent_api_messages.create_agent_chat_message.assert_awaited() + sent = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert "Status check" in sent.content + + +# ── owner escalation on blocked (RFC P5 stage-1b wiring; dormant by default) ── + +def _seed_blocked(tmp_path, *, reason="verify-attempt cap 20 reached", owner_id=None): + """A real store with one subtask driven to ``blocked`` via the FSM. + + Driving it through ``fsm.transition`` (not a bare ``ensure_subtask``) writes + a real ``→ blocked`` transition_log row carrying ``reason`` so the owner + escalation can surface it. ``owner_id`` is persisted on the task row so the + watchdog can resolve the initiator without a constructor override. + """ + from codeband.state import StateStore + from codeband.state.fsm import transition + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id=owner_id) + transition(SUBTASK_ID, TASK_ID, "assigned", caller_role="conductor", store=store) + transition(SUBTASK_ID, TASK_ID, "in_progress", caller_role="coder", store=store) + transition(SUBTASK_ID, TASK_ID, "blocked", caller_role="coder", + reason=reason, store=store) + return store + + +def _owner_daemon(store, rest, *, owner_id="owner-1", owner_handle="Owner", + activity=None): + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=WatchdogConfig(), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + activity=activity, + state_store=store, + owner_id=owner_id, + owner_handle=owner_handle, + ) + + +@pytest.mark.asyncio +async def test_blocked_subtask_escalates_to_owner_mention(tmp_path): + """A blocked subtask triggers a Band @mention to the owner carrying the + owner handle, the subtask id, and the durable blocked reason.""" + store = _seed_blocked(tmp_path, reason="verify-attempt cap 20 reached") + rest = _mock_rest() + activity = MagicMock() + daemon = _owner_daemon(store, rest, owner_id="owner-1", owner_handle="Owner", + activity=activity) + + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + call = rest.agent_api_messages.create_agent_chat_message.call_args + assert call.kwargs["chat_id"] == ROOM_ID + msg = call.kwargs["message"] + assert SUBTASK_ID in msg.content + assert "@Owner" in msg.content # owner handle in text + assert "verify-attempt cap 20 reached" in msg.content # the durable reason + assert [m.id for m in msg.mentions] == ["owner-1"] # structured mention + events = [c.args[0] for c in activity.log.call_args_list] + assert "SUBTASK_BLOCKED_OWNER_ESCALATION" in events + + +@pytest.mark.asyncio +async def test_owner_escalation_is_once_per_subtask(tmp_path): + """The owner is mentioned a single time even across repeated patrols.""" + store = _seed_blocked(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + + now = datetime.now(UTC) + await daemon._check_blocked_subtasks(now) + await daemon._check_blocked_subtasks(now) + await daemon._check_blocked_subtasks(now) + + assert rest.agent_api_messages.create_agent_chat_message.await_count == 1 + + +@pytest.mark.asyncio +async def test_owner_escalation_dormant_without_owner_id(tmp_path): + """With no owner_id (the pre-activation default), the path is a no-op.""" + store = _seed_blocked(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id=None, owner_handle=None) + + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_owner_handle_falls_back_to_id(tmp_path): + """When no display handle is supplied, the owner id is used in the text.""" + store = _seed_blocked(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id="owner-xyz", owner_handle=None) + + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert "@owner-xyz" in msg.content + assert [m.id for m in msg.mentions] == ["owner-xyz"] + + +@pytest.mark.asyncio +async def test_non_blocked_subtasks_are_not_escalated(tmp_path): + """Only ``blocked`` subtasks escalate to the owner; in-flight ones do not.""" + store = _seed_store(tmp_path, state="in_progress") # not blocked + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_owner_resolved_from_task_row_without_override(tmp_path): + """The initiator persisted on the task row drives the escalation even when + the watchdog carries no constructor owner override (the runner's default).""" + store = _seed_blocked(tmp_path, owner_id="initiator-7") + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id=None, owner_handle=None) + + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert SUBTASK_ID in msg.content + assert "@initiator-7" in msg.content + assert [m.id for m in msg.mentions] == ["initiator-7"] + + +@pytest.mark.asyncio +async def test_no_resolvable_owner_does_not_burn_escalate_once(tmp_path): + """A blocked subtask with no row owner and no override is skipped without + consuming its escalate-once marker, so it can escalate once an owner appears. + """ + store = _seed_blocked(tmp_path, owner_id=None) + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id=None, owner_handle=None) + + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + # The marker must NOT be set — a later patrol can still escalate once an + # owner is recorded on the task row. + assert (TASK_ID, SUBTASK_ID) not in daemon._owner_escalated + + +# ── ledger integrity rung (Stage-3) ───────────────────────────────────────── + +def _seed_chain(tmp_path, *, owner_id="owner-1"): + """A real store with a few hash-chained transition_log rows + an owner.""" + from codeband.state import StateStore + from codeband.state.fsm import transition + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id=owner_id) + transition(SUBTASK_ID, TASK_ID, "assigned", caller_role="conductor", store=store) + transition(SUBTASK_ID, TASK_ID, "in_progress", caller_role="coder", store=store) + transition(SUBTASK_ID, TASK_ID, "verify_pending", caller_role="coder", store=store) + return store + + +@pytest.mark.asyncio +async def test_integrity_baseline_then_chain_break_alerts(tmp_path): + """A first patrol baselines the chain (no alert); a subsequent in-place edit + of a NEW row breaks the chain forward and escalates a ledger-integrity alert.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + + # Tamper with an existing row in place BEFORE the first patrol, so the + # first (full) walk catches it. + conn = sqlite3.connect(store.db_path) + ids = [r[0] for r in conn.execute("SELECT id FROM transition_log ORDER BY id ASC").fetchall()] + conn.execute("UPDATE transition_log SET to_state = 'forged' WHERE id = ?", (ids[1],)) + conn.commit() + conn.close() + + await daemon._check_chain_integrity(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited() + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert "LEDGER INTEGRITY" in msg.content + assert "chain break" in msg.content + assert [m.id for m in msg.mentions] == ["owner-1"] + + +@pytest.mark.asyncio +async def test_integrity_head_regression_on_truncation(tmp_path): + """Truncating the tail of a verified chain — which a forward walk cannot + see — triggers a head-regression alert.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + + # First patrol: clean baseline, no alert. + await daemon._check_chain_integrity(datetime.now(UTC)) + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + # Truncate the tail — delete the last (verified) row. + conn = sqlite3.connect(store.db_path) + last = conn.execute("SELECT MAX(id) FROM transition_log").fetchone()[0] + conn.execute("DELETE FROM transition_log WHERE id = ?", (last,)) + conn.commit() + conn.close() + + await daemon._check_chain_integrity(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited() + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert "head regression" in msg.content + + +@pytest.mark.asyncio +async def test_integrity_alert_is_once_per_room_chain_kind(tmp_path): + """The same integrity break escalates a single time across repeated patrols.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + + conn = sqlite3.connect(store.db_path) + first = conn.execute("SELECT MIN(id) FROM transition_log").fetchone()[0] + conn.execute("UPDATE transition_log SET reason = 'x' WHERE id = ?", (first,)) + conn.commit() + conn.close() + + now = datetime.now(UTC) + await daemon._check_chain_integrity(now) + await daemon._check_chain_integrity(now) + await daemon._check_chain_integrity(now) + + assert rest.agent_api_messages.create_agent_chat_message.await_count == 1 + + +@pytest.mark.asyncio +async def test_integrity_clean_chain_never_alerts(tmp_path): + """A clean chain produces no integrity escalation across patrols.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + + now = datetime.now(UTC) + await daemon._check_chain_integrity(now) + await daemon._check_chain_integrity(now) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_integrity_rung_dormant_without_store(): + """No store → the integrity rung is a no-op (no crash, no send).""" + rest = _mock_rest() + daemon = _daemon(None, config=WatchdogConfig(), rest=rest) + + await daemon._check_chain_integrity(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +# ── deep full-history integrity sweep (Stage-3 PR3) ───────────────────────── + + +@pytest.mark.asyncio +async def test_full_history_catches_interior_tamper_incremental_misses(tmp_path): + """The blind-spot case. After the incremental rung baselines past the tip, + an in-place edit of an INTERIOR old row (id below the remembered tip) is + invisible to the incremental walk — it only re-reads rows past the tip, and + the head-regression check inspects only the (unchanged) tip — yet the + full-history sweep, walking from row 1, catches it.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + # interval=1 so the sweep runs on every call. + daemon._config = WatchdogConfig(full_integrity_interval_patrols=1) + + # Baseline the incremental rung: walks the whole chain, advances the + # remembered tip past every current row. + await daemon._check_chain_integrity(datetime.now(UTC)) + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + # Tamper with an INTERIOR row (not the tip) in place. + conn = sqlite3.connect(store.db_path) + ids = [ + r[0] for r in conn.execute( + "SELECT id FROM transition_log ORDER BY id ASC" + ).fetchall() + ] + interior_id = ids[1] # strictly below the remembered tip (ids[-1]) + conn.execute( + "UPDATE transition_log SET to_state = 'forged' WHERE id = ?", + (interior_id,), + ) + conn.commit() + conn.close() + + # Incremental rung MISSES it: only walks rows past the tip; the tip row is + # unchanged so head-regression stays silent. + await daemon._check_chain_integrity(datetime.now(UTC)) + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + # Full-history sweep CATCHES it and escalates to the owner. + await daemon._check_chain_integrity_full(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited() + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert "LEDGER INTEGRITY" in msg.content + assert "chain break" in msg.content + assert "verifier" in msg.content.lower() # attributed to the verifier role + assert [m.id for m in msg.mentions] == ["owner-1"] + + +@pytest.mark.asyncio +async def test_full_history_alert_is_once_per_room_chain_kind(tmp_path): + """The full-history sweep escalates a single time per (room, chain, kind) + across repeated patrols, reusing the rung's escalate-once pattern.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + daemon._config = WatchdogConfig(full_integrity_interval_patrols=1) + + conn = sqlite3.connect(store.db_path) + first = conn.execute("SELECT MIN(id) FROM transition_log").fetchone()[0] + conn.execute("UPDATE transition_log SET reason = 'forged' WHERE id = ?", (first,)) + conn.commit() + conn.close() + + now = datetime.now(UTC) + await daemon._check_chain_integrity_full(now) + await daemon._check_chain_integrity_full(now) + await daemon._check_chain_integrity_full(now) + + assert rest.agent_api_messages.create_agent_chat_message.await_count == 1 + + +@pytest.mark.asyncio +async def test_full_history_marker_independent_of_incremental_rung(tmp_path): + """The two rungs keep separate escalate-once sets: an incremental alert in a + room does not suppress the full-history sweep's own alert there.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + daemon._config = WatchdogConfig(full_integrity_interval_patrols=1) + + # Pre-mark the incremental rung as having already alerted this room/chain/kind. + daemon._integrity_alerted.add((ROOM_ID, "transition_log", "chain_break")) + + conn = sqlite3.connect(store.db_path) + first = conn.execute("SELECT MIN(id) FROM transition_log").fetchone()[0] + conn.execute("UPDATE transition_log SET reason = 'forged' WHERE id = ?", (first,)) + conn.commit() + conn.close() + + await daemon._check_chain_integrity_full(datetime.now(UTC)) + + # The full rung still fires despite the incremental marker being set. + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + assert (ROOM_ID, "transition_log", "chain_break") in daemon._full_integrity_alerted + + +@pytest.mark.asyncio +async def test_full_history_runs_only_every_n_patrols(tmp_path): + """With interval N, the full-history sweep verifies only on the Nth patrol; + earlier patrols are cheap no-ops even when a break is present.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + daemon._config = WatchdogConfig(full_integrity_interval_patrols=3) + + conn = sqlite3.connect(store.db_path) + first = conn.execute("SELECT MIN(id) FROM transition_log").fetchone()[0] + conn.execute("UPDATE transition_log SET reason = 'forged' WHERE id = ?", (first,)) + conn.commit() + conn.close() + + now = datetime.now(UTC) + # Patrols 1 and 2 are below the interval — no walk, no alert. + await daemon._check_chain_integrity_full(now) + await daemon._check_chain_integrity_full(now) + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + # Patrol 3 hits the interval — the sweep fires. + await daemon._check_chain_integrity_full(now) + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_full_history_runs_independent_of_verifier_seat(tmp_path): + """The sweep is code-driven: it escalates even with the verifier LLM seat + INERT, because integrity is a safety sweep, not an LLM behavior. The + watchdog takes no verifier-pool argument at all, so it cannot structurally + depend on seat allocation — demonstrated here with the seat explicitly + disabled (the product default activates it).""" + from codeband.config import AgentsConfig, VerifiersConfig + + # Seat explicitly INERT (no allocated worker) — the sweep must still fire. + assert AgentsConfig(verifiers=VerifiersConfig()).verifiers.total_count() == 0 + + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + daemon._config = WatchdogConfig(full_integrity_interval_patrols=1) + + conn = sqlite3.connect(store.db_path) + first = conn.execute("SELECT MIN(id) FROM transition_log").fetchone()[0] + conn.execute("UPDATE transition_log SET reason = 'forged' WHERE id = ?", (first,)) + conn.commit() + conn.close() + + await daemon._check_chain_integrity_full(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited() + + +@pytest.mark.asyncio +async def test_full_history_finding_attributed_to_verifier_role(tmp_path): + """The activity-log actor for a full-history finding is the verifier role, + not the watchdog.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + activity = MagicMock() + daemon = _owner_daemon(store, rest, activity=activity) + daemon._config = WatchdogConfig(full_integrity_interval_patrols=1) + + conn = sqlite3.connect(store.db_path) + first = conn.execute("SELECT MIN(id) FROM transition_log").fetchone()[0] + conn.execute("UPDATE transition_log SET reason = 'forged' WHERE id = ?", (first,)) + conn.commit() + conn.close() + + await daemon._check_chain_integrity_full(datetime.now(UTC)) + + actors = [ + c.args[1] for c in activity.log.call_args_list + if c.args and c.args[0] == "LEDGER_INTEGRITY_ALERT" + ] + assert actors == ["verifier"] + + +@pytest.mark.asyncio +async def test_full_history_clean_chain_never_alerts(tmp_path): + """A clean chain produces no full-history escalation.""" + store = _seed_chain(tmp_path) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + daemon._config = WatchdogConfig(full_integrity_interval_patrols=1) + + now = datetime.now(UTC) + await daemon._check_chain_integrity_full(now) + await daemon._check_chain_integrity_full(now) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_full_history_rung_dormant_without_store(): + """No store → the full-history rung is a no-op (no crash, no send).""" + rest = _mock_rest() + daemon = _daemon( + None, + config=WatchdogConfig(full_integrity_interval_patrols=1), + rest=rest, + ) + + await daemon._check_chain_integrity_full(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +# ── owner-awareness: nudge exclusion, marker-after-send, active-only patrol ── + +def _supersede(store) -> None: + """Mark the seeded task superseded, as #23's re-registration would.""" + conn = sqlite3.connect(store.db_path) + conn.execute( + "UPDATE tasks SET status = 'superseded' WHERE task_id = ?", (TASK_ID,), + ) + conn.commit() + conn.close() + + +@pytest.mark.asyncio +async def test_owner_agent_participant_is_never_nudged(tmp_path, monkeypatch): + """An Agent-typed participant whose id == task.owner_id is never nudged + (mission control is not a stalled worker); a non-owner agent in the same + room is still nudge-eligible. + """ + from codeband.state import StateStore + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id="owner-agent") + monkeypatch.setattr(subprocess, "run", lambda *a, **k: None) + + room = MagicMock() + room.id = ROOM_ID + + rest = _mock_rest() + rest.agent_api_chats = MagicMock() + rest.agent_api_chats.list_agent_chats = AsyncMock( + return_value=_chats_resp([room]), + ) + # 10 minutes: stale against the 300s threshold, inside the client-side + # read window the agent-API path now applies after fetch. + rest.agent_api_messages.list_agent_messages = AsyncMock( + return_value=MagicMock(data=[ + _msg("owner-agent", minutes_ago=10), # Agent-typed owner, stale + _msg("agent-p0", minutes_ago=10), # non-owner agent, stale + ]), + ) + parts = MagicMock() + parts.data = [ + _participant("agent-cond", "Conductor"), + _participant("owner-agent", "MissionControl"), # type="Agent" + _participant("agent-p0", "Coder-Claude-0"), + ] + rest.agent_api_participants = MagicMock() + rest.agent_api_participants.list_agent_chat_participants = AsyncMock( + return_value=parts, + ) + + from codeband.agents.watchdog import WatchdogDaemon + + daemon = WatchdogDaemon( + config=WatchdogConfig(stale_threshold_seconds=300), + rest_client=rest, + agent_id="agent-cond", + conductor_id="agent-cond", + state_store=store, + ) + # Two patrols: if the owner were tracked, the second would escalate it. + await daemon._patrol() + await daemon._patrol() + + calls = rest.agent_api_messages.create_agent_chat_message.call_args_list + assert calls, "the non-owner stale agent must still be nudged" + for call in calls: + msg = call.kwargs["message"] + assert all(m.id != "owner-agent" for m in msg.mentions) + assert "MissionControl" not in msg.content + assert [m.id for m in calls[0].kwargs["message"].mentions] == ["agent-p0"] + assert "owner-agent" not in daemon._state + + +@pytest.mark.asyncio +async def test_owner_escalation_transient_failure_retries(tmp_path): + """A transient send failure leaves the escalate-once marker unburned; the + next patrol retries and the successful send burns it — exactly one + successful escalation total (marker-after-send). + """ + store = _seed_blocked(tmp_path) + rest = _mock_rest() + rest.agent_api_messages.create_agent_chat_message = AsyncMock( + side_effect=[RuntimeError("simulated transient failure"), None], + ) + activity = MagicMock() + daemon = _owner_daemon(store, rest, activity=activity) + + now = datetime.now(UTC) + await daemon._check_blocked_subtasks(now) + assert (TASK_ID, SUBTASK_ID) not in daemon._owner_escalated, ( + "transient send failure must not burn the escalate-once marker" + ) + + await daemon._check_blocked_subtasks(now) # retry succeeds → marker burns + assert (TASK_ID, SUBTASK_ID) in daemon._owner_escalated + + await daemon._check_blocked_subtasks(now) # escalate-once holds + assert rest.agent_api_messages.create_agent_chat_message.await_count == 2 + events = [c.args[0] for c in activity.log.call_args_list] + assert events.count("SUBTASK_BLOCKED_OWNER_ESCALATION") == 1, ( + "the activity log must record exactly one successful escalation" + ) + + +@pytest.mark.asyncio +async def test_owner_escalation_422_burns_and_logs_critical(tmp_path, caplog): + """A 422 mention rejection (owner not mentionable in the room) is + permanent: burn the marker anyway, log at CRITICAL, never retry. + """ + import logging + + from thenvoi_rest.core.api_error import ApiError + + store = _seed_blocked(tmp_path) + rest = _mock_rest() + rest.agent_api_messages.create_agent_chat_message = AsyncMock( + side_effect=ApiError( + status_code=422, headers={}, + body="mentioned_participant_not_in_room", + ), + ) + daemon = _owner_daemon(store, rest, owner_id="owner-1") + + with caplog.at_level(logging.CRITICAL, logger="codeband.agents.watchdog"): + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + assert (TASK_ID, SUBTASK_ID) in daemon._owner_escalated + critical = [r for r in caplog.records if r.levelno == logging.CRITICAL] + assert len(critical) == 1 + message = critical[0].getMessage() + assert "owner owner-1" in message + assert ROOM_ID in message + assert "escalation undeliverable" in message + + # No retry on subsequent patrols. + await daemon._check_blocked_subtasks(datetime.now(UTC)) + assert rest.agent_api_messages.create_agent_chat_message.await_count == 1 + + +@pytest.mark.asyncio +async def test_superseded_task_blocked_subtask_not_escalated(tmp_path): + """A blocked subtask of a status='superseded' task is invisible to the + owner-escalation scan: no send, no marker burn, no owner/room resolution. + """ + store = _seed_blocked(tmp_path, owner_id="initiator-7") + _supersede(store) + rest = _mock_rest() + daemon = _owner_daemon(store, rest) + get_task_spy = MagicMock(wraps=store.get_task) + store.get_task = get_task_spy + + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + assert (TASK_ID, SUBTASK_ID) not in daemon._owner_escalated + get_task_spy.assert_not_called() + + +@pytest.mark.asyncio +async def test_superseded_task_room_not_patrolled(tmp_path, monkeypatch): + """Chat-recency: a superseded task's room is skipped before any REST read, + retiring the stale-room warning for superseded rows. + """ + store = _seed_store(tmp_path, state="merged") + _supersede(store) + monkeypatch.setattr(subprocess, "run", lambda *a, **k: None) + + room = MagicMock() + room.id = ROOM_ID + + rest = _mock_rest() + rest.agent_api_chats = MagicMock() + rest.agent_api_chats.list_agent_chats = AsyncMock( + return_value=_chats_resp([room]), + ) + rest.agent_api_messages.list_agent_messages = AsyncMock( + return_value=MagicMock(data=[_msg("agent-p0", minutes_ago=30)]), + ) + rest.agent_api_participants = MagicMock() + rest.agent_api_participants.list_agent_chat_participants = AsyncMock() + + from codeband.agents.watchdog import WatchdogDaemon + + daemon = WatchdogDaemon( + config=WatchdogConfig(stale_threshold_seconds=300), + rest_client=rest, + agent_id="agent-cond", + conductor_id="agent-cond", + state_store=store, + ) + await daemon._patrol() + + rest.agent_api_messages.list_agent_messages.assert_not_awaited() + rest.agent_api_participants.list_agent_chat_participants.assert_not_awaited() + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_superseded_task_subtask_progress_not_tracked(tmp_path, monkeypatch): + """Mechanical-progress: in-flight subtasks of a superseded task are not + tracked — no git/gh shell-outs, no health entry, no escalation. + """ + store = _seed_store(tmp_path, state="in_progress") + _supersede(store) + calls: list = [] + monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(1)) + + rest = _mock_rest() + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2), rest=rest) + for _ in range(4): + await daemon._check_subtask_progress(datetime.now(UTC)) + + assert calls == [] + assert (TASK_ID, SUBTASK_ID) not in daemon._subtask_state + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +# ── probe repo context (S9-1): injected at construction, used in argv ──────── + +class TestProbeRepoContext: + """The runner injects the bare-repo path + config repo slug; the probes + must use them — cwd-independent git/gh — without changing how a ``None`` + result is counted (stall semantics belong to Batch 3).""" + + def _daemon(self, **kwargs): + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=WatchdogConfig(), + rest_client=_mock_rest(), + agent_id="agent-wd", + conductor_id="agent-cond", + **kwargs, + ) + + def test_git_head_runs_against_injected_bare_repo(self, tmp_path, monkeypatch): + calls: list = [] + + class _R: + returncode = 0 + stdout = "abc123\n" + + monkeypatch.setattr( + subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _R(), + ) + daemon = self._daemon(bare_repo=tmp_path / "repo.git") + assert daemon._git_head("feature-x") == "abc123" + assert calls == [[ + "git", "-C", str(tmp_path / "repo.git"), + "rev-parse", "--verify", "--end-of-options", "feature-x", + ]] + + def test_git_head_without_context_keeps_cwd_resolution(self, monkeypatch): + calls: list = [] + + class _R: + returncode = 0 + stdout = "abc123\n" + + monkeypatch.setattr( + subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _R(), + ) + daemon = self._daemon() + assert daemon._git_head("feature-x") == "abc123" + # No -C injection, but --verify/--end-of-options still applied (the + # branch name can never be parsed as an option — sweep-4 F-6). + assert calls == [[ + "git", "rev-parse", "--verify", "--end-of-options", "feature-x", + ]] + + def test_git_head_unreadable_branch_is_none_as_before( + self, tmp_path, monkeypatch, + ): + class _R: + returncode = 128 + stdout = "" + + monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: _R()) + daemon = self._daemon(bare_repo=tmp_path / "repo.git") + assert daemon._git_head("gone-branch") is None # counting unchanged + + def test_pr_updated_at_pins_repo_slug(self, monkeypatch): + calls: list = [] + + class _R: + returncode = 0 + stdout = json.dumps( + {"state": "OPEN", "updatedAt": "2026-06-01T00:00:00+00:00"}, + ) + + monkeypatch.setattr( + subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _R(), + ) + daemon = self._daemon(repo_slug="acme/widgets") + assert daemon._pr_updated_at(42) is not None + assert calls == [[ + "gh", "pr", "view", "42", + "--json", "state,updatedAt", "--repo", "acme/widgets", + ]] + + def test_pr_updated_at_without_slug_keeps_cwd_resolution(self, monkeypatch): + calls: list = [] + + class _R: + returncode = 0 + stdout = json.dumps( + {"state": "OPEN", "updatedAt": "2026-06-01T00:00:00+00:00"}, + ) + + monkeypatch.setattr( + subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _R(), + ) + daemon = self._daemon() + assert daemon._pr_updated_at(42) is not None + assert calls == [["gh", "pr", "view", "42", "--json", "state,updatedAt"]] + +# ── patrol coverage: resting states where dispatched work can die (S2-1/F12) ─ + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "state", + ["needs_rebase", "review_pending", "review_failed", "review_passed"], +) +async def test_resting_states_are_patrolled(tmp_path, monkeypatch, state): + """A stale subtask in any resting state crosses the stall cap and blocks. + + These are the states where dispatched work can silently die (a reviewer + that never renders a verdict, a coder that never picks up rework, a + Mergemaster that never queues the approved PR, a rebase nobody starts). + The mechanical signals are state-agnostic: transition recency applies to + every state, and the PR signal applies because pr_number is set. + """ + store = _seed_store(tmp_path, state=state) + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + rest = _mock_rest() + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2), rest=rest) + now = datetime.now(UTC) + for _ in range(5): + await daemon._check_subtask_progress(now) + + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "blocked" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "state", + ["needs_rebase", "review_pending", "review_failed", "review_passed"], +) +async def test_resting_state_progress_resets_counter(tmp_path, monkeypatch, state): + """The standard progress signals (here: PR activity) reset the counter for + the newly patrolled states, so an actively-progressing subtask never + escalates.""" + store = _seed_store(tmp_path, state=state) + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=10)) + now = datetime.now(UTC) + await daemon._check_subtask_progress(now) # baseline + await daemon._check_subtask_progress(now) # stale → 1 + health = daemon._subtask_state[(TASK_ID, SUBTASK_ID)] + assert health.patrol_visits_without_progress == 1 + + signals["pr_updated"] = "2026-06-01T12:00:00+00:00" # PR activity + await daemon._check_subtask_progress(now) + assert health.patrol_visits_without_progress == 0 + + +# ── observation vs absence (S6-F6) ─────────────────────────────────────────── + +def _make_failing_run(): + """Every git/gh shell-out fails — the probe is fully degraded.""" + def _run(cmd, *args, **kwargs): + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="boom") + + return _run + + +@pytest.mark.asyncio +async def test_all_signal_reads_failed_does_not_count(tmp_path, monkeypatch): + """A patrol where EVERY signal read failed observed nothing — it must not + advance the stall counter (and so can never escalate a subtask it cannot + see). The consecutive no-data counter tracks the degraded probe instead.""" + store = _seed_store(tmp_path) + monkeypatch.setattr(subprocess, "run", _make_failing_run()) + + rest = _mock_rest() + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2), rest=rest) + # Sever the transition-log signal: both the batch path and the per-subtask + # fallback must fail so the patrol sees a total-read-failure. + monkeypatch.setattr( + store, "batch_latest_transitions", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("db down")), + ) + monkeypatch.setattr( + daemon, "_latest_transition", lambda subtask_id, task_id: (False, None), + ) + + now = datetime.now(UTC) + for _ in range(5): + await daemon._check_subtask_progress(now) + + health = daemon._subtask_state[(TASK_ID, SUBTASK_ID)] + assert health.patrol_visits_without_progress == 0 + assert health.no_data_patrols == 5 + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "in_progress" + + +@pytest.mark.asyncio +async def test_returned_but_unchanged_still_counts(tmp_path, monkeypatch): + """Failed git/gh reads with a SUCCESSFUL transition-log read still count: + 'queried fine, nothing new' is an observation of absence, not a failure — + the stall cap must keep working when only the shell-outs are degraded.""" + store = _seed_store(tmp_path) + monkeypatch.setattr(subprocess, "run", _make_failing_run()) + _insert_transition(store, timestamp="2026-05-31T00:00:00+00:00") + + rest = _mock_rest() + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2), rest=rest) + now = datetime.now(UTC) + for _ in range(4): + await daemon._check_subtask_progress(now) + + health = daemon._subtask_state[(TASK_ID, SUBTASK_ID)] + assert health.no_data_patrols == 0 + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "blocked" + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_no_data_counter_resets_on_recovery(tmp_path, monkeypatch): + """Once any signal read succeeds again, the no-data counter resets.""" + store = _seed_store(tmp_path) + failing = {"on": True} + + def _run(cmd, *args, **kwargs): + if failing["on"]: + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="boom") + if cmd[0] == "git": + return subprocess.CompletedProcess(cmd, 0, stdout="abc123", stderr="") + payload = json.dumps({"state": "OPEN", "updatedAt": BASELINE_PR_TS}) + return subprocess.CompletedProcess(cmd, 0, stdout=payload, stderr="") + + monkeypatch.setattr(subprocess, "run", _run) + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=10)) + # Sever both the batch path and the per-subtask fallback so the first patrol + # has no transition-log data at all (total degraded read). + monkeypatch.setattr( + store, "batch_latest_transitions", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("db down")), + ) + monkeypatch.setattr( + daemon, "_latest_transition", lambda subtask_id, task_id: (False, None), + ) + + now = datetime.now(UTC) + await daemon._check_subtask_progress(now) + health = daemon._subtask_state[(TASK_ID, SUBTASK_ID)] + assert health.no_data_patrols == 1 + + # Recovery: restore the DB so the batch can succeed on the next patrol. + monkeypatch.undo() + monkeypatch.setattr(subprocess, "run", _run) + failing["on"] = False + await daemon._check_subtask_progress(now) + assert health.no_data_patrols == 0 + + +# ── rung-3 marker-after-send (S6-F7n) ──────────────────────────────────────── + +@pytest.mark.asyncio +async def test_stall_escalation_marker_burns_only_on_success(tmp_path, monkeypatch): + """health.escalated burns only when the escalation took effect (FSM + transition or alert landed) — a patrol where both failed retries next + patrol instead of silently never escalating. A double-send is acceptable; + a silent permanent stall is not.""" + store = _seed_store(tmp_path) + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + rest = _mock_rest() + send = rest.agent_api_messages.create_agent_chat_message + send.side_effect = RuntimeError("band is down") + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2), rest=rest) + # Both halves fail: no FSM effect, no alert. + monkeypatch.setattr(daemon, "_mark_blocked_via_fsm", lambda sub: False) + + now = datetime.now(UTC) + for _ in range(3): # baseline + 2 stale → cap crossed, escalation fails + await daemon._check_subtask_progress(now) + health = daemon._subtask_state[(TASK_ID, SUBTASK_ID)] + assert health.escalated is False + assert send.await_count == 1 + + await daemon._check_subtask_progress(now) # still failing → retried + assert send.await_count == 2 + assert health.escalated is False + + send.side_effect = None # Band recovers — the alert lands + await daemon._check_subtask_progress(now) + assert send.await_count == 3 + assert health.escalated is True + + await daemon._check_subtask_progress(now) # escalate-once: no further send + assert send.await_count == 3 + + +@pytest.mark.asyncio +async def test_stall_escalation_fsm_success_burns_marker_despite_send_failure( + tmp_path, monkeypatch, +): + """The FSM transition landing IS an effect: the marker burns even when the + room alert fails, because the durable blocked state already escalates via + the owner patrol.""" + store = _seed_store(tmp_path) + signals = {"head": "abc123", "pr_updated": BASELINE_PR_TS} + monkeypatch.setattr(subprocess, "run", _make_run(signals)) + + rest = _mock_rest() + rest.agent_api_messages.create_agent_chat_message.side_effect = ( + RuntimeError("band is down") + ) + daemon = _daemon(store, config=WatchdogConfig(max_phase_visits=2), rest=rest) + + now = datetime.now(UTC) + for _ in range(3): + await daemon._check_subtask_progress(now) + + health = daemon._subtask_state[(TASK_ID, SUBTASK_ID)] + assert health.escalated is True + assert store.get_subtask(SUBTASK_ID, TASK_ID).state == "blocked" + + +# ── patrol cost: mention-pattern cache + client-side read window (S8-F1) ──── + +class TestMentionPatternCache: + def test_patterns_compiled_once_per_participant_set(self): + from codeband.agents.watchdog import _mention_patterns + + participants = frozenset({("p1", "Coder-Claude-0"), ("p2", "Reviewer-Codex-0")}) + first = _mention_patterns(participants) + second = _mention_patterns(frozenset(participants)) + assert first is second # lru_cache hit — no recompilation + + def test_cached_patterns_keep_boundary_semantics(self): + from codeband.agents.watchdog import _mentioned_participant_ids + + names = {"p1": "Coder-Claude-0", "p2": "Coder-Claude-01"} + + class _Msg: + mentions = None + content = "ping @Coder-Claude-0 please" + + assert _mentioned_participant_ids(_Msg(), names) == {"p1"} + + class _Email: + mentions = None + content = "mail me at me@Coder-Claude-0" + + assert _mentioned_participant_ids(_Email(), names) == set() + + def test_empty_names_excluded(self): + from codeband.agents.watchdog import _mention_patterns + + patterns = _mention_patterns(frozenset({("p1", ""), ("p2", "Name")})) + assert [pid for pid, _ in patterns] == ["p2"] + + +# ── @[[uuid]] inline-markup mentions (finding 17) ─────────────────────────── + +class TestUuidMentionMarkup: + UUID = "0c69793a-58c8-4f6e-b167-2eb6cb2ad2e3" + + def _msg(self, content): + class _Msg: + mentions = None + m = _Msg() + m.content = content + return m + + def test_uuid_markup_resolves_against_participant_set(self): + from codeband.agents.watchdog import _mentioned_participant_ids + + names = {self.UUID: "Coder-Codex-0", "p2": "Reviewer-Claude-0"} + msg = self._msg(f"please pick this back up @[[{self.UUID}]]") + assert _mentioned_participant_ids(msg, names) == {self.UUID} + + def test_unknown_uuid_markup_is_ignored(self): + from codeband.agents.watchdog import _mentioned_participant_ids + + names = {self.UUID: "Coder-Codex-0"} + msg = self._msg("@[[11111111-2222-3333-4444-555555555555]] hello") + assert _mentioned_participant_ids(msg, names) == set() + + def test_uuid_markup_is_case_insensitive(self): + from codeband.agents.watchdog import _mentioned_participant_ids + + names = {self.UUID: "Coder-Codex-0"} + msg = self._msg(f"@[[{self.UUID.upper()}]] re-dispatch") + assert _mentioned_participant_ids(msg, names) == {self.UUID} + + def test_uuid_markup_combines_with_display_name_form(self): + from codeband.agents.watchdog import _mentioned_participant_ids + + names = {self.UUID: "Coder-Codex-0", "p2": "Reviewer-Claude-0"} + msg = self._msg(f"@Reviewer-Claude-0 and @[[{self.UUID}]] — sync up") + assert _mentioned_participant_ids(msg, names) == {self.UUID, "p2"} + + +@pytest.mark.asyncio +async def test_terminal_message_does_not_untrack_agent_with_newer_uuid_mention( + tmp_path, monkeypatch, +): + """The compounding half of finding 17: an agent whose last own message is + terminal-shaped but that has an UNANSWERED @[[uuid]] re-dispatch newer + than it must stay tracked — and get nudged when the mention goes stale.""" + agent_uuid = "0c69793a-58c8-4f6e-b167-2eb6cb2ad2e3" + store = _seed_store(tmp_path, state="merged") + monkeypatch.setattr(subprocess, "run", lambda *a, **k: None) + + room = MagicMock() + room.id = ROOM_ID + + done = _msg(agent_uuid, minutes_ago=60) + done.content = "Review PASSED — risk low. Merged PR #12." # terminal-shaped + redispatch = _msg("agent-cond", minutes_ago=10) # stale vs 300s threshold + redispatch.content = f"@[[{agent_uuid}]] please pick up subtask st-9" + + rest = _mock_rest() + rest.agent_api_chats = MagicMock() + rest.agent_api_chats.list_agent_chats = AsyncMock( + return_value=_chats_resp([room]), + ) + rest.agent_api_messages.list_agent_messages = AsyncMock( + return_value=MagicMock(data=[done, redispatch]), + ) + parts = MagicMock() + parts.data = [ + _participant("agent-cond", "Conductor"), + _participant(agent_uuid, "Coder-Codex-0"), + ] + rest.agent_api_participants = MagicMock() + rest.agent_api_participants.list_agent_chat_participants = AsyncMock( + return_value=parts, + ) + + from codeband.agents.watchdog import WatchdogDaemon + + daemon = WatchdogDaemon( + config=WatchdogConfig(stale_threshold_seconds=300), + rest_client=rest, + agent_id="agent-cond", + conductor_id="agent-cond", + state_store=store, + ) + await daemon._patrol() + + # The agent was NOT untracked: the unanswered re-dispatch keeps it on the + # clock, and 10 minutes of silence after the mention earns a nudge. + rest.agent_api_messages.create_agent_chat_message.assert_awaited() + sent = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert "Status check" in sent.content + + +@pytest.mark.asyncio +async def test_terminal_message_still_untracks_without_newer_mention( + tmp_path, monkeypatch, +): + """Control: the same terminal-shaped last message with NO newer inbound + mention keeps the historical untrack behavior — no nudge.""" + store = _seed_store(tmp_path, state="merged") + monkeypatch.setattr(subprocess, "run", lambda *a, **k: None) + + room = MagicMock() + room.id = ROOM_ID + + done = _msg("agent-p0", minutes_ago=60) + done.content = "Review PASSED — risk low. Merged PR #12." + + rest = _mock_rest() + rest.agent_api_chats = MagicMock() + rest.agent_api_chats.list_agent_chats = AsyncMock( + return_value=_chats_resp([room]), + ) + rest.agent_api_messages.list_agent_messages = AsyncMock( + return_value=MagicMock(data=[done]), + ) + parts = MagicMock() + parts.data = [ + _participant("agent-cond", "Conductor"), + _participant("agent-p0", "Coder-Claude-0"), + ] + rest.agent_api_participants = MagicMock() + rest.agent_api_participants.list_agent_chat_participants = AsyncMock( + return_value=parts, + ) + + from codeband.agents.watchdog import WatchdogDaemon + + daemon = WatchdogDaemon( + config=WatchdogConfig(stale_threshold_seconds=300), + rest_client=rest, + agent_id="agent-cond", + conductor_id="agent-cond", + state_store=store, + ) + await daemon._patrol() + + rest.agent_api_messages.create_agent_chat_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_agent_api_messages_bounded_client_side(tmp_path): + """The free-tier agent-API path has no server-side `since` param, so the + window bound is applied client-side after fetch — old history never + reaches the per-message mention scan. Messages with no/unparseable + timestamp are kept (the patrol loop already skips them).""" + store = _seed_store(tmp_path) + now = datetime.now(UTC) + + def _m(mid, ts): + m = MagicMock() + m.inserted_at = ts + m.id = mid + return m + + recent = _m("recent", now - timedelta(minutes=5)) + ancient = _m("ancient", now - timedelta(days=30)) + no_ts = _m("no-ts", None) + + rest = _mock_rest() + rest.agent_api_messages.list_agent_messages = AsyncMock( + return_value=MagicMock(data=[recent, ancient, no_ts]), + ) + daemon = _daemon(store, config=WatchdogConfig(), rest=rest) + + since = now - timedelta(minutes=60) + messages = await daemon._list_messages(ROOM_ID, since) + assert [m.id for m in messages] == ["recent", "no-ts"] + + +@pytest.mark.asyncio +async def test_human_api_messages_not_filtered_client_side(tmp_path): + """The human-API path passes `since` server-side; what comes back is + returned as-is.""" + store = _seed_store(tmp_path) + now = datetime.now(UTC) + + old_msg = MagicMock() + old_msg.inserted_at = now - timedelta(days=30) + + human = MagicMock() + human.human_api_messages = MagicMock() + human.human_api_messages.list_my_chat_messages = AsyncMock( + return_value=MagicMock(data=[old_msg]), + ) + + from codeband.agents.watchdog import WatchdogDaemon + + daemon = WatchdogDaemon( + config=WatchdogConfig(), + rest_client=_mock_rest(), + agent_id="agent-wd", + conductor_id="agent-cond", + state_store=store, + human_rest_client=human, + ) + since = now - timedelta(minutes=60) + messages = await daemon._list_messages(ROOM_ID, since) + assert messages == [old_msg] + human.human_api_messages.list_my_chat_messages.assert_awaited_once_with( + chat_id=ROOM_ID, since=since, + ) + + +# ── free-tier recency probe pages to the window (follow-up to S8-F1) ──────── + +def _paged_messages_api(pages): + """An ``list_agent_messages`` fake serving ``pages`` with real paging metadata. + + ``pages`` is oldest-first (page 1 = oldest), each page's messages + oldest-first — the agent API's actual ordering. Returns ``(fetch, + requested_pages)``; the recorded page numbers prove which pages the probe + walked. ``total_pages`` is a real int so the probe's isinstance guard + accepts it (unlike the MagicMock sentinels of the legacy mocks). + """ + requested: list[int | None] = [] + + async def fetch(*, chat_id, status="all", page=None, page_size=None): + requested.append(page) + p = page or 1 + resp = MagicMock() + resp.data = list(pages[p - 1]) + resp.metadata = MagicMock() + resp.metadata.page = p + resp.metadata.total_pages = len(pages) + return resp + + return fetch, requested + + +def _ts_msg(mid, ts): + m = MagicMock() + m.id = mid + m.inserted_at = ts + return m + + +@pytest.mark.asyncio +async def test_long_room_recent_activity_on_last_page_is_seen(tmp_path): + """A 3-page room with all recent activity on the LAST page: the probe must + see it. Before paging, the un-paged fetch returned page 1 (oldest), the + window filtered it to empty, and the probe read an active room as silence. + """ + store = _seed_store(tmp_path) + now = datetime.now(UTC) + old = now - timedelta(days=10) + pages = [ + [_ts_msg(f"old-1-{i}", old + timedelta(minutes=i)) for i in range(3)], + [_ts_msg(f"old-2-{i}", old + timedelta(hours=1, minutes=i)) for i in range(3)], + # Last page mixed: oldest entry outside the window, newest inside — + # the window boundary lies inside this page, so the walk stops here. + [ + _ts_msg("stale-3-0", now - timedelta(hours=3)), + _ts_msg("recent-3-1", now - timedelta(minutes=10)), + _ts_msg("recent-3-2", now - timedelta(minutes=5)), + ], + ] + fetch, requested = _paged_messages_api(pages) + rest = _mock_rest() + rest.agent_api_messages.list_agent_messages = fetch + daemon = _daemon(store, config=WatchdogConfig(), rest=rest) + + since = now - timedelta(minutes=60) + messages = await daemon._list_messages(ROOM_ID, since) + + assert [m.id for m in messages] == ["recent-3-1", "recent-3-2"] + # One probe request (learns total_pages), then the LAST page; the window + # boundary is inside it, so no further walk. + assert requested == [1, 3] + + +@pytest.mark.asyncio +async def test_walk_continues_while_window_extends_into_earlier_pages(tmp_path): + """A fully-in-window last page means the window may extend further back: + the walk fetches the previous page too, and an all-recent page 1 is + served from the probe request without a refetch.""" + store = _seed_store(tmp_path) + now = datetime.now(UTC) + pages = [ + [_ts_msg("p1-0", now - timedelta(minutes=30))], + [_ts_msg("p2-0", now - timedelta(minutes=20))], + [_ts_msg("p3-0", now - timedelta(minutes=10))], + ] + fetch, requested = _paged_messages_api(pages) + rest = _mock_rest() + rest.agent_api_messages.list_agent_messages = fetch + daemon = _daemon(store, config=WatchdogConfig(), rest=rest) + + since = now - timedelta(minutes=60) + messages = await daemon._list_messages(ROOM_ID, since) + + # Everything is inside the window, oldest→newest order preserved. + assert [m.id for m in messages] == ["p1-0", "p2-0", "p3-0"] + # Page 1 is reused from the probe request — never fetched twice. + assert requested == [1, 3, 2] + + +@pytest.mark.asyncio +async def test_short_room_single_page_unchanged(tmp_path): + """A one-page room: one fetch, window applied, behavior as before.""" + store = _seed_store(tmp_path) + now = datetime.now(UTC) + pages = [ + [ + _ts_msg("ancient", now - timedelta(days=30)), + _ts_msg("recent", now - timedelta(minutes=5)), + _ts_msg("no-ts", None), + ], + ] + fetch, requested = _paged_messages_api(pages) + rest = _mock_rest() + rest.agent_api_messages.list_agent_messages = fetch + daemon = _daemon(store, config=WatchdogConfig(), rest=rest) + + since = now - timedelta(minutes=60) + messages = await daemon._list_messages(ROOM_ID, since) + + assert [m.id for m in messages] == ["recent", "no-ts"] + assert requested == [1] + + +@pytest.mark.asyncio +async def test_page_walk_is_capped(tmp_path): + """A very long room that is fully active stops after _MAX_PROBE_PAGES + newest pages — beyond that the room is active by definition and older + history cannot change any recency verdict.""" + from codeband.agents.watchdog import _MAX_PROBE_PAGES + + store = _seed_store(tmp_path) + now = datetime.now(UTC) + # 10 pages, every message inside the window (an extremely busy room). + pages = [ + [_ts_msg(f"p{p}-0", now - timedelta(minutes=59 - p))] + for p in range(1, 11) + ] + fetch, requested = _paged_messages_api(pages) + rest = _mock_rest() + rest.agent_api_messages.list_agent_messages = fetch + daemon = _daemon(store, config=WatchdogConfig(), rest=rest) + + since = now - timedelta(minutes=60) + messages = await daemon._list_messages(ROOM_ID, since) + + # Only the newest _MAX_PROBE_PAGES pages are walked (plus the probe + # request for page 1 that learned total_pages). + assert requested == [1, 10, 9, 8, 7, 6] + assert len(requested) - 1 == _MAX_PROBE_PAGES + # Newest five pages' contents, oldest→newest. + assert [m.id for m in messages] == ["p6-0", "p7-0", "p8-0", "p9-0", "p10-0"] + + +# ── finding 26: stall-blocked alert uses assigned_worker or owner for mention ─ + +def _seed_stall_blocked(tmp_path, *, assigned_worker: str | None = None, + owner_id: str | None = None): + """Store with one subtask in in_progress and optional worker/owner.""" + from codeband.state import StateStore + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id=owner_id) + _write_room_pointer(store) + store.ensure_subtask( + SUBTASK_ID, TASK_ID, + state="in_progress", + metadata={"branch": "feature-x"}, + ) + if assigned_worker is not None: + import sqlite3 as _sqlite3 + conn = _sqlite3.connect(store.db_path) + conn.execute( + "UPDATE subtask_states SET assigned_worker = ? WHERE subtask_id = ?", + (assigned_worker, SUBTASK_ID), + ) + conn.commit() + conn.close() + return store + + +@pytest.mark.asyncio +async def test_stall_blocked_alert_mentions_assigned_worker(tmp_path): + """When assigned_worker is set the stall-blocked alert mentions them.""" + store = _seed_stall_blocked(tmp_path, assigned_worker="coder-42", owner_id="owner-1") + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id="owner-1") + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + await daemon._send_blocked_escalation(sub) + + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert [m.id for m in msg.mentions] == ["coder-42"] + + +@pytest.mark.asyncio +async def test_stall_blocked_alert_null_worker_routes_to_owner(tmp_path): + """With assigned_worker=NULL the alert falls back to the task owner.""" + store = _seed_stall_blocked(tmp_path, assigned_worker=None, owner_id="owner-7") + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id="owner-7") + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + await daemon._send_blocked_escalation(sub) + + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert [m.id for m in msg.mentions] == ["owner-7"] + + +@pytest.mark.asyncio +async def test_stall_blocked_alert_null_worker_null_owner_no_422(tmp_path): + """When both assigned_worker and owner are NULL the alert posts without a + mention so no None id reaches ChatMessageRequestMentionsItem (→ 422).""" + store = _seed_stall_blocked(tmp_path, assigned_worker=None, owner_id=None) + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id=None, owner_handle=None) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + await daemon._send_blocked_escalation(sub) + + msg = rest.agent_api_messages.create_agent_chat_message.call_args.kwargs["message"] + assert msg.mentions == [] + + +# ── finding 28: merge_pending SHA-drift rung routes to needs_rebase ────────── + +def _seed_merge_pending(tmp_path, *, approved_sha: str, owner_id: str = "owner-1"): + """Store with a merge_pending subtask whose approved SHA is set.""" + import sqlite3 as _sqlite3 + from codeband.state import StateStore + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id=owner_id) + _write_room_pointer(store) + store.ensure_subtask( + SUBTASK_ID, TASK_ID, + state="in_progress", + metadata={"branch": "feature-x"}, + ) + conn = _sqlite3.connect(store.db_path) + conn.execute( + "UPDATE subtask_states SET state = 'merge_pending', " + "merge_approved_sha = ? WHERE subtask_id = ?", + (approved_sha, SUBTASK_ID), + ) + conn.commit() + conn.close() + return store + + +@pytest.mark.asyncio +async def test_merge_pending_sha_drift_routes_to_needs_rebase(tmp_path, monkeypatch): + """When the branch HEAD has moved past the approved SHA, the watchdog drives + the merge_pending subtask to needs_rebase via the mergemaster FSM edge.""" + approved_sha = "aaa1111" + live_sha = "bbb2222" + store = _seed_merge_pending(tmp_path, approved_sha=approved_sha) + rest = _mock_rest() + + monkeypatch.setattr( + "subprocess.run", + lambda cmd, **kw: type("R", (), {"returncode": 0, "stdout": live_sha + "\n"})(), + ) + + config = WatchdogConfig(max_phase_visits=10, git_progress_check=True) + daemon = _daemon(store, config=config, rest=rest) + now = datetime.now(UTC) + await daemon._check_subtask_progress(now) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.state == "needs_rebase" + + +@pytest.mark.asyncio +async def test_merge_pending_sha_stable_no_reroute(tmp_path, monkeypatch): + """When the live HEAD matches the approved SHA no needs_rebase transition fires.""" + sha = "aaa1111" + store = _seed_merge_pending(tmp_path, approved_sha=sha) + rest = _mock_rest() + + monkeypatch.setattr( + "subprocess.run", + lambda cmd, **kw: type("R", (), {"returncode": 0, "stdout": sha + "\n"})(), + ) + + config = WatchdogConfig(max_phase_visits=10, git_progress_check=True) + daemon = _daemon(store, config=config, rest=rest) + now = datetime.now(UTC) + await daemon._check_subtask_progress(now) + + sub = store.get_subtask(SUBTASK_ID, TASK_ID) + assert sub.state == "merge_pending" + + +# ── _owner_escalated marker resets after resume (batch4 adjacent finding #1) ─ + +def _drive_to_blocked(store, *, reason: str = "cap reached") -> None: + """Advance the subtask through assigned→in_progress→blocked via real FSM.""" + from codeband.state.fsm import transition + transition(SUBTASK_ID, TASK_ID, "assigned", caller_role="conductor", store=store) + transition(SUBTASK_ID, TASK_ID, "in_progress", caller_role="coder", store=store) + transition(SUBTASK_ID, TASK_ID, "blocked", caller_role="coder", + reason=reason, store=store) + + +@pytest.mark.asyncio +async def test_owner_escalated_marker_cleared_after_resume(tmp_path): + """After cb-phase resume (blocked → in_progress), the next patrol clears the + _owner_escalated marker so a subsequent re-block can escalate again.""" + from codeband.state import StateStore + from codeband.state.fsm import transition + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id="owner-1") + _drive_to_blocked(store) + + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id="owner-1") + + # First patrol: subtask is blocked → escalates and burns the marker. + await daemon._check_blocked_subtasks(datetime.now(UTC)) + assert (TASK_ID, SUBTASK_ID) in daemon._owner_escalated + + # Simulate cb-phase resume: drive blocked → in_progress. + transition(SUBTASK_ID, TASK_ID, "in_progress", caller_role="conductor", + store=store) + + # Next patrol: subtask is no longer blocked → marker should be cleared. + await daemon._check_blocked_subtasks(datetime.now(UTC)) + assert (TASK_ID, SUBTASK_ID) not in daemon._owner_escalated + + +@pytest.mark.asyncio +async def test_owner_reescalates_after_resume_and_reblock(tmp_path): + """After resume + re-block, a second escalation fires (marker was cleared).""" + from codeband.state import StateStore + from codeband.state.fsm import transition + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id="owner-1") + _drive_to_blocked(store) + + rest = _mock_rest() + daemon = _owner_daemon(store, rest, owner_id="owner-1") + + # First block + escalation. + await daemon._check_blocked_subtasks(datetime.now(UTC)) + assert rest.agent_api_messages.create_agent_chat_message.await_count == 1 + + # Resume to in_progress, then patrol — subtask not blocked, marker cleared. + transition(SUBTASK_ID, TASK_ID, "in_progress", caller_role="conductor", + store=store) + await daemon._check_blocked_subtasks(datetime.now(UTC)) # clears marker + assert (TASK_ID, SUBTASK_ID) not in daemon._owner_escalated + + # Re-block. + transition(SUBTASK_ID, TASK_ID, "blocked", caller_role="coder", + reason="stalled again", store=store) + + # Second escalation fires because the marker was cleared. + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + assert rest.agent_api_messages.create_agent_chat_message.await_count == 2 + + +# ── batch_latest_transitions used in progress patrol (T-06) ───────────────── + +@pytest.mark.asyncio +async def test_progress_patrol_uses_batch_transitions(tmp_path, monkeypatch): + """_check_subtask_progress pre-fetches all transition timestamps in one + batch query instead of one per subtask (N+1 elimination). Verify the + batch path is exercised by monkeypatching _latest_transition to fail if + called — a patrol against a store with no transition rows must still + complete without error via the batch (empty result → ok=True, ts=None). + """ + store = _seed_store(tmp_path) + # No git HEAD changes so we can focus purely on transition-log behaviour. + monkeypatch.setattr(subprocess, "run", _make_run({"head": "abc", "pr_updated": BASELINE_PR_TS})) + + config = WatchdogConfig(max_phase_visits=10, git_progress_check=True) + daemon = _daemon(store, config=config) + + # Patch _latest_transition to raise — if the batch path is working, this + # method should never be called during the patrol. + def _boom(*a, **kw): + raise AssertionError("_latest_transition should not be called when batch succeeds") + + monkeypatch.setattr(daemon, "_latest_transition", _boom) + + # Should complete without raising. + await daemon._check_subtask_progress(datetime.now(UTC)) + + # Baseline patrol with no prior head recorded counts as progress (no stall). + health = daemon._subtask_state.get((TASK_ID, SUBTASK_ID)) + assert health is not None + assert health.patrol_visits_without_progress == 0 + + +@pytest.mark.asyncio +async def test_progress_patrol_falls_back_when_batch_fails(tmp_path, monkeypatch): + """When batch_latest_transitions raises, the patrol falls back to + per-subtask _latest_transition calls so results are still correct.""" + store = _seed_store(tmp_path) + monkeypatch.setattr(subprocess, "run", _make_run({"head": "abc", "pr_updated": BASELINE_PR_TS})) + + config = WatchdogConfig(max_phase_visits=10, git_progress_check=True) + daemon = _daemon(store, config=config) + + # Simulate batch failure. + monkeypatch.setattr(store, "batch_latest_transitions", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("db error"))) + + # Should complete without raising — fallback per-subtask path fires. + await daemon._check_subtask_progress(datetime.now(UTC)) + + health = daemon._subtask_state.get((TASK_ID, SUBTASK_ID)) + assert health is not None # patrol still ran + + +# ── _check_blocked_subtasks uses task_rows batch (T-06) ───────────────────── + +def _owner_daemon_no_override(store, rest): + """Daemon with no owner_id override — owner must come from task row.""" + from codeband.agents.watchdog import WatchdogDaemon + + return WatchdogDaemon( + config=WatchdogConfig(), + rest_client=rest, + agent_id="agent-wd", + conductor_id="agent-cond", + activity=None, + state_store=store, + ) + + +@pytest.mark.asyncio +async def test_blocked_subtask_escalation_uses_task_row_batch(tmp_path): + """_check_blocked_subtasks resolves owner+room from the pre-fetched + _task_rows dict without per-subtask get_task calls.""" + from codeband.state import StateStore + + store = StateStore(tmp_path / "state" / "orchestration.db") + store.create_task(TASK_ID, "demo task", ROOM_ID, owner_id="owner-99") + _drive_to_blocked(store) + + rest = _mock_rest() + daemon = _owner_daemon_no_override(store, rest) + + # Patch get_task to raise — if the batch path works, it should never be called. + original_get_task = store.get_task + calls = {"n": 0} + + def _spy(task_id): + calls["n"] += 1 + return original_get_task(task_id) + + store.get_task = _spy + + await daemon._check_blocked_subtasks(datetime.now(UTC)) + + # Escalation fired (owner resolved from task_rows, not per-subtask get_task). + rest.agent_api_messages.create_agent_chat_message.assert_awaited_once() + # get_task should NOT have been called (batch path used task_rows instead). + assert calls["n"] == 0, f"get_task was called {calls['n']} times; expected 0" diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 7a60a20..8c1e861 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -9,6 +9,7 @@ import pytest from codeband.workspace.git import ( + WorkspaceError, branch_name, clone_bare, commit_and_push, @@ -16,6 +17,7 @@ list_worktrees, pin_gh_default_repo, prepare_task_branch, + refresh_worktree, remove_worktree, ) from codeband.workspace.init import ( @@ -240,6 +242,110 @@ def test_recreate_worktree_tolerates_fetch_failure_with_cached_base( assert wt.exists() assert (wt / "README.md").exists() + @staticmethod + def _default_branch(repo: Path) -> str: + return subprocess.run( + ["git", "-C", str(repo), "symbolic-ref", "--short", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + + @staticmethod + def _commit_upstream(repo: Path, name: str) -> None: + (repo / name).write_text("x") + subprocess.run( + ["git", "-C", str(repo), "add", "."], check=True, capture_output=True, + ) + subprocess.run( + ["git", "-C", str(repo), "commit", "-m", f"add {name}"], + check=True, capture_output=True, + ) + + def test_refresh_fast_forwards_stale_detached_worktree( + self, source_repo: Path, tmp_path: Path, + ): + """Finding 16: a reused planner worktree (create_worktree no-ops on + existing) must fast-forward to origin/ at session start.""" + branch = self._default_branch(source_repo) + bare = tmp_path / "bare.git" + clone_bare(str(source_repo), bare) + wt = tmp_path / "wt" / "planner-0" + create_worktree(bare, wt, branch, detach=True) + + self._commit_upstream(source_repo, "new-upstream-file.txt") + + # "Next session": create is a no-op, so the worktree is stale… + create_worktree(bare, wt, branch, detach=True) + assert not (wt / "new-upstream-file.txt").exists() + # …until the session-start refresh fast-forwards it. + refresh_worktree(bare, wt, branch, detach=True) + assert (wt / "new-upstream-file.txt").exists() + + def test_refresh_fast_forwards_branch_worktree( + self, source_repo: Path, tmp_path: Path, + ): + """The mergemaster shape: a branch (non-detached) worktree ffs and + stays on its branch.""" + branch = self._default_branch(source_repo) + bare = tmp_path / "bare.git" + clone_bare(str(source_repo), bare) + wt = tmp_path / "wt" / "mergemaster" + create_worktree(bare, wt, branch) + + self._commit_upstream(source_repo, "post-session-file.txt") + refresh_worktree(bare, wt, branch) + + assert (wt / "post-session-file.txt").exists() + head_branch = subprocess.run( + ["git", "-C", str(wt), "rev-parse", "--abbrev-ref", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + assert head_branch == branch # still ON the branch, not detached + + def test_refresh_fails_loud_on_dirty_worktree( + self, source_repo: Path, tmp_path: Path, + ): + """Local state that prevents the ff must fail loud — never silently + plan against a stale base.""" + branch = self._default_branch(source_repo) + bare = tmp_path / "bare.git" + clone_bare(str(source_repo), bare) + wt = tmp_path / "wt" / "planner-0" + create_worktree(bare, wt, branch, detach=True) + + (wt / "README.md").write_text("local edit") + self._commit_upstream(source_repo, "newer.txt") + + with pytest.raises(WorkspaceError, match="local changes"): + refresh_worktree(bare, wt, branch, detach=True) + # The dirty state is left intact for the human to inspect. + assert (wt / "README.md").read_text() == "local edit" + assert not (wt / "newer.txt").exists() + + def test_refresh_fails_loud_on_diverged_branch( + self, source_repo: Path, tmp_path: Path, + ): + """A branch worktree whose local branch diverged cannot ff — loud.""" + branch = self._default_branch(source_repo) + bare = tmp_path / "bare.git" + clone_bare(str(source_repo), bare) + wt = tmp_path / "wt" / "mergemaster" + create_worktree(bare, wt, branch) + + # Local commit on the branch… + (wt / "local.txt").write_text("local") + subprocess.run( + ["git", "-C", str(wt), "add", "."], check=True, capture_output=True, + ) + subprocess.run( + ["git", "-C", str(wt), "commit", "-m", "local commit"], + check=True, capture_output=True, + ) + # …and a different upstream commit: diverged. + self._commit_upstream(source_repo, "upstream.txt") + + with pytest.raises(WorkspaceError, match="fast-forward"): + refresh_worktree(bare, wt, branch) + def test_commit_no_changes(self, source_repo: Path, tmp_path: Path): """Commit with no changes is a no-op.""" bare = tmp_path / "bare.git" @@ -487,6 +593,26 @@ def test_code_reviewer_workspace(self, source_repo: Path, tmp_path: Path): assert (layout.reviewer_workspace / ".claude" / "settings.json").exists() assert layout.notes_dir is None + def test_verifier_workspace(self, source_repo: Path, tmp_path: Path): + """Verifier gets isolated scratch space but no repo clone — a clean + mirror of the code reviewer (gh-only, repo-less).""" + from codeband.config import CodebandConfig, RepoConfig, WorkspaceConfig + + ws_root = tmp_path / "workspace" + config = CodebandConfig( + repo=RepoConfig(url=str(source_repo), branch="main"), + workspace=WorkspaceConfig(path=str(ws_root)), + ) + layout = initialize_agent_workspace(config, "verifier-codex-0", "verifier") + + assert not layout.bare_repo.exists() + assert layout.worktree is None + assert layout.verifier_workspace is not None + assert layout.verifier_workspace.exists() + assert (layout.verifier_workspace / ".claude" / "settings.json").exists() + assert layout.reviewer_workspace is None + assert layout.notes_dir is None + def test_mergemaster_workspace(self, source_repo: Path, tmp_path: Path): """Mergemaster gets a worktree on the main branch.""" from codeband.config import CodebandConfig, RepoConfig, WorkspaceConfig @@ -652,6 +778,35 @@ def test_layout_paths(self, sample_config): assert layout.mergemaster_worktree is not None assert layout.notes_dir.name == "notes" assert layout.state_dir.name == "state" + # sample_config pins verifiers inert → no verifier scratch slots. + assert layout.verifier_scratch == {} + + def test_layout_includes_verifier_scratch_when_active(self, tmp_path: Path): + """An active verifier pool gets scratch dirs keyed by worker id — + mirroring the reviewer scratch layout.""" + from codeband.config import ( + AgentsConfig, + CodebandConfig, + PoolEntry, + RepoConfig, + VerifiersConfig, + WorkspaceConfig, + ) + + config = CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git", branch="main"), + agents=AgentsConfig( + verifiers=VerifiersConfig( + claude_sdk=PoolEntry(count=1), codex=PoolEntry(count=1), + ), + ), + workspace=WorkspaceConfig(path=str(tmp_path / "workspace")), + ) + layout = resolve_layout(config) + assert "verifier-claude_sdk-0" in layout.verifier_scratch + assert "verifier-codex-0" in layout.verifier_scratch + # Scratch lives under the shared scratch dir, like the reviewer's. + assert layout.verifier_scratch["verifier-codex-0"].parent.name == "scratch" def test_all_worktrees_includes_mergemaster(self, sample_config): """all_worktrees dict includes coders, planners, and mergemaster.""" diff --git a/tests/test_workspace_resolution.py b/tests/test_workspace_resolution.py new file mode 100644 index 0000000..8b4a36b --- /dev/null +++ b/tests/test_workspace_resolution.py @@ -0,0 +1,83 @@ +"""The ONE workspace-path resolution rule — runner / cb-phase / registration parity. + +Two implementations of "where does the workspace live" is how the container +gap happened: the runner honored ``$WORKSPACE`` (compose sets it to +``/workspace``, the shared volume), while ``cb-phase`` resolved relative +``workspace.path`` against the project dir — so agents looked for the +DB/pointer at ``/app/config/.codeband/state/`` instead of +``/workspace/state/``. Everything now routes through +``config.resolve_workspace_path``; this matrix pins that every consumer +agrees for set/unset × relative/absolute. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from codeband.cli import handoff +from codeband.config import ( + CodebandConfig, + RepoConfig, + WorkspaceConfig, + resolve_workspace_path, +) +from codeband.orchestration.runner import _resolve_workspace_config +from codeband.state.registration import resolve_state_dir + + +def _make_config(workspace_path: str) -> CodebandConfig: + return CodebandConfig( + repo=RepoConfig(url="https://github.com/example/repo.git"), + workspace=WorkspaceConfig(path=workspace_path), + ) + + +def _write_yaml(project_dir: Path, config: CodebandConfig) -> None: + (project_dir / "codeband.yaml").write_text( + yaml.safe_dump(config.model_dump(mode="json")), encoding="utf-8", + ) + + +@pytest.mark.parametrize("workspace_env_set", [False, True]) +@pytest.mark.parametrize("relative", [True, False]) +def test_all_consumers_agree_on_workspace_path( + tmp_path, monkeypatch, workspace_env_set, relative, +): + """set/unset $WORKSPACE × relative/absolute workspace.path — cb-phase's + store, the runner's resolved config and registration's state dir must all + name the same workspace.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + env_root = tmp_path / "shared-volume" + + if workspace_env_set: + monkeypatch.setenv("WORKSPACE", str(env_root)) + else: + monkeypatch.delenv("WORKSPACE", raising=False) + + if relative: + ws_value = ".codeband" + expected = (env_root if workspace_env_set else project_dir) / ".codeband" + else: + ws_value = str(tmp_path / "abs-workspace") + expected = tmp_path / "abs-workspace" # $WORKSPACE never rebases absolute + + config = _make_config(ws_value) + _write_yaml(project_dir, config) + + # The shared rule itself. + assert resolve_workspace_path(config, project_dir) == expected + + # Runner: the resolved config the whole fleet runs with. + resolved = _resolve_workspace_config(config, project_dir) + assert Path(resolved.workspace.path) == expected + + # cb-phase / cb approve: the StateStore both gate legs read and write. + store = handoff._resolve_store(project_dir) + assert Path(store.db_path) == expected / "state" / "orchestration.db" + + # Registration / kickoff / doctor: the state dir holding the room pointer. + assert resolve_state_dir(config, project_dir) == expected / "state" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6f0ea87 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1077 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "band-sdk" +version = "0.2.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "phoenix-channels-python-client" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "thenvoi-client-rest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/c9/fd5ea3e00868ec916474bbf2c95cbc205ed32267ad29c85ebc0b50db9c39/band_sdk-0.2.11.tar.gz", hash = "sha256:4ab416088aaf1086b0f6c7f24c980a2084a0bd5d5a7880ba235f0b375c94c49a", size = 354833, upload-time = "2026-05-28T14:14:07.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/20/6497dfdfbf45795ffeaee51474c0a000a894144957ea608c13d52bda87b2/band_sdk-0.2.11-py3-none-any.whl", hash = "sha256:dbf0ca3623a1d7b15137262fa70d2702cf311a71e8b98d1067ef760d3f7310df", size = 373028, upload-time = "2026-05-28T14:14:06.178Z" }, +] + +[package.optional-dependencies] +claude-sdk = [ + { name = "claude-agent-sdk" }, +] +codex = [ + { name = "websockets" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.2.87" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/dc/e2afd59a1dd6484b6500245fa2331a0d8c0b68e6c180bc29d8ce9540f38a/claude_agent_sdk-0.2.87.tar.gz", hash = "sha256:56f02a49a97f7be37e0cd7323494d1c09e52fb0db7ab94f53bba8a230bb4bd0e", size = 252063, upload-time = "2026-05-23T04:19:25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/4e/b83c4c6ec1e0b63e9d4d58ba9a5abfd9936c55b8ee4c06b88f5e93bdfd70/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_arm64.whl", hash = "sha256:52204a9609dec3aa96032afd48c07d72e05d13311faf614978f17b61326e6e31", size = 63037960, upload-time = "2026-05-23T04:19:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/13/d7/5fb02260c5b95c66e108c35e046d4d66011921251f7896274b6b21594f14/claude_agent_sdk-0.2.87-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1713e34e50b830ecac54386d39af14e3a2775f833f1ef715eb53566eaa1b6325", size = 65095745, upload-time = "2026-05-23T04:19:32.533Z" }, + { url = "https://files.pythonhosted.org/packages/1d/84/1061f6580bbbc78de629467abf051cdbbabe71b982297b401e3fde65c7e0/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e9e23119d2a02ad1ea1a2707214db98f5baf2c8809577186629843ddfcb8ec18", size = 72725120, upload-time = "2026-05-23T04:19:36.539Z" }, + { url = "https://files.pythonhosted.org/packages/04/50/449f5044d76d9de18cf6a9f4b1c9386a74f41b4e2da5312df245d9dd23ef/claude_agent_sdk-0.2.87-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:5ac525d9ae3481296df5639d005e12ce2b6b0427426991f35da64db30be25c6e", size = 72875504, upload-time = "2026-05-23T04:19:40.839Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/3f9d7c491d5a98138d293192b31cc9ed792d3552b3a7e276163d7fe2d43a/claude_agent_sdk-0.2.87-py3-none-win_amd64.whl", hash = "sha256:f34973669a1efaeb1543e7b22d7b22feefd8af2fae3adfd39181635077dae432", size = 73514880, upload-time = "2026-05-23T04:19:44.65Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "codeband" +version = "0.1.1" +source = { editable = "." } +dependencies = [ + { name = "band-sdk", extra = ["claude-sdk", "codex"] }, + { name = "click" }, + { name = "prompt-toolkit" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "thenvoi-client-rest" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "band-sdk", extras = ["codex", "claude-sdk"], specifier = ">=0.2.8,<0.3" }, + { name = "click", specifier = ">=8.2,<9" }, + { name = "prompt-toolkit", specifier = ">=3.0" }, + { name = "pydantic", specifier = ">=2.0,<3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "python-dotenv", specifier = ">=1.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, + { name = "thenvoi-client-rest", specifier = ">=0.0.7,<0.1" }, +] +provides-extras = ["dev"] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "phoenix-channels-python-client" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/53/dbabc21137c7f9657d5f02ac020aa1fab97747a409b393dca9a1ba752945/phoenix_channels_python_client-0.2.1.tar.gz", hash = "sha256:a5ff329d4ed0ce850c3568e39ec29beaf82540d18016d4934837374a612151a1", size = 29245, upload-time = "2026-04-12T12:46:38.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/06/1a37e48ead532b123ed71c5a5e4fd78d4fc93c11622887f41e18d14fedb6/phoenix_channels_python_client-0.2.1-py3-none-any.whl", hash = "sha256:24606551dfbcae35f3052f1573091c25b4da1deded4d6f62d171ff2820ed8ad4", size = 23930, upload-time = "2026-04-12T12:46:39.593Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + +[[package]] +name = "thenvoi-client-rest" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/b8/077727592881e2bce52d9cd4198ddff4d2109eb30a456fe29bdd84eacba0/thenvoi_client_rest-0.0.7.tar.gz", hash = "sha256:1f793a96449556c304bbecce79db0ae2d8044e32b6c137e50c608d09cd97ddb3", size = 95101, upload-time = "2026-04-12T13:22:44.949Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/f4/61b5a86a1f3bbaeb09f46a01d019a2c0df0f6bf57494745ee670eee9e68b/thenvoi_client_rest-0.0.7-py3-none-any.whl", hash = "sha256:60b572ed3b64d2acaf447e50f36ea064506c14ca6c7d6ca9b3b197ccbc68c70f", size = 233953, upload-time = "2026-04-12T13:22:43.351Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]