From 06e6825a909b383f7ae8d0e209cc52cff8a3501c Mon Sep 17 00:00:00 2001 From: Aura - jc <67582323+Catafal@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:06:56 +0200 Subject: [PATCH] feat: session observability & loop control (Phase 16) Implements PRD #3: session radar (bach sessions list/adopt/resume/watch), Codex sidecar parity + explicit hook installers (supersedes ADR-007 exception, ADR-015), observer watcher + judge with conservative authority enforced at the status write path, and loop control (stop-hook verification gating + bounded AFK runner, ADR-016). Closes #3 Co-Authored-By: Claude Fable 5 --- AGENTS.md | 11 + CLAUDE.md | 10 + README.md | 72 ++ docs/ARCHITECTURE.md | 143 +++ .../015-session-observability-codex-parity.md | 182 ++++ docs/adr/016-loop-control.md | 211 ++++ docs/how-to/wire-up-session-hook.md | 246 +++-- docs/internal/progress.txt | 17 + src/bach/cli/afk_cmd.py | 178 ++++ src/bach/cli/app.py | 87 +- src/bach/cli/hooks_cmd.py | 181 ++++ src/bach/cli/internal_sessions.py | 235 +++++ src/bach/cli/sessions.py | 334 +++++++ src/bach/config/settings.py | 139 ++- src/bach/domain/models.py | 137 +++ src/bach/repl/app.py | 185 ++-- src/bach/repl/help_content.py | 180 ++-- src/bach/repl/sessions_view.py | 138 +++ src/bach/runtimes/session_discovery.py | 415 ++++++++ src/bach/runtimes/transcript.py | 440 +++++++++ src/bach/services/afk_runner.py | 474 +++++++++ src/bach/services/hooks_service.py | 280 ++++++ src/bach/services/judge_service.py | 271 +++++ src/bach/services/loop_gate.py | 404 ++++++++ src/bach/services/session_tracker.py | 145 ++- src/bach/services/session_watcher.py | 594 +++++++++++ src/bach/services/sessions_service.py | 522 ++++++++++ src/bach/services/status_service.py | 101 +- src/bach/services/task_service.py | 58 +- tests/unit/test_afk_runner.py | 761 ++++++++++++++ tests/unit/test_docs_d2.py | 234 +++++ tests/unit/test_hooks_service.py | 348 +++++++ tests/unit/test_judge_service.py | 637 ++++++++++++ tests/unit/test_loop_gate.py | 682 +++++++++++++ tests/unit/test_phase16_docs.py | 242 +++++ tests/unit/test_session_discovery.py | 925 ++++++++++++++++++ tests/unit/test_session_models.py | 270 +++++ tests/unit/test_session_spool.py | 145 +++ tests/unit/test_session_tracker.py | 56 ++ tests/unit/test_session_watcher.py | 791 +++++++++++++++ tests/unit/test_sessions_service.py | 720 ++++++++++++++ tests/unit/test_settings.py | 218 +++++ tests/unit/test_status_service.py | 180 +++- tests/unit/test_transcript.py | 587 +++++++++++ 44 files changed, 12808 insertions(+), 378 deletions(-) create mode 100644 docs/adr/015-session-observability-codex-parity.md create mode 100644 docs/adr/016-loop-control.md create mode 100644 src/bach/cli/afk_cmd.py create mode 100644 src/bach/cli/hooks_cmd.py create mode 100644 src/bach/cli/internal_sessions.py create mode 100644 src/bach/cli/sessions.py create mode 100644 src/bach/repl/sessions_view.py create mode 100644 src/bach/runtimes/session_discovery.py create mode 100644 src/bach/runtimes/transcript.py create mode 100644 src/bach/services/afk_runner.py create mode 100644 src/bach/services/hooks_service.py create mode 100644 src/bach/services/judge_service.py create mode 100644 src/bach/services/loop_gate.py create mode 100644 src/bach/services/session_watcher.py create mode 100644 src/bach/services/sessions_service.py create mode 100644 tests/unit/test_afk_runner.py create mode 100644 tests/unit/test_docs_d2.py create mode 100644 tests/unit/test_hooks_service.py create mode 100644 tests/unit/test_judge_service.py create mode 100644 tests/unit/test_loop_gate.py create mode 100644 tests/unit/test_phase16_docs.py create mode 100644 tests/unit/test_session_discovery.py create mode 100644 tests/unit/test_session_models.py create mode 100644 tests/unit/test_session_spool.py create mode 100644 tests/unit/test_session_watcher.py create mode 100644 tests/unit/test_sessions_service.py create mode 100644 tests/unit/test_transcript.py diff --git a/AGENTS.md b/AGENTS.md index 60b3d26..4d82874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,17 @@ Follow the same constraints as `CLAUDE.md`. The short version: - Update `docs/internal/progress.txt` when finishing implementation steps. - Update `docs/internal/lessons.md` when a mistake reveals a durable rule. +## Phase 16 Commands (Session Observability & Loop Control) + +- `bach sessions list [--project] [--runtime] [--state] [--json]` — discover running/idle/ended sessions across all runtimes +- `bach sessions adopt [--force]` — link a discovered session ID to a Bach task artifact +- `bach sessions resume ` — resume a session in iTerm (prints fallback command if iTerm unavailable) +- `bach sessions watch ` — run the foreground session observer (polls transcript, calls judge, applies status via observer authority) +- `bach hooks install claude-code [--project-dir PATH]` — idempotent Claude Code hook install (SessionEnd + Stop + PostToolUse → `bach internal hook-event` / `bach internal session-stop`) +- `bach hooks install codex [--codex-home PATH]` — idempotent Codex hook install (sessionStop → `bach internal hook-event`) +- `bach hooks status [--project-dir PATH]` — show which runtimes have Bach hooks installed +- `bach afk run ` — headless multi-turn task runner; claims `afk_queue` → `executing`, drives turns, never sets `done` + # GitNexus — Code Intelligence diff --git a/CLAUDE.md b/CLAUDE.md index 35c097a..373c1aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,16 @@ Bach is a CLI-first personal agentic development session launcher that turns dai - `uv run bach task add` - Create a Mode 1 task artifact - `uv run bach task launch ` - Launch a task session +### Phase 16 — Session Observability & Loop Control +- `uv run bach sessions list [--project] [--runtime] [--state] [--json]` - List discovered runtime sessions joined with Bach tasks +- `uv run bach sessions adopt [--force]` - Link a discovered session to a task artifact +- `uv run bach sessions resume ` - Resume a session via iTerm (fallback command printed on failure) +- `uv run bach sessions watch ` - Run the foreground session watcher (observer mode) +- `uv run bach hooks install claude-code [--project-dir PATH]` - Idempotent install of Claude Code hooks (SessionEnd/Stop/PostToolUse) +- `uv run bach hooks install codex [--codex-home PATH]` - Idempotent install of Codex hooks (sessionStop) +- `uv run bach hooks status [--project-dir PATH]` - Report which runtimes have Bach hooks installed +- `uv run bach afk run ` - Run a task headlessly (AFK mode) from `afk_queue` + ## File References - `docs/internal/DISCOVERY.md` - real goal and scope - `docs/internal/PRD.md` - requirements diff --git a/README.md b/README.md index d5b0824..1017721 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,78 @@ every prompt; drag a folder from Finder into any path prompt. event auto-sets the task status to `paused` and appends an audit log entry. - **Fallback safety** — if `osascript` fails, Bach prints the exact shell command to run manually. +- **Session radar & observer** — discover live/idle/ended agent sessions, + link them to tasks, and let the observer automatically suggest or apply + status transitions based on transcript analysis (see below). +- **AFK loop control** — headless multi-turn task runner with loop-gate + verification and judge-driven status management. + +--- + +## Session radar & observer + +Bach can discover running and ended Claude Code and Codex sessions across +your machine, join them with your task artifacts, and let an observer +process apply status transitions automatically. + +```bash +# See all current sessions with task linkage +bach sessions list + +# Filter by state +bach sessions list --state running + +# Link an orphan session to a task artifact +bach sessions adopt t47 + +# Resume a session in iTerm +bach sessions resume + +# Run the foreground observer for a task +bach sessions watch t47 +``` + +Example output from `bach sessions list`: + +| Session ID | Runtime | State | Task | Project | Preview | +|--------------|--------------|---------|------|---------|--------------------------| +| `a3f9c2e1...` | claude-code | running | t47 | myapp | "refactoring the auth…" | +| `b81d04f7...` | codex | idle | t12 | backend | "added retry logic" | +| `cc4e9101...` | claude-code | ended | — | — | — | + +### Observer authority + +The observer watches a session transcript, calls the LLM judge on a +schedule, and may apply status transitions with **conservative authority**: + +| Allowed move | Trigger | +|-----------------------|---------------------------------------------| +| `executing → qa` | Judge confidence ≥ threshold, task complete | +| `* → hitl_queue` | Judge says `needs_human=true` | + +The observer **never** sets `done`, never moves backward, and never +touches pre-execution stages (`inbox`, `prd`, `kanban_issues`). +All observer actions are appended to the artifact log. + +Set `observer_moves: false` in `~/.bach/config.yaml` to make the +observer log-only (suggestions only, no writes). + +### Hook installation + +Wire up Bach hooks for automatic session tracking: + +```bash +# Claude Code: installs SessionEnd + Stop + PostToolUse hooks +bach hooks install claude-code + +# Codex: installs sessionStop hook in ~/.codex/hooks.json +bach hooks install codex + +# Check what's installed +bach hooks status +``` + +See `docs/how-to/wire-up-session-hook.md` for full setup instructions. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 002b48d..ed67b4d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -220,3 +220,146 @@ Reference: `reference/stable-task-ids.md`. - **Phase 11** — complete (Pocock kanban `/board` view; sticky third view alongside today/all; tN-only refs in board; oldest-first sort; new `src/bach/repl/board.py` module). ADR-009. 322 tests. - **Phase 12** — in progress. macOS TCC EPERM robustness in the daily-index walker (`_skipped_projects` tracker, `_record_skip`, `get_skipped_projects`). New `/workflow` REPL command (Pocock cheatsheet + Obsidian deep-link). Stub `_format_skip_banner` pending UX implementation. ADR-010. 354 tests. - **Phase 13** — in progress. Two new pre-PRD workflow stages: `research` (read/gather) + `prototype` (throwaway spike). Wired across the full stack: `VALID_STATUSES` + enum validator, `SUGGESTED_NEXT_STEPS`, `ThemePalette` color fields (7 themes), board columns (7 → 9 WIP), narrow-terminal threshold (90 → 116), `_count_statuses` canonical order, `/research` + `/prototype` REPL handlers, `WORKFLOW_STAGES` (9 → 11). ADR-011. 354 tests. +- **Phase 16** — complete (session observability + loop control). ADR-015, ADR-016. 812+ tests. See below for component architecture. + +## Phase 16 — Session Observability and Loop Control Components + +Phase 16 introduces a passive observation plane and an active continuation +plane that operate alongside the existing hook-based tracking from Phase 8. +The two planes share the `status_service` write path but are subject to +different authority rules. + +### Observation plane + +```text +Claude/Codex session transcript (.jsonl) + -> runtimes/session_discovery.py discover_sessions() + -> DiscoveredSession (runtime, session_id, transcript_path, + project_dir, liveness, last_activity) + + -> runtimes/transcript.py follow_events() / read_tail_events() / parse_line() + -> SessionEvent (kind, timestamp, text[truncated], raw_kind) + -> Never raises; unrecognized records → kind=unknown + + -> services/judge_service.py judge_session() + -> Bounded prompt window (last 50 events, ≤ 8 000 chars) + -> Fixed JSON schema: {summary, suggested_status, needs_human, confidence} + -> Returns JudgeVerdict | None (None = no-op) + + -> services/session_watcher.py watch_session() + -> Foreground run-and-exit loop (no threads, no asyncio) + -> follow_events + drain spool on each poll cycle + -> Judge fires ≤ once per judge_interval_seconds, always on stop/end + -> Applies verdict via set_task_status(source="observer") + -> Exits when end event seen OR stop spool event received +``` + +The observer writes status via `source="observer"`, which is subject to +a hard transition matrix in `status_service.set_task_status`: + +``` +executing → qa (hand-off) +{executing, qa, afk_queue} → hitl_queue (escalation) +``` + +Any other observer-source write raises `InvalidStatusError`. + +### IPC: event spool + +Hook commands are fast-exit subprocesses; the session watcher is a +long-running foreground process. They communicate via a per-session JSONL +spool: + +```text +~/.bach/sessions/.events.jsonl + +bach internal hook-event → append_hook_event(session_id, payload) +bach internal session-stop → spool stop event; evaluate_stop() handles gate +session_watcher poll cycle → read_spool_events(session_id, from_offset=N) +``` + +The spool is write-once append; the watcher tracks a byte offset to avoid +re-reading events. + +### Session discovery + +`runtimes/session_discovery.py` is the ONLY module that knows on-disk +session layout for both runtimes: + +- **Claude Code:** `~/.claude/projects//.jsonl` + Slug is the project absolute path with `/` → `-`. +- **Codex:** `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl` + Enriched from `~/.codex/session_index.jsonl` when present. + +Liveness is classified as `running` / `idle` / `ended` by comparing +the transcript's mtime against injected thresholds (from `BachConfig`). +A `process_probe` callable (default: `ps -axo pid,command`) is injected +so tests are fully hermetic. + +`services/sessions_service.py` joins `DiscoveredSession` records with +Bach task artifacts (`SessionRow`) and exposes `list_sessions`, +`adopt_session`, `resume_session`. + +### Continuation plane + +```text +claude internal hook Stop + -> bach internal session-stop (reads JSON from stdin) + -> loop_gate.evaluate_stop(session_id, sessions_dir, store, …) + -> sidecar lookup → artifact frontmatter + -> loop_gate.enabled check (opt-in — default: never gate) + -> Mode 1 check: post_grill.readiness + next_action + -> cap check: continuation_count >= max_continuations → hitl_queue + -> run verify_command (shell, cwd=project, timeout) + -> pass → exit 0; fail → print block message to stderr + exit 2 + +bach afk run + -> cli/afk_cmd.py + -> afk_runner.run_afk_task(artifact_path, …) + -> claim: afk_queue → executing, source="afk" + -> turn loop (≤ afk_max_turns, ≤ afk_time_budget_minutes): + run turn → gate? → qa + stop + judge needs_human? → hitl_queue + stop + else → continue + -> bounds exhausted → hitl_queue + -> NEVER sets done +``` + +The AFK runner uses `source="afk"` (human-authority path in +`status_service`) but self-restricts to `{executing, qa, hitl_queue}` +via a code-layer guard inside `afk_runner._set_status`. + +### New CLI surfaces (Phase 16) + +| Command | File | Description | +|---|---|---| +| `bach sessions list` | `cli/sessions.py` | Table of live/recent sessions | +| `bach sessions adopt ` | `cli/sessions.py` | Link session to artifact | +| `bach sessions resume ` | `cli/sessions.py` | Re-enter a session | +| `bach sessions watch ` | `cli/sessions.py` | Foreground watcher | +| `bach hooks install ` | `cli/hooks_cmd.py` | Idempotent hook installer | +| `bach hooks status` | `cli/hooks_cmd.py` | Show installed hooks | +| `bach internal hook-event` | `cli/internal_sessions.py` | Fast spool append | +| `bach internal session-watch ` | `cli/internal_sessions.py` | Launch watcher | +| `bach internal session-stop` | `cli/internal_sessions.py` | Gate check for Stop hook | +| `bach afk run ` | `cli/afk_cmd.py` | Headless multi-turn driver | + +### New config fields (Phase 16) + +All fields live in `~/.bach/config.yaml` and are loaded by +`config/settings.py` with lenient coercion (invalid value → WARNING + +default): + +| Field | Default | Description | +|---|---|---| +| `watch_on_launch` | `false` | Spawn watcher automatically on task launch | +| `observer_moves` | `true` | `false` → judge is log-only, never writes status | +| `judge_confidence_threshold` | `0.7` | Minimum confidence for observer status writes | +| `judge_interval_seconds` | `300` | Min seconds between mid-session judge calls | +| `liveness_running_seconds` | `60` | Transcript mtime age threshold for "running" | +| `liveness_idle_minutes` | `5` | Process-alive + stale threshold for "idle" | +| `afk_max_turns` | `10` | Hard turn cap for AFK runner | +| `afk_time_budget_minutes` | `60` | Hard time cap for AFK runner | + +How-to: `how-to/wire-up-session-hook.md`. ADRs: `adr/015-session-observability-codex-parity.md`, +`adr/016-loop-control.md`. diff --git a/docs/adr/015-session-observability-codex-parity.md b/docs/adr/015-session-observability-codex-parity.md new file mode 100644 index 0000000..e19e0da --- /dev/null +++ b/docs/adr/015-session-observability-codex-parity.md @@ -0,0 +1,182 @@ +# ADR-015 — Session Observability + Codex Parity + +**Status:** Accepted +**Date:** 2026-06-10 +**Supersedes:** ADR-007 (Codex exception — "no sidecar, no hook"). +**Extends:** ADR-007 (SessionEnd hook + centralized status writes). + +## Context + +ADR-007 shipped session lifecycle tracking for Claude Code only. The +"Codex launches: no sidecar, no hook — manual status only" consequence +was an explicit punt: Codex had no documented hook mechanism at the time. + +Phase 16 closes that gap by: + +1. Generalizing the sidecar format to carry a `runtime` field so the + watcher knows which transcript format to parse. +2. Removing the Codex guard in `task_service.launch_task` so both + runtimes get sidecars when `runtime_session_id` is present. +3. Adding a per-session event spool as a lightweight IPC channel between + short-lived hook commands and the long-running session watcher. +4. Introducing a run-and-exit session watcher (`session_watcher.py`) that + tails the transcript and acts on spool events. + +Two separate architecture questions arose during design: + +- **What is the observation plane allowed to do?** Transcripts contain + rich signal but they are also implementation details of the runtime. + Hook payloads (SessionEnd, Stop) are the intended integration surface. +- **How do hook payloads reach the watcher?** The watcher runs as a + foreground process (no daemon); hook commands run and exit in + milliseconds. They cannot share in-process state. + +## Decision + +### 1. Sidecar parity: Codex now gets sidecars + +`task_service.launch_task` previously skipped `record_session_start` for +Codex sessions (ADR-007 consequence). The guard is removed. Any session +with a `runtime_session_id` gets a sidecar written to +`~/.bach/sessions/.yaml`. + +`SessionSidecar` gains a `runtime` field (string, backward-compatible: +`None` for sidecars written by pre-Phase-16 code). The watcher uses +`runtime` to select the correct transcript parser. + +### 2. Event spool — artifact-as-IPC + +Hook commands (`bach internal hook-event`, `bach internal session-stop`) +are fast-exit subprocesses. The session watcher runs in the foreground of +a separate process. There is no shared in-process state. + +The chosen IPC mechanism is a per-session JSONL spool file: + +``` +~/.bach/sessions/.events.jsonl +``` + +- Hook commands call `append_hook_event(session_id, payload, + sessions_dir=…)` — one JSON line, `O(1)` append, exit 0. +- The watcher calls `read_spool_events(session_id, sessions_dir=…, + from_offset=N)` on each poll cycle. The offset prevents re-reading + lines that were already processed. + +The spool is a one-way channel: hook producers write, the watcher +consumes. Malformed lines are silently skipped — a partial last write +from a killed hook command must not block the watcher. + +This design keeps hook commands fast (< 50 ms) so Claude Code's hook +timeout budget is never threatened. + +### 3. Watcher-per-session run-and-exit + +`services/session_watcher.py` implements a **foreground run-and-exit +loop** — no threads, no asyncio. One watcher process per session. It is +launched by `bach internal session-watch ` (owned by S3/L1). + +The loop alternates between: +- Consuming transcript events via `follow_events` (synchronous generator + that polls the JSONL file and yields new `SessionEvent` records). +- Draining the spool via `read_spool_events`. + +Exit conditions (any one is sufficient): +- A `SessionEventKind.end` event appears in the transcript. +- A `stop` or `session_end` spool event arrives. +- The `follow_events` generator is exhausted (transcript file ended + + process gone). + +This run-and-exit model was chosen over a daemon because: +- A persistent daemon requires OS-level supervision, restart logic, and + a shutdown protocol. The complexity cost exceeds the benefit for a + single-developer tool. +- Hook processes already give us reliable "session started / ended" + signals. The watcher only needs to live as long as the session. + +### 4. Observer authority enforced at the write path + +The observation plane (transcript events, judge verdicts) is NOT allowed +to set arbitrary statuses. The permitted moves are defined once, inside +`status_service.set_task_status`: + +``` +source="observer" may ONLY perform: + executing → qa (hand-off after session) + {executing, qa, afk_queue} → hitl_queue (escalation) +``` + +All other observer-source writes raise `InvalidStatusError`. This is +enforced at `set_task_status` — not by the watcher, not by the judge. +The invariant holds regardless of which caller supplies `source="observer"`. + +Rationale: transcript content is an observation, not a command. The agent +may have written "I'm done" in a tool call output — that's not a reliable +signal for marking a task `done`. Conservative automation is the ADR-007 +principle re-applied to the new observation plane. + +### 5. Control plane vs observation plane boundary + +| Signal source | Channel | Allowed to gate / set status? | +|---|---|---| +| SessionEnd hook payload | spool event | Yes — hook fires reliably at known lifecycle points | +| Stop hook payload | spool event | Yes — same lifecycle guarantee | +| Transcript content (parsed) | follow_events | No — feeds judge context only | +| Judge verdict | in-process | Only via observer authority (executing→qa, escalation) | + +This boundary is a PRD non-negotiable: "Parsed transcript content may +only feed previews and judge context — never gate." + +## Consequences + +- **Codex sessions now get sidecars.** Downstream hook consumers that call + `consume_session_end` will now fire for Codex sessions too. Callers that + previously never saw a Codex sidecar must handle this. + +- **New `~/.bach/sessions/*.events.jsonl` files.** The GC logic in + `gc_stale_sidecars` targets `*.yaml`; a separate GC pass for spool + files should be added in a future phase. For now they accumulate but + are small (one line per hook event). + +- **Watcher must be spawned explicitly.** `task_service` can optionally + spawn `bach internal session-watch` via `subprocess.Popen` (detached) + when `watch_on_launch: true` is set in `~/.bach/config.yaml`. This is + opt-in; existing users see no behavior change. + +- **Observer authority is a hard firewall.** Any code path that passes + `source="observer"` to `set_task_status` is bound by the observer + transition matrix. This is a security boundary, not a convention. + +- **`runtimes/transcript.py` is the only transcript parser.** All code + that needs to understand what an agent said goes through this module. + It never raises on bad input — unknown record types yield + `kind=SessionEventKind.unknown` with `raw_kind` preserved for + diagnostics. + +## Alternatives rejected + +- **Daemon process for the watcher.** Requires `launchd`/`systemd` + registration, restart logic, and a graceful shutdown API. Complexity + far exceeds the value for a single-developer CLI tool. + +- **Shared SQLite database as IPC.** Durable, queryable, transactional. + Rejected because it introduces a new hard dependency and requires a + migration story. The JSONL spool is append-only, requires no schema, + and can be read with standard file I/O. + +- **Let transcript content set status directly.** Rejected — the + original ADR-007 principle of "conservative automation" applies with + even more force to transcript observations than to hook signals. Hooks + are designed integration points; transcript content is an + implementation detail that can change format at any release. + +## References + +- ADR-007: `adr/007-session-end-hook-status-tracking.md` +- Implementation: `src/bach/services/session_watcher.py` +- Implementation: `src/bach/services/session_tracker.py` (spool helpers) +- Implementation: `src/bach/runtimes/transcript.py` +- Implementation: `src/bach/runtimes/session_discovery.py` +- Implementation: `src/bach/services/status_service.py` (observer authority) +- Implementation: `src/bach/services/judge_service.py` +- Config: `src/bach/config/settings.py` (`watch_on_launch`, `observer_moves`, + `judge_confidence_threshold`, `judge_interval_seconds`) diff --git a/docs/adr/016-loop-control.md b/docs/adr/016-loop-control.md new file mode 100644 index 0000000..175769d --- /dev/null +++ b/docs/adr/016-loop-control.md @@ -0,0 +1,211 @@ +# ADR-016 — Loop Control: Gate, AFK Runner, Authority Table + +**Status:** Accepted +**Date:** 2026-06-10 +**Extends:** ADR-007 (centralized status writes), ADR-012 (session modes), + ADR-015 (session observability + observer authority). + +## Context + +Phase 16 introduced two autonomous continuation mechanisms that did not +exist in earlier phases: + +1. **AFK runner** (`afk_runner.py`) — drives a task through multiple + headless turns without human attention between turns. +2. **Loop gate** (`loop_gate.py`) — intercepts the Claude Code Stop hook + and can block the session from ending if a verify command fails. + +Both touch status transitions and session lifecycle in ways that required +careful authority separation: the AFK runner operates on behalf of the +user (human authority), while the stop-hook gate reacts to automated +signals (similar to observer authority). Without a clear table of what +each source is allowed to do, the conservative automation guarantee from +ADR-007 would silently erode. + +Additionally, Mode 1 — Bach's no-implementation-without-approval contract +— needed explicit protection in both systems: + +- The AFK runner should only run tasks that the user has placed in + `afk_queue`. It must not run tasks still in grilling or planning stages. +- The loop gate must not intercept Stop hook calls from grilling sessions + where the user hasn't approved execution. + +## Decision + +### 1. Loop gate is opt-in via frontmatter block + +The gate is **never armed by default**. A task must carry this block in +its frontmatter to participate: + +```yaml +loop_gate: + enabled: true + verify_command: "make gate" # shell command, cwd=project path + max_continuations: 3 # default 3 — gate releases at cap +``` + +If `loop_gate` is absent or `enabled: false`, `evaluate_stop` returns +`StopDecision(block=False)` immediately. No subprocess is run. + +This guarantees that existing tasks and all grilling sessions are +completely unaffected by the gate machinery. + +### 2. Mode 1 preservation — gate checks execution approval + +Even when `loop_gate.enabled = true`, the gate checks for explicit +execution approval before running the verify command: + +```yaml +post_grill: + readiness: "ready" + next_action: "execute_same_session" # or "fresh_session" +``` + +If `post_grill.readiness != "ready"` or `post_grill.next_action` is not +one of `{execute_same_session, fresh_session}`, the gate returns +`StopDecision(block=False, message="execution not approved (Mode 1)")`. + +Rationale: the Stop hook fires on every session end — including grilling +sessions where the user is still deliberating. Without this check, a +grilling session that happens to carry `loop_gate.enabled: true` would +run `make gate` on every stop, which is nonsensical and potentially +destructive. + +### 3. Cap exhaustion semantics + +When `continuation_count >= max_continuations`, the gate **stops blocking** +(not keeps blocking) and escalates: + +1. Appends a `gate_exhausted` log event to the artifact. +2. Calls `set_task_status(artifact, "hitl_queue", source="observer")`. +3. Returns `StopDecision(block=False, ...)`. + +An infinite blocking loop would lock the user's session permanently. The +cap is the escape valve: after N failed attempts the human must review. +The cap is inclusive: `count=3, max=3` is exhausted on the third failure. + +`continuation_count` is persisted in `loop_gate.continuation_count` +inside the artifact's frontmatter (not the sidecar) so it survives +session restarts. + +### 4. Authority table + +Three source tokens are now in use for status writes: + +| Source | Authority | Allowed targets | Notes | +|---|---|---|---| +| `"repl"` | Human | All valid statuses | Manual REPL commands | +| `"cli"` | Human | All valid statuses | `bach task set-status` | +| `"hook"` | Human | All valid statuses | SessionEnd hook (conservative logic in caller) | +| `"afk"` | Human (code-restricted) | `{executing, qa, hitl_queue}` | AFK runner — normal authority but self-restricted | +| `"observer"` | Observer | `executing→qa`, `{executing,qa,afk_queue}→hitl_queue` | Session watcher + loop gate cap exhaustion | + +"Human authority" means `status_service` accepts any valid status. +"Observer authority" means `status_service` enforces the transition matrix +and raises `InvalidStatusError` for any other move. Enforcement lives in +`set_task_status`, not in callers. + +The AFK runner uses `source="afk"` (human authority path) but +**self-restricts** inside `afk_runner._set_status` to +`{executing, qa, hitl_queue}`. This is a code-layer invariant, not a +service-layer firewall. The loop gate's cap-exhaustion escalation uses +`source="observer"`, which IS a service-layer firewall. + +### 5. AFK bounds — turn cap and time budget + +The AFK runner enforces two independent hard bounds: + +- **Turn cap:** `config.afk_max_turns` (default 10). After this many + turns without a gate pass or human escalation, the runner sets + `hitl_queue` with `reason="max_turns"`. +- **Time budget:** `config.afk_time_budget_minutes * 60` seconds. + After this elapsed wall time (monotonic clock, injected for tests), + the runner sets `hitl_queue` with `reason="time_budget"`. + +Both bounds are checked at the top of each turn loop iteration. Either +condition alone is sufficient to exit. The bounds exist because: + +- A buggy or confused agent could run indefinitely if only the gate + controlled stopping. The bounds guarantee liveness regardless of + gate behavior. +- A user who starts an AFK task and walks away must have a predictable + upper bound on resource consumption. + +The runner **never sets `done`**. Human review via `qa` is always +required before completion. + +### 6. AFK turn loop + +``` +1. Claim artifact: afk_queue (or executing) → executing, source="afk". +2. Loop while turns < max_turns AND elapsed < time_budget: + a. Run one headless turn (injected turn_runner). + b. Check gate (injected gate callable → bool). + True → set_status(qa) + return AfkResult(reason="verify_passed"). + c. Run judge (injected judge callable). + needs_human=True → set_status(hitl_queue) + return AfkResult(reason="needs_human"). + d. Build continuation message from gate failure + judge summary. + Loop again. +3. Bounds exhausted → set_status(hitl_queue) + return AfkResult(reason=...). +4. turn_runner raises → set_status(hitl_queue) + return AfkResult(reason="error"). +``` + +All external seams (turn_runner, judge, gate, clock) are injected keyword +args. Tests never touch real LLM / subprocess / wall clock. + +## Consequences + +- **Mode 1 is preserved.** The gate checks `post_grill` before running + any verify command. Grilling and planning sessions are never gated. + +- **New frontmatter fields.** `loop_gate.continuation_count` is written + by the gate. `afk_queue` is an existing status; `afk_max_turns` and + `afk_time_budget_minutes` are new config fields in `BachConfig`. + +- **Observer source is a security boundary.** Any future code that passes + `source="observer"` to `set_task_status` is automatically bound by the + four-transition matrix. No audit is needed — the enforcement is structural. + +- **AFK runner prints resume command on exit.** Following the iTerm launch + pattern from ADR-007, the AFK runner prints the artifact path and the + session's resume command on exit so the user can re-enter the session + if needed. + +- **`bach afk run ` is a new CLI surface.** Implemented in + `cli/afk_cmd.py` and registered in `cli/app.py`. The turn_runner and + gate callables are wired at the CLI layer, not inside `afk_runner.py`. + +## Alternatives rejected + +- **Observer authority for AFK runner.** Would restrict the runner to + `executing→qa` and escalation paths, which is almost right but misses + `afk_queue→executing` (the claim step). Using human authority with a + code-layer restriction gives the same safety guarantee with more clarity. + +- **Daemon-based gate.** Run `evaluate_stop` inside the session watcher + rather than a per-hook subprocess. Rejected for the same reason as the + watcher daemon (ADR-015): no need for persistent process supervision + when hook commands are already reliable exit points. + +- **Automatic `done` at turn loop completion.** Rejected — the + "never sets done" rule is load-bearing. An autonomous process + declaring completion would undermine the human-review guarantee that + Mode 1 depends on. + +- **Single bounds check (either cap or time budget, not both).** Having + only one bound would make the system less predictable: a slow agent + that uses few turns would exhaust time; a fast agent that cycles + pointlessly would exhaust turns. Two independent bounds cover both + failure modes. + +## References + +- ADR-007: `adr/007-session-end-hook-status-tracking.md` +- ADR-015: `adr/015-session-observability-codex-parity.md` +- Implementation: `src/bach/services/loop_gate.py` +- Implementation: `src/bach/services/afk_runner.py` +- Implementation: `src/bach/cli/afk_cmd.py` +- Implementation: `src/bach/cli/internal_sessions.py` (`session-stop` command) +- Config: `src/bach/config/settings.py` (`afk_max_turns`, `afk_time_budget_minutes`) +- Authority enforcement: `src/bach/services/status_service.py` + (`OBSERVER_SOURCE`, `_OBSERVER_ALLOWED_TRANSITIONS`) diff --git a/docs/how-to/wire-up-session-hook.md b/docs/how-to/wire-up-session-hook.md index 63c434b..3a1e5b3 100644 --- a/docs/how-to/wire-up-session-hook.md +++ b/docs/how-to/wire-up-session-hook.md @@ -1,47 +1,69 @@ -# Wire up the Claude Code SessionEnd hook +# Wire up Bach session hooks -Make Bach automatically detect when a launched Claude session ends and -update the task's status — without you having to type `/done` every -time. +Make Bach automatically detect session lifecycle events, update task status, +and drive the loop-gate verification — without manual `/done` every time. -## What this does +## Quick setup (recommended) -Bach launches Claude Code sessions via `iTerm`. By default, Bach has -no way to know when that session ends — you just close the tab and the -task's `status:` field stays at whatever Claude left it. +Use the Bach hook installer instead of editing JSON by hand: -This hook closes the loop: +```bash +# Claude Code — installs SessionEnd + Stop + PostToolUse hooks +# Edits /.claude/settings.json (or --project-dir PATH) +bach hooks install claude-code -``` -You: /open 1 Bach: writes sidecar, launches Claude -[work in claude...] -You: type "/quit" or Ctrl-D Claude: fires SessionEnd hook -Hook: runs `bach internal session-ended` - reads sidecar, looks at artifact, applies conservative update +# Codex — installs sessionStop hook +# Edits ~/.codex/hooks.json (or --codex-home PATH) +bach hooks install codex + +# Verify what's installed +bach hooks status ``` -The "conservative update" rule: +Both installs are **idempotent** — re-running never duplicates entries and +never removes unrelated user hooks. Every change made is printed to stdout. -- **If the agent updated `status:` during the session** (e.g. moved - `grilling` → `ready`) — leave that value alone. The hook just appends - a `session_ended` log event. -- **If the agent didn't update `status:`** — set it to `paused`. Never - `done`, because "session ended" ≠ "task complete." You verify - completion and flip with `/done ` manually. +--- -## Setup (one snippet, paste once) +## What each hook does -Add this to your Claude Code settings. Three places work — pick one: +### Claude Code hooks -| Location | Scope | -|---|---| -| `~/.claude/settings.json` | Global (fires for every Claude session on your machine) | -| `/.claude/settings.json` | Per-project, committed to git (team-shared) | -| `/.claude/settings.local.json` | Per-project, local only (gitignored) | +Bach installs three hook events into `/.claude/settings.json`: + +| Hook event | Handler command | Purpose | +|---------------|--------------------------------------|---------| +| `SessionEnd` | `bach internal session-ended` | Conservative status update when session ends: preserves agent-set status, sets `hitl_queue` otherwise | +| `Stop` | `bach internal session-stop` | Loop-gate check: runs `verify_command` from artifact; exit 2 blocks Claude + replays continuation message | +| `PostToolUse` | `bach internal hook-event` | Spools all tool-use events to `~/.bach/sessions/.events.jsonl` for the session watcher | + +The `Stop` hook uses `bach internal session-stop`, which reads the Bach +artifact's `loop_gate` block, runs the verify command, and exits 2 (block) +or 0 (pass). Exiting 2 causes Claude Code to re-prompt the agent with the +verification failure output as context — this is how the AFK loop continues +without human intervention. -**Recommended**: per-project `settings.local.json` for the projects Bach -launches into. That way the hook only fires for sessions Bach started -in those projects — not every Claude session on your machine. +The `SessionEnd` and `PostToolUse` hooks both call `bach internal hook-event`, +which appends a JSON line to the session spool. Fast: single append + exit 0. + +### Codex hooks + +Bach installs one hook event into `~/.codex/hooks.json`: + +| Hook event | Handler command | Purpose | +|----------------|-----------------------------|---------| +| `sessionStop` | `bach internal hook-event` | Spool the session stop event for the session watcher | + +Codex sessions now get full sidecar tracking and spool support just like +Claude Code sessions. The loop-gate Stop hook is Claude Code-only for now +(Codex doesn't expose an equivalent pre-stop hook). + +--- + +## Manual setup (advanced) + +If you prefer to edit settings files directly, here is the full snippet +for Claude Code: ```json { @@ -59,30 +81,83 @@ in those projects — not every Claude session on your machine. } ] } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "bach", + "args": ["internal", "session-stop"], + "async": false, + "timeout": 300 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "bach", + "args": ["internal", "hook-event"], + "async": true, + "timeout": 10 + } + ] + } ] } } ``` -That's it. Restart any open Claude sessions to pick up the new -configuration. +For Codex, add to `~/.codex/hooks.json`: + +```json +{ + "sessionStop": { + "command": "bach", + "args": ["internal", "hook-event"] + } +} +``` + +--- + +## Hook placement + +Three locations work for Claude Code — pick the right scope: + +| Location | Scope | +|---|---| +| `~/.claude/settings.json` | Global (all Claude sessions on your machine) | +| `/.claude/settings.json` | Per-project, committed to git (team-shared) | +| `/.claude/settings.local.json` | Per-project, local only (gitignored) | + +**Recommended**: per-project `settings.local.json` so the hooks only fire +for Bach-launched sessions in that project. + +--- ## How to know it's working -After running `/open ` and then exiting the Claude session, run: +After running `/open ` and exiting the Claude session, run: ```bash bach bach > /show ``` -The artifact's `log:` array should now contain an event like: +The artifact's `log:` array should contain an event like: ```yaml - timestamp: 2026-05-24T22:15:33+00:00 event: status_changed - from: grilling - to: paused + from: executing + to: hitl_queue source: hook ``` @@ -95,52 +170,101 @@ If the agent updated status during the session, you'll see: (no status change — agent's value preserved). -Logs from the hook also land in `bach.log` (the standard Bach log -stream). Look for `event=session_end_hook_*` lines for a full trace. +The Stop hook logs `event=session_stop_pass` or `event=session_stop_gate_*` +to the Bach log stream. + +--- ## What gets stored where | File | Purpose | Lifetime | |---|---|---| -| `~/.bach/sessions/.yaml` | Per-session sidecar (artifact path + status_at_start) | Single-shot: deleted on hook fire | +| `~/.bach/sessions/.yaml` | Per-session sidecar (artifact path + status_at_start + runtime) | Single-shot: deleted on SessionEnd hook fire | +| `~/.bach/sessions/.events.jsonl` | Session event spool (hook payloads appended in order) | Retained for watcher; GC'd after session ends | | `/.bach/runs//task-*.md` | Task artifact (the source of truth) | Permanent | -Sidecars are auto-pruned after 7 days by `bach internal session-gc`, -which `/refresh` calls best-effort. If the hook never fires (e.g. iTerm -tab `SIGKILL`'d), the sidecar will be cleaned up on the next sweep. +Sidecars are auto-pruned after 7 days by `bach internal session-gc`. +Spool files are consumed by `bach sessions watch` or the auto-spawned +watcher when `watch_on_launch: true` is set in config. -## Matcher choice +--- -`prompt_input_exit` covers the common cases: user hits Ctrl-D, types -`/quit` in Claude, or the session ends at a prompt. If you want to -catch other exit reasons (e.g. `/logout`, `bypass_permissions_disabled`, -or unmatched fallback `other`), add more `matcher` entries to the -`SessionEnd` array — same `hooks` block. See [Claude Code Hooks -Reference](https://code.claude.com/docs/en/hooks) for the full list. +## Loop-gate (Stop hook) + +When `loop_gate` is configured in the artifact frontmatter, the Stop hook +will block Claude from ending the session if a verify command fails: + +```yaml +# In your task artifact frontmatter: +loop_gate: + enabled: true + verify_command: "make test" + max_continuations: 3 +``` + +When verify fails: +1. `bach internal session-stop` exits 2 +2. Claude Code re-prompts the agent with the failure output +3. The continuation counter in the artifact increments +4. At `max_continuations`, the loop is released and the task moves to `hitl_queue` + +The loop gate only activates when the artifact records an execution approval +(Mode 1 — the task was explicitly approved for execution after grilling). + +--- ## Codex sessions -Codex doesn't have an equivalent SessionEnd hook today. Codex-launched -tasks rely on `/done`, `/ready`, `/skip` manual overrides — same -commands, just no automation. The sidecar isn't written for codex -launches either (no point — there's no hook to consume it). +Codex now has full hook support via `bach hooks install codex`. The +`sessionStop` hook spools the stop event for the session watcher. + +The AFK loop (`bach afk run `) drives Codex sessions headlessly using +the `codex exec resume` command, and the loop gate applies there too via +the injected gate callable. + +--- + +## Matcher choice + +`prompt_input_exit` covers the common exit cases. For more thorough +coverage, add extra matchers: + +```json +"SessionEnd": [ + { "matcher": "prompt_input_exit", "hooks": [...] }, + { "matcher": "other", "hooks": [...] } +] +``` + +See [Claude Code Hooks Reference](https://code.claude.com/docs/en/hooks) +for the full matcher list. + +--- ## Limitations | Case | Behavior | |---|---| | Force-killed iTerm tab (`kill -9`) | Hook doesn't fire. Sidecar becomes stale; cleaned up after 7 days by `session-gc`. | -| `/clear` inside Claude | Doesn't fire SessionEnd (fires SessionStart with `matcher=clear`). Status stays as agent left it. | +| `/clear` inside Claude | Doesn't fire SessionEnd. Status stays as agent left it. | | Resume (`claude --resume`) | When the resumed session ends, the hook fires normally. | -| Hook command missing from PATH | Claude logs the failure to its transcript; no artifact change. Re-add `bach` to your PATH and try again. | +| Hook command missing from PATH | Claude logs the failure; no artifact change. Re-add `bach` to PATH. | +| Verify command timeout | `bach internal session-stop` times out after 300 seconds; gate passes (non-blocking on timeout). | + +--- + +## Removing hooks -## Removing the hook +Run `bach hooks install` and then manually remove the entries, or delete +the relevant blocks from `settings.json` / `hooks.json`. No artifact +cleanup is needed — spools and sidecars without a hook to fire get GC'd. -Delete the `SessionEnd` block from your `settings.json`. No artifact -cleanup needed — sidecars without a hook to fire just get GC'd. +--- ## Related docs - `use-the-repl.md` — `/done` `/ready` `/skip` manual status commands -- `../adr/007-session-end-hook-status-tracking.md` — design rationale +- `../adr/007-session-end-hook-status-tracking.md` — SessionEnd design rationale +- `../adr/015-session-observability-codex-parity.md` — Phase 16 spool/observer architecture +- `../adr/016-loop-control.md` — loop gate, AFK bounds, Mode 1 preservation - [Claude Code Hooks Reference](https://code.claude.com/docs/en/hooks) — full hook system spec diff --git a/docs/internal/progress.txt b/docs/internal/progress.txt index 53cc80a..ccdf461 100644 --- a/docs/internal/progress.txt +++ b/docs/internal/progress.txt @@ -4,6 +4,23 @@ - Repo scaffolded; core docs created; Python CLI skeleton; basic project/task services in place. +- **Phase 16 complete** (session observability & loop control: `DiscoveredSession`, + `SessionEvent`, `JudgeVerdict` domain types; `session_discovery.py` scans + Claude Code + Codex transcript directories with mtime-based liveness; + `transcript.py` parses both Claude JSONL and Codex rollout formats; + `judge_service.py` wraps `run_codex_json` with bounded prompt window and + fixed JSON schema; observer authority enforced in `status_service.py` at + write path (`executing→qa`, `*→hitl_queue` only); `sessions_service.py` + joins discovery with Bach artifacts; `cli/sessions.py` sub-app (`list/ + adopt/resume/watch`); `session_tracker.py` spool (`append_hook_event`, + `read_spool_events`) + sidecar `runtime` field; `session_watcher.py` + run-and-exit watcher loop; `hooks_service.py` idempotent installer for + Claude Code (SessionEnd/Stop/PostToolUse) and Codex (sessionStop); + `cli/hooks_cmd.py` (`bach hooks install/status`); `cli/internal_sessions.py` + (`bach internal hook-event/session-watch/session-stop`); `loop_gate.py` + Stop-hook gate with verify command + continuation counter + `hitl_queue` + cap; `afk_runner.py` headless multi-turn driver; `cli/afk_cmd.py` + (`bach afk run`); 8 new config fields in `BachConfig`; 812 tests). - **Phase 15.2 complete** (auto-create the `bach-{task_id}` label in the target GH repo via `ensure_gh_label_exists`; idempotent — uses blind-create + "already exists" swallow; called from `import_from_gh` diff --git a/src/bach/cli/afk_cmd.py b/src/bach/cli/afk_cmd.py new file mode 100644 index 0000000..4ccc312 --- /dev/null +++ b/src/bach/cli/afk_cmd.py @@ -0,0 +1,178 @@ +"""AFK runner CLI sub-app — `bach afk run `. + +This module owns the Typer app registration and the thin CLI handler only. +All AFK loop logic lives in services/afk_runner.py (owned by Phase L2). +The runtime seam implementations (turn_runner, judge, gate) are wired here. + +Registered from cli/app.py as `app.add_typer(afk_app, name="afk")`. +""" + +import logging +import subprocess +from pathlib import Path +from typing import Annotated + +import typer + +from bach.config.paths import bach_home +from bach.config.projects import ProjectRegistry +from bach.config.settings import load_config +from bach.services.daily_index_service import find_task_by_id +from bach.services.judge_service import judge_session +from bach.services.loop_gate import evaluate_stop +from bach.services.session_tracker import SESSIONS_DIRNAME +from bach.storage.artifacts import TaskArtifactStore + +logger = logging.getLogger(__name__) + +afk_app = typer.Typer(help="AFK (headless) task runner commands.") + + +@afk_app.command("run") +def afk_run( + task: Annotated[ + str, + typer.Argument(help="Task reference: stable ID (e.g. t47) or artifact path."), + ], +) -> None: + """Run a task headlessly (AFK mode) from the afk_queue. + + Claims the task from afk_queue → executing, then drives it through + the AFK loop: run a turn, verify via the loop gate, judge with the + LLM, and either finish (qa) or continue. Exits when done, needs_human + (hitl_queue), max turns reached, or time budget exceeded. + + Never sets status to done — human QA is always the final step. + + Implementation lives in services/afk_runner.py (Phase L2). + """ + # Lazy import only for afk_runner itself: it is owned by Phase L2 and may + # not exist during partial-phase development. All other imports above are + # stable top-level imports with no import cycle. + try: + from bach.services.afk_runner import run_afk_task # noqa: PLC0415 + except ImportError: + logger.warning("event=afk_run_import_failed reason=afk_runner_not_found") + typer.echo("AFK runner not yet available (services/afk_runner.py missing).", err=True) + raise typer.Exit(code=1) from None + + config = load_config() + + # Resolve task ref: try as stable ID first, then as artifact path. + artifact_path: Path | None = None + registry = ProjectRegistry.default() + found = find_task_by_id(registry, task) + if found is not None: + artifact_path = found.artifact_path + else: + candidate = Path(task) + if candidate.exists(): + artifact_path = candidate + + if artifact_path is None: + typer.echo(f"No task found for ref: {task}", err=True) + raise typer.Exit(code=1) + + logger.info("event=afk_run_start task=%s artifact=%s", task, artifact_path) + + sessions_dir = bach_home() / SESSIONS_DIRNAME + + # Build the gate callable: wraps evaluate_stop with the deferred store + # so the gate can be called with just session_id + artifact_path kwargs. + def _gate_fn(*, session_id: str, artifact_path: Path, **_kw: object) -> bool: + store = TaskArtifactStore.from_artifact_path(artifact_path) + decision = evaluate_stop( + session_id, + sessions_dir=sessions_dir, + store=store, + ) + # evaluate_stop returns block=False on cap exhaustion but has already + # written hitl_queue to the artifact. Re-read the current status so + # we do NOT treat that as "gate passed" (which would overwrite hitl_queue + # with qa). If the artifact was escalated by the gate, treat as NOT passed + # so the runner sees block=True and stops with reason=needs_human. + if not decision.block: + try: + current_payload = store.read_frontmatter(artifact_path) + current_status = str(current_payload.get("status", "")) + if current_status == "hitl_queue": + # Cap was exhausted — the gate already escalated. Signal the + # runner to stop via needs_human rather than qa. + logger.info( + "event=gate_fn_hitl_detected artifact=%s " + "reason=cap_exhausted_by_evaluate_stop", + artifact_path, + ) + return False + except Exception as exc: # noqa: BLE001 — read failure is non-fatal + logger.warning( + "event=gate_fn_status_reread_failed artifact=%s reason=%s", + artifact_path, + type(exc).__name__, + ) + return not decision.block # True = pass (no block) + + try: + result = run_afk_task( + artifact_path, + config=config, + turn_runner=_run_headless_turn, + judge=judge_session, + gate=_gate_fn, + ) + except Exception as exc: # noqa: BLE001 — unexpected errors exit cleanly + logger.error( + "event=afk_run_failed artifact=%s reason=%s", + artifact_path, + type(exc).__name__, + ) + typer.echo(f"AFK run failed: {type(exc).__name__}: {exc}", err=True) + raise typer.Exit(code=1) from None + + typer.echo( + f"AFK run complete: {result.turns_used} turn(s), " + f"status={result.final_status}, reason={result.reason}" + ) + typer.echo(f"Artifact: {artifact_path}") + + +def _run_headless_turn( + *, + artifact_path: Path, + message: str, + runtime: str, + session_id: str, + **_kwargs: object, +) -> str: + """Run one headless turn via the appropriate runtime CLI. + + Dispatches to `claude -p --resume ` or + `codex exec resume ` depending on runtime. The output + is captured and returned as a string for the judge to consume. + + This is the only place the AFK runner touches real subprocesses. + Raises on non-zero exit so afk_runner.run_afk_task catches it as + reason="error". + """ + + if runtime == "codex": + cmd = ["codex", "exec", "resume", session_id, message] + else: + # Default: claude-code + cmd = ["claude", "-p", message, "--resume", session_id, "--output-format", "json"] + + logger.info( + "event=afk_turn_start runtime=%s session_id=%s artifact=%s", + runtime, + session_id, + artifact_path, + ) + proc = subprocess.run( # noqa: S603 — explicit list, no shell + cmd, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(f"headless turn failed (exit {proc.returncode}): {proc.stderr[:500]}") + return proc.stdout diff --git a/src/bach/cli/app.py b/src/bach/cli/app.py index 6091677..6aa6def 100644 --- a/src/bach/cli/app.py +++ b/src/bach/cli/app.py @@ -7,7 +7,10 @@ import typer from rich.console import Console +from bach.cli.hooks_cmd import hooks_app +from bach.cli.internal_sessions import register_internal_sessions from bach.cli.issue import issue_app +from bach.cli.sessions import sessions_app from bach.config.paths import bach_home from bach.config.projects import ProjectRegistry from bach.config.settings import is_valid_user_name, load_config, write_config_field @@ -61,6 +64,16 @@ app.add_typer(day_app, name="day") app.add_typer(config_app, name="config") app.add_typer(internal_app, name="internal") +# Phase 16: session observability sub-app. Fully merged — registered at top level +# like issue_app and hooks_app. The old try/except guard was for partial-phase dev. +app.add_typer(sessions_app, name="sessions") +app.add_typer(hooks_app, name="hooks") +# Internal hook-event + session-watch + session-stop commands. +register_internal_sessions(internal_app) +# Phase 16 L1: `bach afk run` sub-app. afk_runner.py (L2) provides the logic. +from bach.cli.afk_cmd import afk_app # noqa: PLC0415, E402 + +app.add_typer(afk_app, name="afk") # highlight=False disables Rich's ReprHighlighter (auto-styles numbers, # Python strings, paths with hardcoded colors that bypass the theme). @@ -107,7 +120,7 @@ def _launch_repl(start_command: str | None = None) -> None: t = _cli_theme() console.print( f"[{t.error}]REPL dependencies not installed.[/{t.error}]\n" - "Install with: [bold]uv pip install -e \".[repl]\"[/bold]" + 'Install with: [bold]uv pip install -e ".[repl]"[/bold]' ) raise typer.Exit(code=1) from None logger.info( @@ -138,6 +151,7 @@ def main(ctx: typer.Context) -> None: # UX (e.g. `bach project` → /projects on startup). The CLI subcommands # themselves remain unchanged (separate decorators below). Additive only. + @task_app.callback(invoke_without_command=True) def task_callback(ctx: typer.Context) -> None: """`bach task` (no subcommand) → REPL. Tasks are the default view.""" @@ -328,13 +342,9 @@ def task_set_status( except InvalidStatusError as exc: console.print(f"[{t.error}]{exc}[/{t.error}]") raise typer.Exit(code=1) from None - console.print( - f"[{t.success}]{result.old} → {result.new}[/{t.success}] task: {found.title}" - ) + console.print(f"[{t.success}]{result.old} → {result.new}[/{t.success}] task: {found.title}") if result.suggestions: - console.print( - f"[{t.muted}]Suggested next: {', '.join(result.suggestions)}[/{t.muted}]" - ) + console.print(f"[{t.muted}]Suggested next: {', '.join(result.suggestions)}[/{t.muted}]") @task_app.command("launch") @@ -475,8 +485,9 @@ def config_list_models() -> None: def config_set_model( slug: Annotated[ str, - typer.Argument(help="Model slug to set as normalize_model. " - "See `bach config list-models` for options."), + typer.Argument( + help="Model slug to set as normalize_model. See `bach config list-models` for options." + ), ], ) -> None: """Validate `slug` against the codex catalog and write to ~/.bach/config.yaml. @@ -529,8 +540,10 @@ def config_set_theme( def config_set_name( name: Annotated[ str, - typer.Argument(help="Name to use in agent-facing prompts " - "(e.g. 'Alice'). Leave blank to reset to 'the user'."), + typer.Argument( + help="Name to use in agent-facing prompts " + "(e.g. 'Alice'). Leave blank to reset to 'the user'." + ), ], ) -> None: """Set the user_name shown in Bach's Mode 1 agent prompts. @@ -587,7 +600,7 @@ def board( str | None, typer.Argument( help="Optional project filter and/or 'all' (e.g. 'skillia', 'all', " - "or 'skillia all'). Order-independent." + "or 'skillia all'). Order-independent." ), ] = None, ) -> None: @@ -729,9 +742,7 @@ def _launch_in_batches( default=True, ) if not ok: - console.print( - f"[{t.warning}]Stopped[/{t.warning}] after {batch_start} task(s)." - ) + console.print(f"[{t.warning}]Stopped[/{t.warning}] after {batch_start} task(s).") return for artifact in batch: service.launch_task(artifact) @@ -772,20 +783,15 @@ def internal_session_ended() -> None: try: payload = json.load(sys.stdin) except json.JSONDecodeError as exc: - logger.warning( - "event=session_end_hook_bad_json reason=%s", type(exc).__name__ - ) + logger.warning("event=session_end_hook_bad_json reason=%s", type(exc).__name__) return except Exception as exc: # noqa: BLE001 — best-effort, swallow - logger.warning( - "event=session_end_hook_read_failed reason=%s", type(exc).__name__ - ) + logger.warning("event=session_end_hook_read_failed reason=%s", type(exc).__name__) return session_id = payload.get("session_id") if not session_id: - logger.warning("event=session_end_hook_no_session_id payload_keys=%s", - list(payload.keys())) + logger.warning("event=session_end_hook_no_session_id payload_keys=%s", list(payload.keys())) return logger.info("event=session_end_hook_start session_id=%s", session_id) @@ -800,18 +806,21 @@ def internal_session_ended() -> None: # Read current status without using the helper (it expects to # mutate). Use the same store helpers to stay schema-consistent. from bach.storage.artifacts import TaskArtifactStore + store = TaskArtifactStore.from_artifact_path(sidecar.artifact) current_status = str(store.read_frontmatter(sidecar.artifact).get("status", "")) except FileNotFoundError: logger.warning( "event=session_end_hook_artifact_missing session_id=%s path=%s", - session_id, sidecar.artifact, + session_id, + sidecar.artifact, ) return except Exception as exc: # noqa: BLE001 logger.warning( "event=session_end_hook_artifact_read_failed session_id=%s reason=%s", - session_id, type(exc).__name__, + session_id, + type(exc).__name__, ) return @@ -820,16 +829,18 @@ def internal_session_ended() -> None: # Just append a session_ended event so the audit trail shows # when the user closed out. logger.info( - "event=session_end_hook_agent_updated session_id=%s " - "from=%s to=%s", - session_id, sidecar.status_at_start, current_status, + "event=session_end_hook_agent_updated session_id=%s from=%s to=%s", + session_id, + sidecar.status_at_start, + current_status, ) try: append_session_ended_log(sidecar.artifact) except Exception as exc: # noqa: BLE001 — log + return ok logger.warning( "event=session_end_hook_log_failed session_id=%s reason=%s", - session_id, type(exc).__name__, + session_id, + type(exc).__name__, ) return @@ -842,9 +853,9 @@ def internal_session_ended() -> None: set_task_status(sidecar.artifact, "hitl_queue", source="hook") except Exception as exc: # noqa: BLE001 logger.warning( - "event=session_end_hook_status_update_failed session_id=%s " - "reason=%s", - session_id, type(exc).__name__, + "event=session_end_hook_status_update_failed session_id=%s reason=%s", + session_id, + type(exc).__name__, ) return @@ -858,15 +869,14 @@ def internal_session_ended() -> None: except Exception as exc: # noqa: BLE001 logger.warning( "event=session_end_hook_regen_failed date=%s reason=%s", - date_str, type(exc).__name__, + date_str, + type(exc).__name__, ) @internal_app.command("session-gc") def internal_session_gc( - max_age_days: Annotated[ - int, typer.Option(help="Sidecars older than this are deleted.") - ] = 7, + max_age_days: Annotated[int, typer.Option(help="Sidecars older than this are deleted.")] = 7, ) -> None: """Prune stale session sidecars. Safe to run anytime. @@ -901,10 +911,7 @@ def internal_migrate_task_ids() -> None: ) return - console.print( - f"Migrating {len(legacy)} legacy task(s) " - f"(of {len(tasks)} total)…" - ) + console.print(f"Migrating {len(legacy)} legacy task(s) (of {len(tasks)} total)…") migrated = 0 for task in legacy: try: diff --git a/src/bach/cli/hooks_cmd.py b/src/bach/cli/hooks_cmd.py new file mode 100644 index 0000000..6fc28b9 --- /dev/null +++ b/src/bach/cli/hooks_cmd.py @@ -0,0 +1,181 @@ +"""Typer subapp for `bach hooks ...` — hook install and status commands. + +Subcommands: + install claude-code — idempotent install of Bach hooks into + /.claude/settings.json (SessionEnd/Stop/PostToolUse) + install codex — idempotent install of Bach hooks into ~/.codex/hooks.json + status — report which runtimes have Bach hooks installed + +These are explicit user-facing commands. There is no automated install — +see the architecture rule: "No hidden writes: hook installation only via +explicit `bach hooks ...` commands". + +Design: + - Thin handler — all logic lives in services/hooks_service.py. + - Paths injected via typer Options so tests and advanced users can + override without touching real config files. + - Every change made is printed so the user can review what changed. +""" + +import logging +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console + +from bach.config.settings import load_config +from bach.config.themes import THEMES, ThemePalette, get_theme +from bach.services.hooks_service import ( + get_hooks_status, + install_claude_hooks, + install_codex_hooks, +) + +logger = logging.getLogger(__name__) + +hooks_app = typer.Typer(help="Manage Bach runtime hook integrations.") +_console = Console(highlight=False) + + +def _theme() -> ThemePalette: + return get_theme(load_config().theme) or THEMES["default"] + + +# --------------------------------------------------------------------------- +# `bach hooks install ` +# --------------------------------------------------------------------------- + + +@hooks_app.command("install") +def hooks_install( + runtime: Annotated[ + str, + typer.Argument( + help="Runtime to install hooks for: 'claude-code' or 'codex'.", + ), + ], + project_dir: Annotated[ + Path | None, + typer.Option( + "--project-dir", + help="Project directory containing .claude/ (default: cwd). " + "Only used for claude-code installs.", + ), + ] = None, + codex_home: Annotated[ + Path | None, + typer.Option( + "--codex-home", + help="Codex home directory (default: ~/.codex). Only used for codex installs.", + ), + ] = None, +) -> None: + """Install Bach hooks for the specified runtime. + + Claude Code: edits .claude/settings.json in the target project directory + (or the current working directory when --project-dir is omitted) to add + SessionEnd, Stop, and PostToolUse entries calling `bach internal hook-event`. + + Codex: edits ~/.codex/hooks.json to add a sessionStop entry. + + Both installs are idempotent and non-destructive — pre-existing user + hooks are preserved. Every change made is printed to stdout. + """ + t = _theme() + normalized = runtime.lower().strip() + + if normalized in ("claude-code", "claude"): + target_dir = project_dir or Path.cwd() + result = install_claude_hooks(project_dir=target_dir) + if result.added: + for event_name, cmd in result.added: + _console.print(f"[{t.success}]added[/{t.success}] hooks.{event_name}: {cmd!r}") + else: + _console.print(f"[{t.muted}]Already installed (no changes made).[/{t.muted}]") + logger.info( + "event=hooks_install runtime=claude-code project_dir=%s added=%d already_present=%d", + target_dir, + len(result.added), + len(result.already_present), + ) + + elif normalized == "codex": + target_codex_home = codex_home or (Path.home() / ".codex") + result = install_codex_hooks(codex_home=target_codex_home) + if result.added: + for event_name, cmd in result.added: + _console.print(f"[{t.success}]added[/{t.success}] hooks.{event_name}: {cmd!r}") + else: + _console.print(f"[{t.muted}]Already installed (no changes made).[/{t.muted}]") + logger.info( + "event=hooks_install runtime=codex codex_home=%s added=%d already_present=%d", + target_codex_home, + len(result.added), + len(result.already_present), + ) + + else: + _console.print( + f"[{t.error}]Unknown runtime:[/{t.error}] {runtime!r}. Use 'claude-code' or 'codex'." + ) + raise typer.Exit(code=1) + + +# --------------------------------------------------------------------------- +# `bach hooks status` +# --------------------------------------------------------------------------- + + +@hooks_app.command("status") +def hooks_status( + project_dir: Annotated[ + Path | None, + typer.Option( + "--project-dir", + help="Project directory to check (default: cwd).", + ), + ] = None, + codex_home: Annotated[ + Path | None, + typer.Option( + "--codex-home", + help="Codex home directory to check (default: ~/.codex).", + ), + ] = None, +) -> None: + """Report which runtimes have Bach hooks installed. + + Checks .claude/settings.json (in the project directory or cwd) for + Claude Code hooks, and ~/.codex/hooks.json for Codex hooks. + """ + t = _theme() + target_dir = project_dir or Path.cwd() + target_codex_home = codex_home or (Path.home() / ".codex") + + status = get_hooks_status( + project_dir=target_dir, + codex_home=target_codex_home, + ) + + def _checkmark(ok: bool) -> str: + return f"[{t.success}]✓[/{t.success}]" if ok else f"[{t.error}]✗[/{t.error}]" + + _console.print( + f"{_checkmark(status['claude_installed'])} claude-code " + f"({target_dir / '.claude' / 'settings.json'})" + ) + if status["claude_detail"]: + _console.print(f" [{t.muted}]events: {', '.join(status['claude_detail'])}[/{t.muted}]") + + _console.print( + f"{_checkmark(status['codex_installed'])} codex ({target_codex_home / 'hooks.json'})" + ) + if status["codex_detail"]: + _console.print(f" [{t.muted}]events: {', '.join(status['codex_detail'])}[/{t.muted}]") + + if not status["claude_installed"] or not status["codex_installed"]: + _console.print( + f"\n[{t.muted}]Run `bach hooks install claude-code` or " + f"`bach hooks install codex` to install.[/{t.muted}]" + ) diff --git a/src/bach/cli/internal_sessions.py b/src/bach/cli/internal_sessions.py new file mode 100644 index 0000000..9bb214f --- /dev/null +++ b/src/bach/cli/internal_sessions.py @@ -0,0 +1,235 @@ +"""Internal hook-event and session-watch commands for `bach internal ...`. + +These commands are invoked by automation (Claude Code hooks, Codex hooks, +subprocess spawned by `bach task launch`) — NOT by human users directly. +They are hidden from --help and registered onto the existing `internal_app` +in cli/app.py. + +Commands: + hook-event — reads hook JSON payload from stdin, appends to session + spool. Fast: one append + exit 0. The session watcher + polls the spool asynchronously. + session-watch — foreground watcher loop for one session artifact. + Spawned by `bach task launch` when watch_on_launch=True. + Delegates to services/session_watcher.watch_session. + session-stop — reads Stop hook JSON payload from stdin, calls + loop_gate.evaluate_stop. Blocks (exit 2 + stderr message) + when the gate fires; exits 0 when gate passes or is not + configured. Owned by Phase L1. +""" + +import json +import logging +import sys +from pathlib import Path +from typing import Annotated + +import typer + +from bach.config.paths import bach_home +from bach.services.session_tracker import SESSIONS_DIRNAME, append_hook_event + +logger = logging.getLogger(__name__) + + +def register_internal_sessions(internal_app: typer.Typer) -> None: + """Register hook-event and session-watch onto `internal_app`. + + Called once from cli/app.py. Kept as a function (not module-level + decorators) so the registration target is explicit and there are no + import-time side-effects — the module can be imported safely by tests + without needing a fully wired Typer app. + """ + + @internal_app.command("hook-event") + def internal_hook_event() -> None: + """Append one hook-event payload (stdin JSON) to the session spool. + + Called by Claude Code's SessionEnd/Stop/PostToolUse hooks and by + Codex's sessionStop hook. Reads the hook JSON from stdin, extracts + `session_id`, appends the payload to the session spool, then exits 0. + + Exit code is ALWAYS 0 — hook failures should never surface to the + user as a Claude/Codex session error. All failures are logged. + + Spool lives at ~/.bach/sessions/.events.jsonl. + """ + try: + payload = json.load(sys.stdin) + except json.JSONDecodeError as exc: + logger.warning("event=hook_event_bad_json reason=%s", type(exc).__name__) + return + except Exception as exc: # noqa: BLE001 — best-effort, always exit 0 + logger.warning("event=hook_event_read_failed reason=%s", type(exc).__name__) + return + + session_id = payload.get("session_id") or payload.get("sessionId") + if not session_id: + logger.warning( + "event=hook_event_no_session_id payload_keys=%s", + list(payload.keys()), + ) + return + + sessions_dir = bach_home() / SESSIONS_DIRNAME + try: + append_hook_event(str(session_id), payload, sessions_dir=sessions_dir) + except Exception as exc: # noqa: BLE001 — best-effort, always exit 0 + logger.warning( + "event=hook_event_spool_failed session_id=%s reason=%s", + session_id, + type(exc).__name__, + ) + return + + logger.info( + "event=hook_event_spooled session_id=%s hook=%s", + session_id, + payload.get("hook_event_name", payload.get("type", "unknown")), + ) + + @internal_app.command("session-watch") + def internal_session_watch( + artifact: Annotated[ + Path, + typer.Argument(help="Path to the Bach task artifact to watch."), + ], + ) -> None: + """Run the foreground session watcher for a task artifact. + + Spawned by `bach task launch` when `watch_on_launch=True` in config. + Follows the session transcript, drains the spool, and drives status + updates via the judge. Exits when the session ends (transcript ends + + no live process) or when the artifact is missing. + + This is a blocking foreground process — it is meant to run in a + background subprocess (Popen detached from the terminal). The + caller (task_service.launch_task) is responsible for the detachment. + """ + # Lazy import of session_watcher to avoid a circular dep at module + # level. The watcher depends on config, transcript, discovery — all + # of which can be imported fine, but keeping this lazy is consistent + # with the REPL lazy-import pattern in app.py. + try: + from bach.services.session_watcher import watch_session + except ImportError as exc: + # session_watcher.py is owned by S2 and may not exist during + # partial-phase development. Log + exit gracefully. + logger.warning("event=session_watch_import_failed reason=%s", type(exc).__name__) + raise typer.Exit(code=1) from None + + from bach.config.paths import bach_home + from bach.config.settings import load_config + from bach.services.judge_service import judge_session + from bach.services.session_tracker import SESSIONS_DIRNAME + + config = load_config() + sessions_dir = bach_home() / SESSIONS_DIRNAME + try: + exit_code = watch_session( + artifact, + config=config, + sessions_dir=sessions_dir, + judge_fn=judge_session, + ) + except FileNotFoundError: + logger.error("event=session_watch_artifact_missing path=%s", artifact) + raise typer.Exit(code=1) from None + except Exception as exc: # noqa: BLE001 — log + exit, never crash to stderr + logger.error( + "event=session_watch_unexpected_error path=%s reason=%s", + artifact, + type(exc).__name__, + ) + raise typer.Exit(code=1) from None + + raise typer.Exit(code=exit_code) + + @internal_app.command("session-stop") + def internal_session_stop() -> None: + """Stop hook handler: evaluate the loop gate and block if verify fails. + + Wired into Claude Code's Stop hook. Claude passes a JSON payload on + stdin: {session_id, ...}. We extract session_id, look up the task + artifact via the session sidecar, and run the loop gate. + + Exit codes: + 0 gate passed (or not configured) — Claude session ends normally. + 2 gate blocked — Claude re-prompts the agent with the message + printed to stderr. Claude Code's hook spec treats exit 2 as + "block + show message". All other errors also exit 0 (best- + effort bookkeeping must never surface as a session disruption). + """ + try: + payload = json.load(sys.stdin) + except json.JSONDecodeError as exc: + logger.warning("event=session_stop_bad_json reason=%s", type(exc).__name__) + return # exit 0 — bad JSON must not block the agent + except Exception as exc: # noqa: BLE001 — best-effort, always exit 0 + logger.warning("event=session_stop_read_failed reason=%s", type(exc).__name__) + return + + session_id = payload.get("session_id") or payload.get("sessionId") + if not session_id: + logger.warning( + "event=session_stop_no_session_id payload_keys=%s", + list(payload.keys()), + ) + return + + sessions_dir = bach_home() / SESSIONS_DIRNAME + + # Lazy imports: loop_gate depends on storage.artifacts + status_service. + # Keeping lazy is consistent with the session-watch pattern above and + # avoids heavyweight imports on the fast hook-event path. + try: + from bach.services.loop_gate import evaluate_stop # noqa: PLC0415 + from bach.storage.artifacts import TaskArtifactStore # noqa: PLC0415 + except ImportError as exc: + logger.warning("event=session_stop_import_failed reason=%s", type(exc).__name__) + return + + # Build a store-factory proxy: evaluate_stop calls read_frontmatter and + # write_payload AFTER resolving the artifact from the sidecar, so we can + # build the real store on first call using the already-resolved path. + class _LazyStore: + """Deferred TaskArtifactStore — builds on first method call.""" + + def __init__(self) -> None: + self._real: TaskArtifactStore | None = None + + def _get(self, artifact: Path) -> TaskArtifactStore: + if self._real is None: + self._real = TaskArtifactStore.from_artifact_path(artifact) + return self._real + + def read_frontmatter(self, artifact: Path) -> "dict[str, object]": + return self._get(artifact).read_frontmatter(artifact) + + def write_payload(self, artifact: Path, fm: "dict[str, object]") -> None: + self._get(artifact).write_payload(artifact, fm) + + try: + decision = evaluate_stop( + str(session_id), + sessions_dir=sessions_dir, + store=_LazyStore(), # type: ignore[arg-type] + ) + except Exception as exc: # noqa: BLE001 — gate errors must never block claude + logger.warning( + "event=session_stop_gate_error session_id=%s reason=%s", + session_id, + type(exc).__name__, + ) + return + + if decision.block: + # Exit 2 signals Claude Code to block the stop and show the message. + sys.stderr.write(decision.message + "\n") + sys.stderr.flush() + raise typer.Exit(code=2) + + logger.info( + "event=session_stop_pass session_id=%s", + session_id, + ) diff --git a/src/bach/cli/sessions.py b/src/bach/cli/sessions.py new file mode 100644 index 0000000..a60f609 --- /dev/null +++ b/src/bach/cli/sessions.py @@ -0,0 +1,334 @@ +"""Typer sub-app for `bach sessions ...` (Phase 16). + +Subcommands: + list — list discovered sessions (table or JSON); supports --project/--runtime/--state + adopt — link a discovered session to a Bach task artifact + resume — resume (launch) a session + watch — foreground session watcher (delegates to session_watcher.watch_session) + +Why a separate file rather than inline in cli/app.py: + app.py is at its line budget. Each sub-app file keeps one CLI group + focused — mirroring cli/issue.py which was extracted for the same reason. + +Registration: S3 owns cli/app.py and wires this via: + app.add_typer(sessions_app, name="sessions") +""" + +import json +import logging +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +from bach.config.paths import bach_home +from bach.config.settings import load_config +from bach.config.themes import THEMES, ThemePalette, get_theme +from bach.domain.models import DiscoveredSession +from bach.runtimes.session_discovery import default_process_probe, discover_sessions +from bach.runtimes.transcript import read_tail_events +from bach.services.session_tracker import SESSIONS_DIRNAME +from bach.services.sessions_service import ( + SessionRow, + adopt_session, + find_artifact_for_ref, + list_sessions, + resume_session, + scan_artifact_paths, +) + +logger = logging.getLogger(__name__) + +# Typer sub-app mounted at `bach sessions` by cli/app.py (S3 registration). +sessions_app = typer.Typer(help="Discover and manage runtime sessions.") + +_console = Console(highlight=False) + + +def _theme() -> ThemePalette: + """Load the active theme palette for colored CLI output.""" + return get_theme(load_config().theme) or THEMES["default"] + + +def _default_discover_fn() -> list[DiscoveredSession]: + """Build discover_fn using live system state and config defaults. + + All thresholds from BachConfig so they are user-tunable without code changes. + """ + from datetime import UTC, datetime + + cfg = load_config() + return discover_sessions( + claude_projects_dir=Path.home() / ".claude" / "projects", + codex_home=Path.home() / ".codex", + process_probe=default_process_probe, + now=datetime.now(UTC), + running_threshold_s=cfg.liveness_running_seconds, + idle_threshold_s=cfg.liveness_idle_minutes * 60, + ) + + +def _default_artifacts_dir() -> Path: + """Return the bach home directory for artifact scanning.""" + return bach_home() + + +# --------------------------------------------------------------------------- +# `bach sessions list` (default sub-command) +# --------------------------------------------------------------------------- + + +@sessions_app.command("list") +def sessions_list( + project: Annotated[ + str | None, + typer.Option("--project", help="Filter by project path (exact)."), + ] = None, + runtime: Annotated[ + str | None, + typer.Option("--runtime", help="Filter by runtime: claude-code or codex."), + ] = None, + state: Annotated[ + str | None, + typer.Option("--state", help="Filter by liveness: running, idle, or ended."), + ] = None, + as_json: Annotated[ + bool, + typer.Option("--json", help="Output as JSON array instead of Rich table."), + ] = False, +) -> None: + """List discovered runtime sessions, optionally filtered and joined with Bach tasks.""" + t = _theme() + + try: + rows = list_sessions( + sessions_dir=_default_artifacts_dir() / "sessions", + artifacts_dir=_default_artifacts_dir(), + discover_fn=_default_discover_fn, + read_events_fn=read_tail_events, + project=project, + runtime=runtime, + state=state, + ) + except Exception as exc: + _console.print(f"[{t.error}]error[/{t.error}] {exc}") + raise typer.Exit(code=1) from exc + + if as_json: + _output_sessions_json(rows) + return + + _render_sessions_table(rows, t) + + +def _output_sessions_json(rows: list[SessionRow]) -> None: + """Serialize session rows to stdout as a JSON array.""" + output = [] + for row in rows: + output.append( + { + "session_id": row.session.session_id, + "runtime": row.session.runtime.value, + "liveness": row.session.liveness.value, + "project_dir": str(row.session.project_dir) if row.session.project_dir else None, + "last_activity": ( + row.session.last_activity.isoformat() if row.session.last_activity else None + ), + "task_id": row.task_id, + "artifact_path": str(row.artifact_path) if row.artifact_path else None, + "preview": row.preview, + } + ) + _console.print(json.dumps(output, indent=2)) + + +def _render_sessions_table(rows: list[SessionRow], t: ThemePalette) -> None: + """Render discovered sessions as a Rich table.""" + if not rows: + _console.print(f"[{t.muted}]no sessions found[/{t.muted}]") + return + + table = Table(show_header=True, header_style="bold", highlight=False) + table.add_column("Session ID", style="dim", max_width=14) + table.add_column("Runtime") + table.add_column("State") + table.add_column("Task") + table.add_column("Project") + table.add_column("Preview", max_width=40) + + # Color mapping for liveness states — makes at-a-glance scanning fast. + liveness_colors = { + "running": t.success, + "idle": t.warning, + "ended": t.muted, + } + + for row in rows: + s = row.session + sid_short = s.session_id[:12] # truncate for readability + liveness_val = s.liveness.value + color = liveness_colors.get(liveness_val, t.muted) + project_name = s.project_dir.name if s.project_dir else "—" + task_display = row.task_id or "—" + preview_display = (row.preview or "")[:40] if row.preview else "—" + + table.add_row( + sid_short, + s.runtime.value, + f"[{color}]{liveness_val}[/{color}]", + task_display, + project_name, + preview_display, + ) + + _console.print(table) + + +# --------------------------------------------------------------------------- +# `bach sessions adopt` +# --------------------------------------------------------------------------- + + +@sessions_app.command("adopt") +def sessions_adopt( + session_ref: Annotated[str, typer.Argument(help="Session ID or unique prefix.")], + task_ref: Annotated[str, typer.Argument(help="Stable task ID (e.g. t47).")], + force: Annotated[ + bool, + typer.Option("--force", help="Overwrite existing link even if session is live."), + ] = False, +) -> None: + """Link a discovered session to a Bach task artifact. + + Writes the session's runtime_session_id into the artifact so Bach can + track it and resume it later. Refuses if the task is already linked to + a live session unless --force is given. + """ + t = _theme() + artifact_paths = scan_artifact_paths(_default_artifacts_dir()) + + try: + artifact = adopt_session( + session_ref=session_ref, + task_ref=task_ref, + artifact_paths=artifact_paths, + discover_fn=_default_discover_fn, + force=force, + ) + except ValueError as exc: + _console.print(f"[{t.error}]error[/{t.error}] {exc}") + raise typer.Exit(code=1) from exc + + _console.print( + f"[{t.success}]adopted[/{t.success}] session {session_ref!r} → task {task_ref} ({artifact})" + ) + + +# --------------------------------------------------------------------------- +# `bach sessions resume` +# --------------------------------------------------------------------------- + + +@sessions_app.command("resume") +def sessions_resume( + session_ref: Annotated[str, typer.Argument(help="Session ID or unique prefix.")], +) -> None: + """Resume a session in iTerm (or print fallback command if iTerm fails). + + For linked sessions, uses the persisted resume_command from the artifact. + For orphan sessions, builds the command from runtime info. + """ + t = _theme() + cfg = load_config() + artifact_paths = scan_artifact_paths(_default_artifacts_dir()) + + from bach.runtimes.iterm import launch_in_iterm # noqa: PLC0415 + + def _launch(cmd: str, path: Path | None, title: str) -> bool: + return launch_in_iterm( + command=cmd, + project_path=path or Path.cwd(), + title=title, + layout=cfg.iterm_layout, + ) + + try: + cmd = resume_session( + session_ref=session_ref, + artifact_paths=artifact_paths, + discover_fn=_default_discover_fn, + launch_fn=_launch, + ) + except ValueError as exc: + _console.print(f"[{t.error}]error[/{t.error}] {exc}") + raise typer.Exit(code=1) from exc + + _console.print(f"[{t.success}]resumed[/{t.success}] {session_ref}") + logger.info("event=sessions_resume_cmd session_ref=%s cmd_len=%d", session_ref, len(cmd)) + + +# --------------------------------------------------------------------------- +# `bach sessions watch` +# --------------------------------------------------------------------------- + + +@sessions_app.command("watch") +def sessions_watch( + task_or_session: Annotated[ + str, + typer.Argument(help="Stable task ID (e.g. t47) or session ID."), + ], +) -> None: + """Run the foreground session watcher for a task or session. + + The watcher polls the session transcript, calls the judge on a schedule, + and applies status changes via observer authority. Exits when the session + ends or the transcript goes cold with no live process. + + This is a foreground run-and-exit process — no daemon is started. + """ + t = _theme() + cfg = load_config() + + # Resolve the artifact path via the public service function so the CLI + # handler stays thin and does not reach into private service internals. + artifact_paths = scan_artifact_paths(_default_artifacts_dir()) + artifact: Path | None = find_artifact_for_ref(task_or_session, artifact_paths) + + if artifact is None: + _console.print( + f"[{t.error}]error[/{t.error}] no artifact found for {task_or_session!r}. " + "Use `bach sessions list` to find linked tasks." + ) + raise typer.Exit(code=1) + + # Import and run the session watcher (S2 owns session_watcher.py). + # session_watcher is wired by Phase 16 S2; guard gracefully until then. + try: + import importlib # noqa: PLC0415 + + watcher_mod = importlib.import_module("bach.services.session_watcher") + watch_fn = watcher_mod.watch_session + except (ImportError, AttributeError): + _console.print( + f"[{t.error}]error[/{t.error}] session_watcher not yet available " + "(Phase 16 S2 not yet implemented)." + ) + raise typer.Exit(code=1) from None + + # Wire sessions_dir and judge_fn exactly as internal_sessions.py does so + # both entry points behave identically. NOTE: do NOT pass transcript_path_fn + # — session_watcher.py is being modified in parallel and that param may not + # exist yet. Only pass the currently-required kwargs. + from bach.services.judge_service import judge_session # noqa: PLC0415 + + sessions_dir = bach_home() / SESSIONS_DIRNAME + exit_code = watch_fn( + artifact, + config=cfg, + sessions_dir=sessions_dir, + judge_fn=judge_session, + ) + raise typer.Exit(code=exit_code) diff --git a/src/bach/config/settings.py b/src/bach/config/settings.py index 57df9c7..03020d5 100644 --- a/src/bach/config/settings.py +++ b/src/bach/config/settings.py @@ -77,6 +77,36 @@ class BachConfig: # Defaults to the neutral "the user" so the repo ships with no # personal names baked in. Set via `bach config set-name `. user_name: str = "the user" + # --------------------------------------------------------------------------- + # Phase 16 — Session observability & loop control + # --------------------------------------------------------------------------- + # Automatically spawn a background session-watcher when `bach task launch` + # starts a session. False by default — opt in once the hook pipeline is wired. + watch_on_launch: bool = False + # When True, the session watcher is allowed to write status changes via + # status_service (source="observer"). When False, verdicts are log-only — + # useful for auditing/dry-run before enabling automated moves. + observer_moves: bool = True + # Minimum confidence a JudgeVerdict must have before the watcher acts on it. + # Below this threshold, the verdict is appended to the artifact log only + # (regardless of observer_moves). Range [0.0, 1.0]; validated at load time. + judge_confidence_threshold: float = 0.7 + # Minimum wall-clock seconds between successive mid-session judge calls. + # Guards against hammering the LLM on very active transcripts. The + # on-stop/end judge is NOT subject to this interval — it always fires. + judge_interval_seconds: int = 300 + # Transcript mtime within this many seconds → session is "running". + # Must be positive; corresponds to SessionLiveness.running in discovery. + liveness_running_seconds: int = 60 + # Process alive AND transcript stale beyond this many minutes → "idle". + # Must be positive; corresponds to SessionLiveness.idle in discovery. + liveness_idle_minutes: int = 5 + # Hard turn budget for the AFK headless runner. Enforced regardless of + # judge outcome — prevents runaway spending on stuck tasks. + afk_max_turns: int = 10 + # Hard time budget (minutes) for the AFK runner. Enforced alongside + # afk_max_turns — whichever limit fires first stops the loop. + afk_time_budget_minutes: int = 60 def _default_config_path() -> Path: @@ -131,6 +161,23 @@ def load_config(path: Path | None = None) -> BachConfig: normalize_model = _coerce_normalize_model(parsed.get("normalize_model")) theme = _coerce_theme(parsed.get("theme")) user_name = _coerce_user_name(parsed.get("user_name")) + # Phase 16 — session observability & loop control fields + watch_on_launch = _coerce_bool(parsed.get("watch_on_launch"), "watch_on_launch", default=False) + observer_moves = _coerce_bool(parsed.get("observer_moves"), "observer_moves", default=True) + judge_confidence_threshold = _coerce_confidence(parsed.get("judge_confidence_threshold")) + judge_interval_seconds = _coerce_positive_int( + parsed.get("judge_interval_seconds"), "judge_interval_seconds", default=300 + ) + liveness_running_seconds = _coerce_positive_int( + parsed.get("liveness_running_seconds"), "liveness_running_seconds", default=60 + ) + liveness_idle_minutes = _coerce_positive_int( + parsed.get("liveness_idle_minutes"), "liveness_idle_minutes", default=5 + ) + afk_max_turns = _coerce_positive_int(parsed.get("afk_max_turns"), "afk_max_turns", default=10) + afk_time_budget_minutes = _coerce_positive_int( + parsed.get("afk_time_budget_minutes"), "afk_time_budget_minutes", default=60 + ) config = BachConfig( iterm_layout=layout, concurrency=concurrency, @@ -138,11 +185,22 @@ def load_config(path: Path | None = None) -> BachConfig: normalize_model=normalize_model, theme=theme, user_name=user_name, + watch_on_launch=watch_on_launch, + observer_moves=observer_moves, + judge_confidence_threshold=judge_confidence_threshold, + judge_interval_seconds=judge_interval_seconds, + liveness_running_seconds=liveness_running_seconds, + liveness_idle_minutes=liveness_idle_minutes, + afk_max_turns=afk_max_turns, + afk_time_budget_minutes=afk_time_budget_minutes, ) logger.info( "event=config_load source=file path=%s iterm_layout=%s concurrency=%d " - "runtime_default=%s normalize_model=%s theme=%s user_name=%s", + "runtime_default=%s normalize_model=%s theme=%s user_name=%s " + "watch_on_launch=%s observer_moves=%s judge_confidence_threshold=%s " + "judge_interval_seconds=%d liveness_running_seconds=%d " + "liveness_idle_minutes=%d afk_max_turns=%d afk_time_budget_minutes=%d", config_path, config.iterm_layout.value, config.concurrency, @@ -150,6 +208,14 @@ def load_config(path: Path | None = None) -> BachConfig: config.normalize_model, config.theme, config.user_name, + config.watch_on_launch, + config.observer_moves, + config.judge_confidence_threshold, + config.judge_interval_seconds, + config.liveness_running_seconds, + config.liveness_idle_minutes, + config.afk_max_turns, + config.afk_time_budget_minutes, ) return config @@ -310,3 +376,74 @@ def _coerce_user_name(value: Any) -> str: ) return "the user" return stripped + + +def _coerce_bool(value: Any, field: str, *, default: bool) -> bool: + """Accept a Python bool from YAML; anything else → default + warning. + + YAML parses `true`/`false` as Python bools, so the only valid inputs + are True/False. Strings like "maybe" or integers land here as non-bool + and must fall back — silently accepting 0/1 as booleans would be + confusing given that int fields use the same YAML file. + """ + if value is None: + return default + if not isinstance(value, bool): + logger.warning( + "event=config_invalid field=%s value=%r using=%s", + field, + value, + default, + ) + return default + return value + + +def _coerce_confidence(value: Any) -> float: + """Accept a float in [0.0, 1.0]; anything else → 0.7 + warning. + + Out-of-range thresholds would silently disable the judge (>1.0 → never + triggers) or make every verdict act (< 0.0 is nonsensical) — fall back + rather than pass through a semantically broken value. + """ + default: float = 0.7 + if value is None: + return default + # YAML parses plain integers as int, not float — accept both. + if isinstance(value, bool) or not isinstance(value, (int, float)): + logger.warning( + "event=config_invalid field=judge_confidence_threshold value=%r using=%s", + value, + default, + ) + return default + fval = float(value) + if fval < 0.0 or fval > 1.0: + logger.warning( + "event=config_invalid field=judge_confidence_threshold value=%r using=%s", + value, + default, + ) + return default + return fval + + +def _coerce_positive_int(value: Any, field: str, *, default: int) -> int: + """Accept a positive (>0) integer; anything else → default + warning. + + Mirrors _coerce_concurrency but is parameterised for reuse across the + Phase 16 int fields (judge_interval_seconds, liveness_*, afk_*). + bool is a subclass of int — explicitly reject it for the same reason + as _coerce_concurrency: True == 1 and False == 0 are silently wrong. + """ + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + logger.warning( + "event=config_invalid field=%s value=%r using=%d", + field, + value, + default, + ) + return default + return value diff --git a/src/bach/domain/models.py b/src/bach/domain/models.py index 603f0d7..ef11e10 100644 --- a/src/bach/domain/models.py +++ b/src/bach/domain/models.py @@ -19,9 +19,17 @@ Sync source) and ADR-013 for the design rationale (flat YAML + derived `unblocked`, no per-issue session lifecycle, ongoing-mirror sync semantics with `gh` CLI piggyback). + +SessionLiveness / SessionEventKind / SessionEvent / DiscoveredSession / +JudgeVerdict (Phase 16): + Session observability types. Consumed by runtimes/session_discovery.py + (discovery), runtimes/transcript.py (parsing), services/judge_service.py + (verdict), services/session_watcher.py (watcher loop). All are frozen + dataclasses or StrEnums — no behavior, no I/O. """ from dataclasses import dataclass, field +from datetime import datetime from enum import StrEnum from pathlib import Path @@ -193,3 +201,132 @@ class SyncSource: # conflict entirely. filter_criteria: dict[str, str] = field(default_factory=dict) last_synced_at: str | None = None # ISO 8601, set by github_service + + +# --------------------------------------------------------------------------- +# Phase 16 — Session observability domain types +# --------------------------------------------------------------------------- +# +# These types are consumed exclusively by observation-plane modules: +# - runtimes/session_discovery.py — produces DiscoveredSession lists +# - runtimes/transcript.py — parses lines into SessionEvent +# - services/judge_service.py — produces JudgeVerdict +# - services/session_watcher.py — reads liveness + events, applies verdicts +# +# The control plane (status writes, hook events, artifact mutations) NEVER +# depends on these types directly — they arrive via services with injected +# deps so authority checks (observer_moves, confidence threshold) are +# centralised. Keeping observation types in domain/models.py (the stable +# noun layer) lets all four modules import without circular deps. + + +class SessionLiveness(StrEnum): + """Coarse liveness bucket for a discovered session. + + Determined at discovery time by comparing transcript mtime and live + process table — see session_discovery.discover_sessions for the exact + thresholds (injected via liveness_running_seconds / liveness_idle_minutes + from BachConfig so tests don't need real wall-clock sleeps). + + running: transcript written within `liveness_running_seconds` — session + is actively producing output. + idle: matched process is alive BUT transcript is stale beyond the idle + threshold — agent may be waiting for user input or hung. + ended: no matching live process for this session ID — session is over. + """ + + running = "running" + idle = "idle" + ended = "ended" + + +class SessionEventKind(StrEnum): + """Normalized event kind from a parsed transcript line. + + Both Claude JSONL and Codex rollout formats have their own record-type + strings. transcript.parse_line maps them best-effort onto these values: + session_meta — session-level metadata (title, model, cwd, etc.) + user_turn — a turn authored by the human user + assistant_turn — a turn produced by the LLM + tool_call — the LLM invoked a tool / function + tool_result — the result of a tool invocation + end — explicit session-end marker + unknown — any record type that did not match a known pattern; + raw_kind on SessionEvent preserves the runtime string + for diagnostics. NEVER raise on unknown — preserve it. + """ + + session_meta = "session_meta" + user_turn = "user_turn" + assistant_turn = "assistant_turn" + tool_call = "tool_call" + tool_result = "tool_result" + end = "end" + unknown = "unknown" + + +@dataclass(frozen=True) +class SessionEvent: + """One normalized event from a parsed transcript line. + + Frozen because events are append-only facts — nothing should mutate a + parsed event after it is created. transcript.py is the sole producer. + + text is truncated to ~200 chars by the parser (see transcript.py) — + it is a human-readable preview for the judge's context window, NOT the + full structured content. Callers must not parse `text` as JSON. + + raw_kind preserves the runtime's own record-type string unchanged. + For kind=unknown this is the only diagnostic information available, so + it must always be populated (never None, even if the JSON is malformed). + """ + + kind: SessionEventKind + timestamp: datetime | None # None when the record has no timestamp field + text: str | None # best-effort preview, ~200 chars, None if not derivable + raw_kind: str # the runtime's own record type string, for diagnostics + + +@dataclass(frozen=True) +class DiscoveredSession: + """One live or recently-ended session found on disk. + + Frozen because discovery returns a point-in-time snapshot — callers + that need a fresher view must call discover_sessions again. + + project_dir is None when the slug-to-path decoding is ambiguous or when + the Codex session index does not record a working directory. Callers that + need a project_dir MUST handle None gracefully. + + last_activity is the transcript mtime at discovery time (None if the + transcript file was not stat-able, e.g. a race between discovery and + deletion). + """ + + runtime: AgentRuntime + session_id: str + transcript_path: Path + project_dir: Path | None # decoded working dir if derivable, else None + liveness: SessionLiveness + last_activity: datetime | None # transcript mtime; None on stat failure + + +@dataclass(frozen=True) +class JudgeVerdict: + """Output from services/judge_service.judge_session. + + Frozen because a verdict is an immutable assessment of a point-in-time + event window — callers must not mutate it. + + Confidence range [0.0, 1.0] is ENFORCED by judge_service (which returns + None on out-of-range values) — this dataclass deliberately has no + validation so the domain layer stays free of service-layer policy. + + suggested_status must be one of status_service.VALID_STATUSES; again, + validation is judge_service's responsibility, not the dataclass's. + """ + + summary: str # one-line, present-tense description of session state + suggested_status: str # must be in status_service.VALID_STATUSES + needs_human: bool # True → escalate to hitl_queue regardless of confidence + confidence: float # judge's self-assessed reliability, 0.0–1.0 diff --git a/src/bach/repl/app.py b/src/bach/repl/app.py index 53869c2..71cae37 100644 --- a/src/bach/repl/app.py +++ b/src/bach/repl/app.py @@ -68,6 +68,7 @@ ModePickerCancelled, pick_session_mode, ) +from bach.repl.sessions_view import cmd_sessions # Phase 16 wiring from bach.runtimes.codex_discovery import list_codex_models from bach.services.daily_index_service import ( DailyTask, @@ -199,9 +200,7 @@ def run(self) -> None: # correctly suggests `/project-add` / `/project-init` / `/projects`. # Returns None when prompt_toolkit isn't installed — completer # kwarg is then silently ignored downstream. - main_completer = _build_word_completer( - sorted(_HANDLERS.keys()), sentence=True - ) + main_completer = _build_word_completer(sorted(_HANDLERS.keys()), sentence=True) while not self._should_quit: _render_dashboard(self) @@ -233,9 +232,7 @@ def prompt(self, prompt_str: str, completer: Any = None) -> str: return self._prompt(prompt_str, completer=completer) def _prompt(self, prompt_str: str, completer: Any = None) -> str: - return self._input_fn( - prompt_str, completer=completer, session=self._get_session() - ) + return self._input_fn(prompt_str, completer=completer, session=self._get_session()) def _get_session(self) -> PromptSession[str] | None: """Lazy-build the prompt_toolkit session on first use. @@ -355,9 +352,7 @@ def _render_dashboard(app: BachRepl) -> None: # current_date_str is guaranteed non-None when view=="today" by # construction (init writes today's date; /day always writes a date). # Defensive fallback to today in case a future refactor breaks that. - _render_date_tasks_table( - app, app.current_date_str or datetime.now().strftime("%Y-%m-%d") - ) + _render_date_tasks_table(app, app.current_date_str or datetime.now().strftime("%Y-%m-%d")) def _render_date_tasks_table(app: BachRepl, date_str: str) -> None: @@ -372,8 +367,7 @@ def _render_date_tasks_table(app: BachRepl, date_str: str) -> None: m = app.current_theme.muted a = app.current_theme.accent app.console.print( - f"[{m}]No tasks for {date_str}. " - f"Try [{a}]/add[/{a}] or [{a}]/project-add[/{a}].[/{m}]" + f"[{m}]No tasks for {date_str}. Try [{a}]/add[/{a}] or [{a}]/project-add[/{a}].[/{m}]" ) return theme = app.current_theme @@ -413,8 +407,7 @@ def _render_all_tasks_table(app: BachRepl) -> None: m = app.current_theme.muted a = app.current_theme.accent app.console.print( - f"[{m}]No tasks yet across any project. " - f"Try [{a}]/add[/{a}] to create one.[/{m}]" + f"[{m}]No tasks yet across any project. Try [{a}]/add[/{a}] to create one.[/{m}]" ) return theme = app.current_theme @@ -550,9 +543,7 @@ def _get_task_by_index(app: BachRepl, index: int) -> DailyTask | None: ) if index < 1 or index > len(tasks): e = app.current_theme.error - app.console.print( - f"[{e}]Index {index} out of range.[/{e}] Tasks: 1..{len(tasks) or 1}" - ) + app.console.print(f"[{e}]Index {index} out of range.[/{e}] Tasks: 1..{len(tasks) or 1}") return None return tasks[index - 1] @@ -692,9 +683,7 @@ def _cmd_add(app: BachRepl, arg_str: str) -> None: # names can contain `-` / `.` / `/` (non-word chars under prompt_toolkit's # default word definition), and without sentence=True a name like # `my-project` would only be Tab-completable up to the `-`. - project_completer = _build_word_completer( - [p.name for p in projects.values()], sentence=True - ) + project_completer = _build_word_completer([p.name for p in projects.values()], sentence=True) project_name = app.prompt( f"Project ({', '.join(p.name for p in projects.values())}): ", completer=project_completer, @@ -704,10 +693,13 @@ def _cmd_add(app: BachRepl, arg_str: str) -> None: return # sentence=True so `claude-co` finds `claude-code` (the `-` is a # non-word char and breaks default word-before-cursor matching). - runtime_str = app.prompt( - "Runtime [claude-code]: ", - completer=_build_word_completer(["claude-code", "codex"], sentence=True), - ).strip() or "claude-code" + runtime_str = ( + app.prompt( + "Runtime [claude-code]: ", + completer=_build_word_completer(["claude-code", "codex"], sentence=True), + ).strip() + or "claude-code" + ) try: runtime = AgentRuntime(runtime_str) except ValueError: @@ -765,9 +757,7 @@ def _cmd_open(app: BachRepl, arg_str: str) -> None: return # task_service.launch_task picks resume_command or launch_command # based on whether runtime_session_id is set in the artifact. - TaskService(app.registry).launch_task( - task.artifact_path, output=app.console.print - ) + TaskService(app.registry).launch_task(task.artifact_path, output=app.console.print) def _cmd_normalize(app: BachRepl, arg_str: str) -> None: @@ -781,9 +771,7 @@ def _cmd_normalize(app: BachRepl, arg_str: str) -> None: task = _resolve_task(app, arg_str) if task is None: return - app.console.print( - f"[{t.muted}]Asking codex to extract fields for '{task.title}'…[/{t.muted}]" - ) + app.console.print(f"[{t.muted}]Asking codex to extract fields for '{task.title}'…[/{t.muted}]") try: proposal = propose_normalize(task.artifact_path) except FileNotFoundError: @@ -792,9 +780,7 @@ def _cmd_normalize(app: BachRepl, arg_str: str) -> None: ) return except subprocess.CalledProcessError as exc: - app.console.print( - f"[{t.error}]codex failed:[/{t.error}] {(exc.stderr or '')[:200]}" - ) + app.console.print(f"[{t.error}]codex failed:[/{t.error}] {(exc.stderr or '')[:200]}") return except subprocess.TimeoutExpired: app.console.print(f"[{t.error}]codex timed out.[/{t.error}] Try again.") @@ -803,9 +789,11 @@ def _cmd_normalize(app: BachRepl, arg_str: str) -> None: app.console.print(f"[{t.muted}]No changes proposed.[/{t.muted}]") return _render_normalize_diff(app, proposal) - confirm = app.prompt( - f"Apply? (dropped {proposal.dropped_for_safety} unsafe value(s)) [y/N]: " - ).strip().lower() + confirm = ( + app.prompt(f"Apply? (dropped {proposal.dropped_for_safety} unsafe value(s)) [y/N]: ") + .strip() + .lower() + ) if confirm not in ("y", "yes"): app.console.print(f"[{t.muted}]Aborted.[/{t.muted}]") return @@ -827,9 +815,7 @@ def _cmd_validate(app: BachRepl, arg_str: str) -> None: if not errors: app.console.print(f"[{t.success}]✓[/{t.success}] '{task.title}' is valid.") return - app.console.print( - f"[{t.error}]Validation errors for '{task.title}':[/{t.error}]" - ) + app.console.print(f"[{t.error}]Validation errors for '{task.title}':[/{t.error}]") for err in errors: app.console.print(f" • {err}") @@ -894,7 +880,9 @@ def _cmd_project_add(app: BachRepl, arg_str: str) -> None: init_str = app.prompt("Initialize .bach now? [Y/n]: ").strip().lower() init = init_str not in ("n", "no") project = ProjectService(app.registry).add_project( - name=name, path=Path(cleaned).expanduser(), initialize=init, + name=name, + path=Path(cleaned).expanduser(), + initialize=init, ) suffix = " (and initialized .bach)" if init else "" app.console.print( @@ -912,18 +900,14 @@ def _cmd_project_init(app: BachRepl, arg_str: str) -> None: t = app.current_theme name = arg_str.strip() if not name: - app.console.print( - f"[{t.error}]Usage:[/{t.error}] /project-init (see /projects)" - ) + app.console.print(f"[{t.error}]Usage:[/{t.error}] /project-init (see /projects)") return try: project = ProjectService(app.registry).init_project(name) except KeyError as exc: app.console.print(f"[{t.error}]Unknown project:[/{t.error}] {exc}") return - app.console.print( - f"[{t.success}]✓[/{t.success}] Initialized {project.path / '.bach'}" - ) + app.console.print(f"[{t.success}]✓[/{t.success}] Initialized {project.path / '.bach'}") # --- CONFIG ----------------------------------------------------------------- @@ -938,9 +922,7 @@ def _cmd_config(app: BachRepl, arg_str: str) -> None: catalog_slugs = {m.slug for m in list_codex_models()} warn = "" if config.normalize_model not in catalog_slugs: - warn = ( - f"\n[{t.warning}]⚠ Normalize model not in codex catalog.[/{t.warning}]" - ) + warn = f"\n[{t.warning}]⚠ Normalize model not in codex catalog.[/{t.warning}]" app.console.print( f"[bold]Bach config[/bold]\n" f" Source: {source}\n" @@ -992,11 +974,7 @@ def _cmd_theme(app: BachRepl, arg_str: str) -> None: # a live sample of what the accent will look like after switching. # Description and "← current" marker use the ACTIVE theme's muted # so chrome text stays consistent regardless of what's listed. - marker = ( - f" [{cur.muted}]← current[/{cur.muted}]" - if t_name == current_name - else "" - ) + marker = f" [{cur.muted}]← current[/{cur.muted}]" if t_name == current_name else "" app.console.print( f" [{t.accent}]{idx}[/{t.accent}]) " f"[bold]{t_name}[/bold] " @@ -1016,9 +994,7 @@ def _cmd_theme(app: BachRepl, arg_str: str) -> None: if 1 <= choice <= len(names): arg = names[choice - 1] else: - app.console.print( - f"[{cur.error}]Choice out of range:[/{cur.error}] {choice}" - ) + app.console.print(f"[{cur.error}]Choice out of range:[/{cur.error}] {choice}") return except ValueError: arg = choice_str # treat as name, fall through to validation below @@ -1027,9 +1003,7 @@ def _cmd_theme(app: BachRepl, arg_str: str) -> None: new_theme = get_theme(arg) if new_theme is None: valid = ", ".join(THEMES.keys()) - app.console.print( - f"[{cur.error}]Unknown theme:[/{cur.error}] {arg!r}. Valid: {valid}" - ) + app.console.print(f"[{cur.error}]Unknown theme:[/{cur.error}] {arg!r}. Valid: {valid}") return app.current_theme = new_theme @@ -1047,9 +1021,7 @@ def _cmd_set_model(app: BachRepl, arg_str: str) -> None: t = app.current_theme slug = arg_str.strip() if not slug: - app.console.print( - f"[{t.error}]Usage:[/{t.error}] /set-model (use /models to list)" - ) + app.console.print(f"[{t.error}]Usage:[/{t.error}] /set-model (use /models to list)") return catalog = list_codex_models() valid = {m.slug for m in catalog} @@ -1058,9 +1030,7 @@ def _cmd_set_model(app: BachRepl, arg_str: str) -> None: app.console.print(f"Valid: {', '.join(sorted(valid))}") return write_config_field("normalize_model", slug) - app.console.print( - f"[{t.success}]✓[/{t.success}] Set [bold]normalize_model = {slug}[/bold]" - ) + app.console.print(f"[{t.success}]✓[/{t.success}] Set [bold]normalize_model = {slug}[/bold]") # --- DAY VIEW --------------------------------------------------------------- @@ -1155,10 +1125,13 @@ def _cmd_delete(app: BachRepl, arg_str: str) -> None: task = _resolve_task(app, arg_str) if task is None: return - confirm = app.prompt( - f"Delete task '{task.title}' from {task.date} " - f"(project: {task.project_name})? [y/N]: " - ).strip().lower() + confirm = ( + app.prompt( + f"Delete task '{task.title}' from {task.date} (project: {task.project_name})? [y/N]: " + ) + .strip() + .lower() + ) t = app.current_theme if confirm not in ("y", "yes"): logger.info("event=task_delete_cancelled path=%s", task.artifact_path) @@ -1193,9 +1166,7 @@ def _cmd_delete(app: BachRepl, arg_str: str) -> None: ) -def _set_status_handler( - app: BachRepl, arg_str: str, new_status: str -) -> None: +def _set_status_handler(app: BachRepl, arg_str: str, new_status: str) -> None: """Shared helper for /done /skip and the Pocock-journey direct setters. Each "set status X" command is the same code modulo the target @@ -1215,18 +1186,13 @@ def _set_status_handler( app.console.print(f"[{t.error}]{exc}[/{t.error}]") return except FileNotFoundError: - app.console.print( - f"[{t.error}]Artifact missing:[/{t.error}] {task.artifact_path}" - ) + app.console.print(f"[{t.error}]Artifact missing:[/{t.error}] {task.artifact_path}") return if result.old == result.new: - app.console.print( - f"[{t.muted}]Already {new_status}.[/{t.muted}] task: '{task.title}'" - ) + app.console.print(f"[{t.muted}]Already {new_status}.[/{t.muted}] task: '{task.title}'") return app.console.print( - f"[{t.success}]✓[/{t.success}] {result.old} → {result.new} " - f"task: '{task.title}'" + f"[{t.success}]✓[/{t.success}] {result.old} → {result.new} task: '{task.title}'" ) # Phase 10: render the trailing hint line ("Suggested next: …") so # the user immediately sees workflow-aligned options after every @@ -1240,7 +1206,8 @@ def _set_status_handler( except Exception as exc: # noqa: BLE001 logger.warning( "event=task_status_change_regen_failed date=%s reason=%s", - task.date, type(exc).__name__, + task.date, + type(exc).__name__, ) @@ -1335,9 +1302,7 @@ def _cmd_move(app: BachRepl, arg_str: str) -> None: current = task.status suggestions = SUGGESTED_NEXT_STEPS.get(current, []) - app.console.print( - f"[bold]Currently:[/bold] [{t.accent}]{current}[/{t.accent}]" - ) + app.console.print(f"[bold]Currently:[/bold] [{t.accent}]{current}[/{t.accent}]") app.console.print("Pick a new status:") for idx, opt in enumerate(suggestions, start=1): app.console.print( @@ -1345,8 +1310,7 @@ def _cmd_move(app: BachRepl, arg_str: str) -> None: ) other_index = len(suggestions) + 1 app.console.print( - f" [{t.accent}]{other_index}[/{t.accent}]) " - f"[{t.muted}][/{t.muted}]" + f" [{t.accent}]{other_index}[/{t.accent}]) [{t.muted}][/{t.muted}]" ) try: @@ -1371,14 +1335,10 @@ def _cmd_move(app: BachRepl, arg_str: str) -> None: elif choice == other_index: # User wants something not in the suggestions. Show the full enum # (excluding current and already-suggested) and ask again. - other = sorted( - s for s in VALID_STATUSES if s != current and s not in suggestions - ) + other = sorted(s for s in VALID_STATUSES if s != current and s not in suggestions) if not other: # Edge case: suggestions cover everything; nothing else to pick. - app.console.print( - f"[{t.warning}]No other statuses available.[/{t.warning}]" - ) + app.console.print(f"[{t.warning}]No other statuses available.[/{t.warning}]") return app.console.print("Other statuses:") for idx, opt in enumerate(other, start=1): @@ -1443,8 +1403,7 @@ def _cmd_log(app: BachRepl, arg_str: str) -> None: log_path = bach_home() / "runs" / f"{date_str}.md" if not log_path.exists(): app.console.print( - f"[{t.warning}]No index file yet.[/{t.warning}] " - "Run /refresh or create a task first." + f"[{t.warning}]No index file yet.[/{t.warning}] Run /refresh or create a task first." ) return rc = open_in_editor(log_path) @@ -1632,7 +1591,11 @@ def _cmd_help(app: BachRepl, arg_str: str) -> None: def _render_command_help( - app: BachRepl, name: str, entry: CommandHelp, accent: str, muted: str, + app: BachRepl, + name: str, + entry: CommandHelp, + accent: str, + muted: str, ) -> None: """Pretty-print a CommandHelp record as a structured help page. @@ -1643,8 +1606,7 @@ def _render_command_help( """ # Header: command name in accent + tagline. app.console.print( - f"[bold {accent}]/{name}[/bold {accent}] " - f"[{muted}]{entry.synopsis}[/{muted}]" + f"[bold {accent}]/{name}[/bold {accent}] [{muted}]{entry.synopsis}[/{muted}]" ) if entry.aliases: alias_str = " ".join(f"/{a}" for a in entry.aliases) @@ -1692,8 +1654,11 @@ def _render_command_help( def _render_category_listing( - app: BachRepl, category: str, commands: tuple[str, ...], - accent: str, muted: str, + app: BachRepl, + category: str, + commands: tuple[str, ...], + accent: str, + muted: str, ) -> None: """Render a category overview when no dedicated _HELP_TOPIC_FNS handler exists. @@ -1707,12 +1672,9 @@ def _render_category_listing( entry = COMMAND_HELP.get(cmd) if entry is None: continue - app.console.print( - f" [cyan]/{cmd}[/cyan] [{muted}]{entry.synopsis}[/{muted}]" - ) + app.console.print(f" [cyan]/{cmd}[/cyan] [{muted}]{entry.synopsis}[/{muted}]") app.console.print( - f"[{muted}]Type [bold]/help [/bold] for details " - f"(e.g. /help normalize).[/{muted}]" + f"[{muted}]Type [bold]/help [/bold] for details (e.g. /help normalize).[/{muted}]" ) @@ -1734,12 +1696,9 @@ def _cmd_workflow(app: BachRepl, arg_str: str) -> None: # 1. Title + thesis. app.console.print( - f"[bold {a}]Bach workflow[/bold {a}] " - f"[{m}]— Pocock agentic-coding pipeline[/{m}]" - ) - app.console.print( - f' [{m}]"Do not delegate judgment. Delegate bounded execution."[/{m}]' + f"[bold {a}]Bach workflow[/bold {a}] [{m}]— Pocock agentic-coding pipeline[/{m}]" ) + app.console.print(f' [{m}]"Do not delegate judgment. Delegate bounded execution."[/{m}]') app.console.print() # 2. Stage table. Manual alignment (not Rich Table) for a denser look @@ -1755,11 +1714,7 @@ def _cmd_workflow(app: BachRepl, arg_str: str) -> None: # Stage in accent so the eye anchors on it; skill list in default # colour (so /command tokens read naturally); cue in muted for # de-emphasis (it's commentary, not action). - app.console.print( - f" [{a}]{stage:<{stage_w}}[/{a}] " - f"{skills:<{skill_w}} " - f"[{m}]{cue}[/{m}]" - ) + app.console.print(f" [{a}]{stage:<{stage_w}}[/{a}] {skills:<{skill_w}} [{m}]{cue}[/{m}]") app.console.print() # 3. Direct status setters — the keyboard-shortcut layer. @@ -1860,6 +1815,8 @@ def _cmd_quit(app: BachRepl, arg_str: str) -> None: "/log": _cmd_log, # Board (Phase 11) — handler lives in repl/board.py "/board": cmd_board, + # Sessions (Phase 16) — handler lives in repl/sessions_view.py + "/sessions": cmd_sessions, # REPL control "/help": _cmd_help, "/workflow": _cmd_workflow, diff --git a/src/bach/repl/help_content.py b/src/bach/repl/help_content.py index c55f434..c191819 100644 --- a/src/bach/repl/help_content.py +++ b/src/bach/repl/help_content.py @@ -94,17 +94,17 @@ class CommandHelp: # Pocock-faithful naming rule. Layering custom skills can happen later as # a separate command (e.g. `/workflow personal`). WORKFLOW_STAGES: tuple[tuple[str, str, str], ...] = ( - ("inbox", "/grill-me /grill-with-docs", "Raw idea; clarify intent"), - ("research", "/deep-research", "Read existing material; gather sources"), - ("prototype", "/prototype", "Throwaway spike to learn before the PRD"), - ("prd", "/to-prd /to-issues", "Destination doc; then breakdown"), - ("kanban_issues", "—", "Parent tracks; children carry the action"), - ("afk_queue", "/tdd", "Ready for unattended execution"), - ("hitl_queue", "/prototype /tdd", "Needs human judgment / taste"), - ("executing", "/tdd /diagnose /zoom-out", "Active engineering"), - ("qa", "/review", "Fresh-context review + manual QA"), - ("done", "—", "Verified complete"), - ("carried_forward", "/grill-me", "Re-evaluate before re-queueing"), + ("inbox", "/grill-me /grill-with-docs", "Raw idea; clarify intent"), + ("research", "/deep-research", "Read existing material; gather sources"), + ("prototype", "/prototype", "Throwaway spike to learn before the PRD"), + ("prd", "/to-prd /to-issues", "Destination doc; then breakdown"), + ("kanban_issues", "—", "Parent tracks; children carry the action"), + ("afk_queue", "/tdd", "Ready for unattended execution"), + ("hitl_queue", "/prototype /tdd", "Needs human judgment / taste"), + ("executing", "/tdd /diagnose /zoom-out", "Active engineering"), + ("qa", "/review", "Fresh-context review + manual QA"), + ("done", "—", "Verified complete"), + ("carried_forward", "/grill-me", "Re-evaluate before re-queueing"), ) @@ -116,13 +116,24 @@ class CommandHelp: CATEGORY_COMMANDS: dict[str, tuple[str, ...]] = { "tasks": ("add", "list", "open", "show", "edit", "delete", "normalize", "validate"), "status": ( - "move", "research", "prototype", "prd", "kanban", - "afk", "hitl", "run", "qa", "done", "skip", + "move", + "research", + "prototype", + "prd", + "kanban", + "afk", + "hitl", + "run", + "qa", + "done", + "skip", ), "projects": ("projects", "project-add", "project-init"), "config": ("config", "models", "set-model", "theme"), "daily": ("refresh", "day", "log"), "board": ("board",), + # Phase 16: session observability command. + "sessions": ("sessions",), "repl": ("help", "workflow", "clear", "quit"), } @@ -135,6 +146,8 @@ class CommandHelp: "config": "Configuration", "daily": "Daily view", "board": "Kanban board", + # Phase 16: session observability category. + "sessions": "Session observability", "repl": "REPL control", } @@ -144,11 +157,9 @@ class CommandHelp: # --------------------------------------------------------------------------- COMMAND_HELP: dict[str, CommandHelp] = { - # ===================================================================== # Task management # ===================================================================== - "add": CommandHelp( synopsis="Create a new task artifact via sequential prompts.", syntax="/add", @@ -178,7 +189,6 @@ class CommandHelp: related=("open", "list", "show"), aliases=("a",), ), - "list": CommandHelp( synopsis="Switch the dashboard to all-time view (every project, every date).", syntax="/list", @@ -203,7 +213,6 @@ class CommandHelp: related=("day", "board", "open"), aliases=("l",), ), - "open": CommandHelp( synopsis="Launch or resume the task's session in iTerm (DTRT).", syntax="/open ", @@ -220,14 +229,10 @@ class CommandHelp: "Anytime you want to start or continue work on a task. This " "is the most-used command after /add." ), - example=( - "bach > /open t47\n" - "✓ Opened in iTerm tab — cd '/path/to/skillia' && claude ..." - ), + example=("bach > /open t47\n✓ Opened in iTerm tab — cd '/path/to/skillia' && claude ..."), related=("add", "show", "edit"), aliases=("o",), ), - "show": CommandHelp( synopsis="Render a task's artifact (frontmatter + body) inline in the REPL.", syntax="/show ", @@ -253,7 +258,6 @@ class CommandHelp: related=("edit", "validate", "normalize"), aliases=("s",), ), - "edit": CommandHelp( synopsis="Open the task's artifact in $EDITOR for free-form editing.", syntax="/edit ", @@ -271,14 +275,11 @@ class CommandHelp: "blocked_by lists by hand." ), example=( - "bach > /edit t47\n" - "(opens task-*.md in $EDITOR)\n" - "[dim]Editor returned (code 0).[/dim]" + "bach > /edit t47\n(opens task-*.md in $EDITOR)\n[dim]Editor returned (code 0).[/dim]" ), related=("show", "normalize", "validate"), aliases=("e",), ), - "delete": CommandHelp( synopsis="Remove a task's artifact file (with confirmation).", syntax="/delete ", @@ -304,7 +305,6 @@ class CommandHelp: ), related=("skip", "edit"), ), - "normalize": CommandHelp( synopsis="Propose extracted task fields from the body and apply on confirm.", syntax="/normalize ", @@ -332,7 +332,6 @@ class CommandHelp: related=("validate", "edit", "show"), aliases=("n",), ), - "validate": CommandHelp( synopsis="Print validation errors for a task without changing it.", syntax="/validate ", @@ -359,11 +358,9 @@ class CommandHelp: related=("normalize", "edit"), aliases=("v",), ), - # ===================================================================== # Status / workflow stage # ===================================================================== - "move": CommandHelp( synopsis="Interactive status picker — current + suggested next + escape hatch.", syntax="/move ", @@ -395,7 +392,6 @@ class CommandHelp: ), related=("prd", "kanban", "afk", "hitl", "run", "qa", "done", "skip"), ), - "research": CommandHelp( synopsis="Move a task into the research stage (status=research).", syntax="/research ", @@ -419,7 +415,6 @@ class CommandHelp: ), related=("prototype", "prd", "move"), ), - "prototype": CommandHelp( synopsis="Move a task into the prototype stage (status=prototype).", syntax="/prototype ", @@ -444,7 +439,6 @@ class CommandHelp: ), related=("research", "prd", "move"), ), - "prd": CommandHelp( synopsis="Move a task into the PRD-drafting stage (status=prd).", syntax="/prd ", @@ -469,7 +463,6 @@ class CommandHelp: ), related=("kanban", "afk", "hitl", "move"), ), - "kanban": CommandHelp( synopsis="Mark a task as broken into child tasks (status=kanban_issues).", syntax="/kanban ", @@ -492,7 +485,6 @@ class CommandHelp: ), related=("prd", "afk", "hitl", "move"), ), - "afk": CommandHelp( synopsis="Mark a task as safe for unattended execution (status=afk_queue).", syntax="/afk ", @@ -518,7 +510,6 @@ class CommandHelp: ), related=("hitl", "run", "move"), ), - "hitl": CommandHelp( synopsis="Mark a task as requiring human-in-the-loop (status=hitl_queue).", syntax="/hitl ", @@ -543,7 +534,6 @@ class CommandHelp: ), related=("afk", "run", "move"), ), - "run": CommandHelp( synopsis="Mark a task as actively being worked (status=executing).", syntax="/run ", @@ -567,7 +557,6 @@ class CommandHelp: ), related=("qa", "done", "move"), ), - "qa": CommandHelp( synopsis="Move a task into review (status=qa).", syntax="/qa ", @@ -580,9 +569,7 @@ class CommandHelp: "QA findings should become new tasks (via /add) rather than " "modify the original — Matt's pattern." ), - when=( - "After implementation completes, before merging or shipping." - ), + when=("After implementation completes, before merging or shipping."), example=( "bach > /qa t47\n" "✓ executing → qa task: 'Fix OAuth bug'\n" @@ -590,7 +577,6 @@ class CommandHelp: ), related=("done", "run", "skip", "move"), ), - "done": CommandHelp( synopsis="Mark a task as verified-complete (status=done).", syntax="/done ", @@ -607,13 +593,9 @@ class CommandHelp: "When you've verified the work is complete via QA — tests " "pass, manual QA passes, taste is satisfied." ), - example=( - "bach > /done t47\n" - "✓ qa → done task: 'Fix OAuth bug'" - ), + example=("bach > /done t47\n✓ qa → done task: 'Fix OAuth bug'"), related=("qa", "skip", "move"), ), - "skip": CommandHelp( synopsis="Carry a task forward to another day (status=carried_forward).", syntax="/skip ", @@ -636,11 +618,9 @@ class CommandHelp: ), related=("done", "move"), ), - # ===================================================================== # Project registry # ===================================================================== - "projects": CommandHelp( synopsis="List all registered projects (key + path).", syntax="/projects", @@ -663,7 +643,6 @@ class CommandHelp: ), related=("project-add", "project-init"), ), - "project-add": CommandHelp( synopsis="Register a new project (with drag-and-drop path support).", syntax="/project-add", @@ -690,7 +669,6 @@ class CommandHelp: related=("projects", "project-init"), aliases=("p-add",), ), - "project-init": CommandHelp( synopsis="Initialize .bach/ for an already-registered project.", syntax="/project-init ", @@ -702,20 +680,14 @@ class CommandHelp: "registered with init=False and now want it' edge case." ), when=( - "If you registered a project without initialization and now " - "want to add tasks to it." - ), - example=( - "bach > /project-init skillia\n" - "✓ Initialized /Users/jordi/.../skillia/.bach" + "If you registered a project without initialization and now want to add tasks to it." ), + example=("bach > /project-init skillia\n✓ Initialized /Users/jordi/.../skillia/.bach"), related=("project-add", "projects"), ), - # ===================================================================== # Configuration # ===================================================================== - "config": CommandHelp( synopsis="Print effective Bach configuration.", syntax="/config", @@ -745,7 +717,6 @@ class CommandHelp: ), related=("models", "set-model", "theme"), ), - "models": CommandHelp( synopsis="List Codex models available for normalize (with current marked).", syntax="/models", @@ -757,8 +728,7 @@ class CommandHelp: "Read-only. Use /set-model to change selection." ), when=( - "Before changing the normalize model with /set-model, or to " - "verify codex is reachable." + "Before changing the normalize model with /set-model, or to verify codex is reachable." ), example=( "bach > /models\n" @@ -769,7 +739,6 @@ class CommandHelp: ), related=("set-model", "config"), ), - "set-model": CommandHelp( synopsis="Set the Codex model used for /normalize.", syntax="/set-model ", @@ -784,14 +753,10 @@ class CommandHelp: "When you want /normalize to use a different model (e.g. a " "stronger one for tricky extraction)." ), - example=( - "bach > /set-model gpt-5.4\n" - "✓ Set normalize_model = gpt-5.4" - ), + example=("bach > /set-model gpt-5.4\n✓ Set normalize_model = gpt-5.4"), related=("models", "config"), aliases=("sm",), ), - "theme": CommandHelp( synopsis="Switch Bach's visual theme (status colors + chrome palette).", syntax="/theme []", @@ -818,11 +783,9 @@ class CommandHelp: related=("config",), aliases=("th",), ), - # ===================================================================== # Daily view # ===================================================================== - "refresh": CommandHelp( synopsis="Re-render dashboard + regenerate the daily index file.", syntax="/refresh", @@ -839,14 +802,10 @@ class CommandHelp: "session wrote back), or when you suspect the daily index " "file is stale." ), - example=( - "bach > /refresh\n" - "✓ Refreshed." - ), + example=("bach > /refresh\n✓ Refreshed."), related=("log", "day"), aliases=("r",), ), - "day": CommandHelp( synopsis="Switch the view to a specific date, today, or all-time.", syntax="/day [YYYY-MM-DD | all]", @@ -869,14 +828,10 @@ class CommandHelp: "browsing." ), example=( - "bach > /day 2026-05-23\n" - "✓ Viewing 2026-05-23.\n" - "bach > /day\n" - "✓ Viewing 2026-05-25." + "bach > /day 2026-05-23\n✓ Viewing 2026-05-23.\nbach > /day\n✓ Viewing 2026-05-25." ), related=("list", "board", "refresh"), ), - "log": CommandHelp( synopsis="Open today's daily index file in $EDITOR.", syntax="/log", @@ -894,17 +849,12 @@ class CommandHelp: "End of day, or when reviewing yesterday's work — the index " "is the cross-project summary." ), - example=( - "bach > /log\n" - "(opens ~/.bach/runs/2026-05-25.md in $EDITOR)" - ), + example=("bach > /log\n(opens ~/.bach/runs/2026-05-25.md in $EDITOR)"), related=("refresh", "day"), ), - # ===================================================================== # Kanban board (Phase 11) # ===================================================================== - "board": CommandHelp( synopsis="Show the WIP kanban board across all projects (sticky view).", syntax="/board [] [all]", @@ -934,11 +884,9 @@ class CommandHelp: related=("list", "day", "move"), aliases=("b",), ), - # ===================================================================== # REPL control # ===================================================================== - "help": CommandHelp( synopsis="Print the command list or detailed help for one command.", syntax="/help [] []", @@ -956,9 +904,7 @@ class CommandHelp: "Aliases work — `/help n` shows /normalize. The leading slash " "is optional: `/help /normalize` works too." ), - when=( - "Anytime. `/help` to scan; `/help ` to dive into one." - ), + when=("Anytime. `/help` to scan; `/help ` to dive into one."), example=( "bach > /help\n" "(full overview)\n" @@ -969,7 +915,6 @@ class CommandHelp: ), related=("quit", "clear"), ), - "workflow": CommandHelp( synopsis="Print the Pocock workflow cheatsheet — stages, skills, and vault deep-link.", syntax="/workflow", @@ -998,7 +943,6 @@ class CommandHelp: related=("help", "move", "prd", "kanban"), aliases=("w",), ), - "clear": CommandHelp( synopsis="Wipe the terminal viewport AND scrollback.", syntax="/clear", @@ -1010,18 +954,11 @@ class CommandHelp: "After this, the next loop iteration's dashboard re-renders " "fresh at the top — no command needed." ), - when=( - "When accumulated output makes the dashboard hard to read, or " - "before a screenshot." - ), - example=( - "bach > /clear\n" - "(terminal wipes; dashboard renders fresh)" - ), + when=("When accumulated output makes the dashboard hard to read, or before a screenshot."), + example=("bach > /clear\n(terminal wipes; dashboard renders fresh)"), related=("refresh",), aliases=("c",), ), - "quit": CommandHelp( synopsis="Exit the REPL.", syntax="/quit", @@ -1036,13 +973,39 @@ class CommandHelp: "(history, daily index, artifacts) is already saved — quitting " "is safe at any time." ), - example=( - "bach > /quit\n" - "bye." - ), + example=("bach > /quit\nbye."), related=("help",), aliases=("q",), ), + # ===================================================================== + # Session observability (Phase 16) + # ===================================================================== + "sessions": CommandHelp( + synopsis="Show live and recent agent sessions (runtime-plane radar).", + syntax="/sessions", + args="(none — use `bach sessions list --state/--runtime/--project` for filters)", + what=( + "Scans ~/.claude/projects/ and ~/.codex/sessions/ to find all " + "active and recently-ended Claude Code / Codex sessions. Shows " + "each session's ID, runtime, liveness state (running/idle/ended), " + "linked Bach task ID (if any), project name, and a short preview " + "of the last agent message.\n" + "Read-only. Use `bach sessions adopt` to link a session to a task " + "or `bach sessions resume` to reattach." + ), + when=( + "When you want to see what is currently running across all projects " + "without switching windows, or to find an orphan session to link " + "to a Bach task." + ), + example=( + "bach > /sessions\n" + "Session Runtime State Task Project Preview\n" + "a1b2c3d4e5f6 claude-code running t47 bach Working on the...\n" + "99887766aabb codex ended — skillia —" + ), + related=("board", "open"), + ), } @@ -1054,7 +1017,8 @@ class CommandHelp: def resolve_help_target( - target: str, aliases: dict[str, str], + target: str, + aliases: dict[str, str], ) -> tuple[str, CommandHelp] | tuple[str, tuple[str, ...]] | None: """Look up a help target by name. diff --git a/src/bach/repl/sessions_view.py b/src/bach/repl/sessions_view.py new file mode 100644 index 0000000..bde8ce8 --- /dev/null +++ b/src/bach/repl/sessions_view.py @@ -0,0 +1,138 @@ +"""REPL /sessions view — thin wrapper around sessions_service.list_sessions. + +Renders discovered sessions inline in the REPL using the same theme and +Rich table style as other REPL views. Keeps the REPL app.py handler +(which calls cmd_sessions) as small as possible — the table-building +logic lives here. + +Design notes: + - Uses the same injected architecture as the service so tests can + provide fake discover_fn / read_events_fn without real disk state. + - Production path (cmd_sessions) uses the live defaults from + cli/sessions.py helpers — imported locally to avoid circular deps. + - All columns truncate gracefully so wide session IDs don't break + the table layout. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from rich.console import Console +from rich.table import Table + +from bach.config.themes import ThemePalette +from bach.domain.models import DiscoveredSession +from bach.services.sessions_service import SessionRow + +logger = logging.getLogger(__name__) + +# Liveness color mapping — consistent with cli/sessions.py table. +_LIVENESS_COLORS = { + "running": "success", # resolved at render time via theme + "idle": "warning", + "ended": "muted", +} + + +def render_sessions_table( + rows: list[SessionRow], + *, + console: Console, + theme: ThemePalette, +) -> None: + """Render a list of SessionRow values as a Rich table. + + Empty rows list → prints a muted "no sessions" message. + Called by the REPL /sessions handler and by the CLI list command + when the user prefers REPL-style output. + """ + if not rows: + console.print(f"[{theme.muted}]no sessions found[/{theme.muted}]") + return + + table = Table(show_header=True, header_style="bold", highlight=False) + table.add_column("Session", style="dim", max_width=14) + table.add_column("Runtime", max_width=12) + table.add_column("State", max_width=10) + table.add_column("Task", max_width=8) + table.add_column("Project", max_width=20) + table.add_column("Preview", max_width=44) + + for row in rows: + s = row.session + liveness_val = s.liveness.value + # Resolve theme color for liveness state. + color_attr = _LIVENESS_COLORS.get(liveness_val, "muted") + color = getattr(theme, color_attr, theme.muted) + + sid_short = s.session_id[:12] + project_name = s.project_dir.name if s.project_dir else "—" + task_display = row.task_id or "—" + preview_text = row.preview[:44] if row.preview else "—" + + table.add_row( + sid_short, + s.runtime.value, + f"[{color}]{liveness_val}[/{color}]", + task_display, + project_name, + preview_text, + ) + + console.print(table) + logger.debug("event=sessions_table_rendered rows=%d", len(rows)) + + +def cmd_sessions(app: Any, _args: str) -> None: + """/sessions REPL command handler. + + Discovers live sessions and renders them inline. No filters — the REPL + command is a quick-look tool; detailed filtering belongs to the CLI + `bach sessions list` command. + + `app` is typed as object to avoid importing BachRepl here (circular dep). + The duck-typed attribute access (app.console, app.current_theme) is safe: + BachRepl guarantees these attributes exist and the REPL router only calls + handlers with a valid BachRepl instance. + """ + from datetime import UTC, datetime + + from bach.config.paths import bach_home + from bach.config.settings import load_config + from bach.runtimes.session_discovery import default_process_probe, discover_sessions + from bach.runtimes.transcript import read_tail_events + from bach.services.sessions_service import list_sessions + + cfg = load_config() + # app is typed as Any to avoid a circular import with repl/app.py. + # BachRepl guarantees console + current_theme exist; the router only + # calls handlers with a valid BachRepl instance. + console: Console = app.console + theme: ThemePalette = app.current_theme + + def _discover() -> list[DiscoveredSession]: + return discover_sessions( + claude_projects_dir=Path.home() / ".claude" / "projects", + codex_home=Path.home() / ".codex", + process_probe=default_process_probe, + now=datetime.now(UTC), + running_threshold_s=cfg.liveness_running_seconds, + idle_threshold_s=cfg.liveness_idle_minutes * 60, + ) + + try: + rows = list_sessions( + sessions_dir=bach_home() / "sessions", + artifacts_dir=bach_home(), + discover_fn=_discover, + read_events_fn=read_tail_events, + ) + except Exception as exc: + logger.warning("event=repl_sessions_error reason=%s", exc) + console.print(f"[{theme.error}]sessions error:[/{theme.error}] {exc}") + return + + render_sessions_table(rows, console=console, theme=theme) diff --git a/src/bach/runtimes/session_discovery.py b/src/bach/runtimes/session_discovery.py new file mode 100644 index 0000000..4af5800 --- /dev/null +++ b/src/bach/runtimes/session_discovery.py @@ -0,0 +1,415 @@ +"""On-disk session layout knowledge for Claude Code and Codex. + +This is THE ONLY module that knows where each runtime stores its session +transcripts. All callers receive DiscoveredSession records; they never +need to know the underlying directory structure. + +Claude Code layout: + //.jsonl + Slug: the project's absolute path with every '/' replaced by '-'. + Decoding is best-effort — a slug that doesn't start with '-' has no + unambiguous root and yields project_dir=None. + +Codex layout: + /sessions/YYYY/MM/DD/rollout--.jsonl + /session_index.jsonl (may lag the directory scan) + The index is informational: it enriches discovered sessions with + project_dir but never gates session existence. A session on disk + without an index entry is still a valid session. + +Liveness algorithm (all thresholds are injected, NOT from wall clock): + 1. Transcript mtime within running_threshold_s of `now` → running. + (Fresh output means the agent is actively writing.) + 2. Process table contains a matching runtime binary AND transcript is + stale beyond idle_threshold_s → idle. + (Agent is alive but not producing output — may be waiting.) + 3. Otherwise → ended. + (No matching process and transcript is old.) + +All injected callables (process_probe, now) prevent this module from +touching real OS state in unit tests. +""" + +import json +import logging +import subprocess +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path + +from bach.domain.models import AgentRuntime, DiscoveredSession, SessionLiveness + +logger = logging.getLogger(__name__) + +# Type alias: callable that returns [(pid, command_line)] of live agent processes. +ProcessProbe = Callable[[], list[tuple[int, str]]] + +# Binary name substrings used to match live processes to a runtime. +# We match by substring because the full path varies by install method +# (homebrew, npm, direct binary, etc.). +_CLAUDE_BINARY_MARKER = "claude" +_CODEX_BINARY_MARKER = "codex" + +# Maximum age (in seconds) before we classify a session as ended regardless of +# whether a matching runtime process is alive machine-wide. The process probe +# checks for ANY claude/codex process — it is not per-session. A 2-day-old +# transcript almost certainly belongs to a different session than whatever +# process is currently running; beyond this cap we presume the session ended. +IDLE_MAX_AGE_S: int = 86400 # 24 hours + + +def default_process_probe() -> list[tuple[int, str]]: + """Return [(pid, command_line)] for all currently live processes. + + Uses `ps -axo pid,command` — POSIX-compatible, no BSD-only flags. + Falls back to an empty list if `ps` fails or is unavailable so the + caller's liveness logic degrades to `ended` rather than crashing. + """ + try: + result = subprocess.run( + ["ps", "-axo", "pid,command"], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + logger.warning("event=process_probe_failed reason=%s", type(exc).__name__) + return [] + + processes: list[tuple[int, str]] = [] + for line in result.stdout.splitlines()[1:]: # skip header + parts = line.strip().split(None, 1) + if len(parts) == 2: + try: + processes.append((int(parts[0]), parts[1])) + except ValueError: + # Malformed line — skip it rather than crash. + continue + return processes + + +def discover_sessions( + *, + claude_projects_dir: Path, + codex_home: Path, + process_probe: ProcessProbe, + now: datetime, + running_threshold_s: int, + idle_threshold_s: int, +) -> list[DiscoveredSession]: + """Scan both runtime stores and return point-in-time session snapshots. + + All filesystem paths are injected — callers decide where each runtime + stores data. This keeps tests fully hermetic without touching real + ~/.claude or ~/.codex directories. + + The process_probe is called ONCE and its result is shared across all + liveness checks to avoid inconsistent process-table reads mid-scan. + + Returns an empty list when no sessions exist or when the directories + do not exist — never raises. + """ + # Call the probe once; share the snapshot across all liveness checks. + live_processes = process_probe() + + sessions: list[DiscoveredSession] = [] + sessions.extend( + _scan_claude( + claude_projects_dir, + live_processes=live_processes, + now=now, + running_threshold_s=running_threshold_s, + idle_threshold_s=idle_threshold_s, + ) + ) + sessions.extend( + _scan_codex( + codex_home, + live_processes=live_processes, + now=now, + running_threshold_s=running_threshold_s, + idle_threshold_s=idle_threshold_s, + ) + ) + logger.debug( + "event=session_discovery_complete total=%d claude=%d codex=%d", + len(sessions), + sum(1 for s in sessions if s.runtime is AgentRuntime.claude_code), + sum(1 for s in sessions if s.runtime is AgentRuntime.codex), + ) + return sessions + + +# --------------------------------------------------------------------------- +# Claude Code scanning +# --------------------------------------------------------------------------- + + +def _scan_claude( + projects_dir: Path, + *, + live_processes: list[tuple[int, str]], + now: datetime, + running_threshold_s: int, + idle_threshold_s: int, +) -> list[DiscoveredSession]: + """Scan //.jsonl and build DiscoveredSession records.""" + if not projects_dir.exists(): + return [] + + has_claude_process = _has_runtime_process(live_processes, _CLAUDE_BINARY_MARKER) + sessions: list[DiscoveredSession] = [] + + for slug_dir in projects_dir.iterdir(): + if not slug_dir.is_dir(): + continue + project_dir = _decode_claude_slug(slug_dir.name) + for transcript in slug_dir.glob("*.jsonl"): + session_id = transcript.stem # filename without extension + last_activity, liveness = _classify_liveness( + transcript, + has_runtime_process=has_claude_process, + now=now, + running_threshold_s=running_threshold_s, + idle_threshold_s=idle_threshold_s, + ) + sessions.append( + DiscoveredSession( + runtime=AgentRuntime.claude_code, + session_id=session_id, + transcript_path=transcript, + project_dir=project_dir, + liveness=liveness, + last_activity=last_activity, + ) + ) + + return sessions + + +def _decode_claude_slug(slug: str) -> Path | None: + """Decode a Claude project slug back to an absolute path. + + Claude replaces every '/' in the absolute path with '-', producing a slug + that always starts with '-' for absolute paths. If the slug lacks a + leading '-', the decode is ambiguous (could be any relative path) so we + return None rather than guess. + + Example: '-home-user-myproject' → Path('/home/user/myproject') + """ + if not slug.startswith("-"): + # No leading '-' means we cannot infer the filesystem root. + # Return None rather than produce a path that might coincidentally + # exist but be wrong. + return None + # Replace '-' with '/' then strip the leading '/' that the prefix added. + # slug[1:] drops the leading '-' so the subsequent replace produces a + # path starting with '/' (via the leading separator reconstruction). + decoded = "/" + slug[1:].replace("-", "/") + return Path(decoded) + + +# --------------------------------------------------------------------------- +# Codex scanning +# --------------------------------------------------------------------------- + + +def _scan_codex( + codex_home: Path, + *, + live_processes: list[tuple[int, str]], + now: datetime, + running_threshold_s: int, + idle_threshold_s: int, +) -> list[DiscoveredSession]: + """Scan Codex rollout files and enrich with session_index.jsonl data. + + Two-pass approach: + 1. Read session_index.jsonl to build {session_id → project_dir} map. + 2. Scan sessions/YYYY/MM/DD/rollout--.jsonl to find all + sessions on disk. Index enriches with project_dir; absence from + the index is not an error. + """ + sessions_dir = codex_home / "sessions" + if not sessions_dir.exists(): + return [] + + # Build the project_dir lookup from the index (best-effort enrichment). + index_map = _read_codex_index(codex_home) + has_codex_process = _has_runtime_process(live_processes, _CODEX_BINARY_MARKER) + sessions: list[DiscoveredSession] = [] + + # Glob recursively for rollout-*.jsonl files under the sessions tree. + # The path structure is YYYY/MM/DD/rollout--.jsonl but we use + # rglob so unexpected nesting doesn't silently drop sessions. + for transcript in sessions_dir.rglob("rollout-*.jsonl"): + session_id = _extract_codex_session_id(transcript.name) + if session_id is None: + # Filename doesn't match expected pattern — skip it. + logger.debug("event=codex_discovery_skip_filename path=%s", transcript) + continue + + project_dir = index_map.get(session_id) + last_activity, liveness = _classify_liveness( + transcript, + has_runtime_process=has_codex_process, + now=now, + running_threshold_s=running_threshold_s, + idle_threshold_s=idle_threshold_s, + ) + sessions.append( + DiscoveredSession( + runtime=AgentRuntime.codex, + session_id=session_id, + transcript_path=transcript, + project_dir=project_dir, + liveness=liveness, + last_activity=last_activity, + ) + ) + + return sessions + + +def _read_codex_index(codex_home: Path) -> dict[str, Path]: + """Read session_index.jsonl and return {session_id → project_dir}. + + Index entries without a 'project_dir' field are silently omitted from the + result — callers get None from dict.get() and handle it gracefully. + Malformed JSON lines are logged at DEBUG and skipped — the index is + informational so one bad line must not drop all other sessions. + """ + index_path = codex_home / "session_index.jsonl" + if not index_path.exists(): + return {} + + result: dict[str, Path] = {} + for line_no, line in enumerate(index_path.read_text().splitlines(), start=1): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError as exc: + logger.debug( + "event=codex_index_parse_fail line=%d reason=%s", + line_no, + exc, + ) + continue + session_id = entry.get("id") + project_dir_raw = entry.get("project_dir") + if session_id and project_dir_raw: + result[str(session_id)] = Path(str(project_dir_raw)) + + return result + + +def _extract_codex_session_id(filename: str) -> str | None: + """Extract the session UUID from a Codex rollout filename. + + Expected format: rollout--.jsonl + where has no hyphens (e.g. '20260115T120000Z') and + may contain hyphens (e.g. '550e8400-e29b-41d4-a716-446655440000'). + + The extraction strategy: strip the 'rollout-' prefix and '.jsonl' + suffix, then split on '-' taking the SECOND token as the start of + the UUID (the first token is the timestamp). Because the UUID itself + contains hyphens, we must join everything after the first '-' separator + that follows the 'rollout-' prefix. + + Returns None if the filename doesn't match the expected pattern. + """ + if not filename.endswith(".jsonl"): + return None + stem = filename[:-6] # strip '.jsonl' + if not stem.startswith("rollout-"): + return None + rest = stem[len("rollout-") :] # remove 'rollout-' prefix + # rest is now '-' + # The timestamp has no hyphens, so split on the FIRST '-' gives ts and uuid. + dash_pos = rest.find("-") + if dash_pos < 0: + # No separator between timestamp and UUID — malformed filename. + return None + return rest[dash_pos + 1 :] + + +# --------------------------------------------------------------------------- +# Shared liveness classification +# --------------------------------------------------------------------------- + + +def _classify_liveness( + transcript: Path, + *, + has_runtime_process: bool, + now: datetime, + running_threshold_s: int, + idle_threshold_s: int, +) -> tuple[datetime | None, SessionLiveness]: + """Determine liveness and last_activity from the transcript's mtime. + + Priority order (from most to least certain): + 1. Mtime within running_threshold_s → running (output is fresh). + 2. Runtime process alive AND mtime stale > idle_threshold_s → idle. + 3. Otherwise → ended. + + Returns (last_activity, liveness) where last_activity is UTC-aware. + last_activity is None only if stat() fails — e.g. a race between + discovery and deletion. + """ + try: + stat = transcript.stat() + mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC) + except OSError as exc: + # File disappeared between glob and stat — treat as ended. + logger.debug( + "event=transcript_stat_failed path=%s reason=%s", + transcript, + type(exc).__name__, + ) + return None, SessionLiveness.ended + + age_s = (now - mtime).total_seconds() + + if age_s <= running_threshold_s: + # Transcript is fresh — the agent is actively writing. + return mtime, SessionLiveness.running + + if age_s > IDLE_MAX_AGE_S: + # Beyond the age cap we always treat as ended. The process probe is + # machine-global (any claude/codex binary, any session), so a live + # process does NOT confirm THIS transcript's session is still running. + # A 24-hour-old transcript almost certainly belongs to a prior session. + return mtime, SessionLiveness.ended + + if has_runtime_process and age_s > idle_threshold_s: + # Process is alive but hasn't written in a while — agent is idle. + return mtime, SessionLiveness.idle + + if has_runtime_process and age_s <= idle_threshold_s: + # Process alive, stale beyond running but within idle window. + # No strong signal — treat as ended (agent may be between writes + # but we can't confirm it's for THIS session specifically). + return mtime, SessionLiveness.ended + + # No matching process → session is over. + return mtime, SessionLiveness.ended + + +# --------------------------------------------------------------------------- +# Process-table helpers +# --------------------------------------------------------------------------- + + +def _has_runtime_process(processes: list[tuple[int, str]], binary_marker: str) -> bool: + """Return True if any process command line contains the binary marker. + + We match by substring (case-insensitive) because install paths vary: + `/usr/local/bin/claude`, `~/.nvm/.../claude`, `/opt/homebrew/.../claude`, + etc. A substring match is broad enough to catch all of these without + needing the full path. + """ + marker_lower = binary_marker.lower() + return any(marker_lower in cmd.lower() for _, cmd in processes) diff --git a/src/bach/runtimes/transcript.py b/src/bach/runtimes/transcript.py new file mode 100644 index 0000000..705909e --- /dev/null +++ b/src/bach/runtimes/transcript.py @@ -0,0 +1,440 @@ +"""Transcript parsing for Claude Code and Codex rollout JSONL files. + +This is the ONLY module in Bach that knows how to turn raw runtime +transcript records into normalized SessionEvent values. All other code +that needs to understand what an agent said must go through here. + +Design decisions: + - NEVER raises on bad input (malformed JSON, truncated lines, unknown + record types). A single corrupt transcript line must not kill a watcher + loop or crash a judge call. Every error path returns kind=unknown. + - Text is truncated to ~200 chars. The text field is a human-readable + preview for judge context windows — it is NOT the full structured content. + Callers that need the raw JSON should parse the original line themselves. + - raw_kind always preserves the runtime's own record-type string so + downstream code can log or diagnose unrecognized types without + re-parsing the line. + - Timestamp parsing is best-effort: if the 'timestamp' field is absent or + unparseable, event.timestamp is None. A missing timestamp never degrades + the kind assignment. + +Claude JSONL record types seen in the wild: + user → user_turn + assistant → assistant_turn (or tool_call if content blocks contain tool_use) + tool_result → tool_result + system → session_meta + ai-title → session_meta + Everything else → unknown + +Codex rollout record types: + session_meta → session_meta + turn_context → user_turn + response_item → assistant_turn + event_msg → dispatched by payload.type sub-field: + task_started → session_meta + agent_message → assistant_turn + agent_reasoning → assistant_turn + task_complete → end + Everything else → unknown + Everything else → unknown +""" + +import json +import logging +import time +from collections.abc import Callable, Iterator +from datetime import datetime +from pathlib import Path +from typing import Any + +from bach.domain.models import AgentRuntime, SessionEvent, SessionEventKind + +logger = logging.getLogger(__name__) + +# Text preview cap. The judge receives a compact window of events; long +# content blocks waste tokens without improving the verdict quality. +_TEXT_MAX_CHARS = 200 + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def parse_line(line: str, runtime: AgentRuntime) -> SessionEvent: + """Parse one raw transcript line into a normalized SessionEvent. + + Never raises. Any failure — malformed JSON, missing fields, unknown record + type — returns a SessionEvent with kind=unknown. The raw line content is + NOT included in text to avoid leaking large blobs; raw_kind carries the + diagnostically useful part (the record type string, or a sentinel if the + JSON couldn't be parsed). + """ + stripped = line.strip() + if not stripped: + # Blank / whitespace lines appear in JSONL files between sessions or + # as trailing newlines — treat as unknown without logging noise. + return SessionEvent( + kind=SessionEventKind.unknown, timestamp=None, text=None, raw_kind="" + ) + + try: + record = json.loads(stripped) + except json.JSONDecodeError: + # Truncated, corrupt, or non-JSON line (e.g. a stray log line that + # ended up in the transcript). Log at DEBUG only — this can be + # voluminous for large transcripts with encoding issues. + logger.debug("event=transcript_parse_fail reason=json_decode chars=%d", len(stripped)) + return SessionEvent( + kind=SessionEventKind.unknown, timestamp=None, text=None, raw_kind="" + ) + + if not isinstance(record, dict): + # Unexpected top-level JSON type (array, scalar, null). Keep going. + logger.debug("event=transcript_parse_fail reason=not_object type=%s", type(record).__name__) + return SessionEvent( + kind=SessionEventKind.unknown, timestamp=None, text=None, raw_kind="" + ) + + ts = _extract_timestamp(record) + raw_kind = record.get("type", "") + if not isinstance(raw_kind, str): + raw_kind = str(raw_kind) + + if runtime is AgentRuntime.claude_code: + kind, text = _map_claude(record, raw_kind) + else: + kind, text = _map_codex(record, raw_kind) + + return SessionEvent(kind=kind, timestamp=ts, text=text, raw_kind=raw_kind) + + +def read_tail_events( + path: Path, + runtime: AgentRuntime, + *, + max_events: int = 50, +) -> list[SessionEvent]: + """Read up to `max_events` events from the tail of a transcript file. + + Returns an empty list if the file is missing or unreadable — callers + treat this the same as an empty transcript. Lines are read from the + beginning and the last `max_events` are returned (tail semantics), + so this performs a full-file read. For typical transcript sizes (< 1 MB) + this is fast; very large transcripts (multi-hour sessions) may need a + smarter seek-from-end strategy in the future. + """ + if not path.exists(): + logger.debug("event=transcript_read_missing path=%s", path) + return [] + + try: + raw = path.read_text(encoding="utf-8", errors="replace") + except OSError as exc: + logger.warning("event=transcript_read_error path=%s reason=%s", path, exc) + return [] + + lines = raw.splitlines() + # Take the tail before parsing to avoid parsing thousands of lines + # from a large transcript when we only need the last N events. + tail_lines = lines[-max_events:] if len(lines) > max_events else lines + + events: list[SessionEvent] = [] + for line in tail_lines: + if not line.strip(): + continue # skip blank lines without creating noise events + events.append(parse_line(line, runtime)) + + return events + + +def follow_events( + path: Path, + runtime: AgentRuntime, + *, + poll_seconds: float, + should_stop: Callable[[], bool], +) -> Iterator[SessionEvent]: + """Tail-follow a transcript file, yielding new events as they appear. + + Reads from the last known position and yields any new complete lines. + The 'complete line' heuristic is: each line in a JSONL file ends with + '\\n', so a partial last line (the record currently being written) is + deferred to the next poll cycle. + + Stops polling when should_stop() returns True. No asyncio, no threads — + this is a simple synchronous generator with a sleep between polls. It is + designed to run in a foreground process (session_watcher.watch_session). + + Missing file: if the file does not exist yet, polling continues (the + session may not have written its first line yet). Once should_stop fires + the generator returns cleanly. + """ + offset = 0 # byte offset into the file; advances as lines are consumed + + while not should_stop(): + if not path.exists(): + time.sleep(poll_seconds) + continue + + try: + with path.open("r", encoding="utf-8", errors="replace") as fh: + fh.seek(offset) + chunk = fh.read() + new_offset = fh.tell() + except OSError as exc: + logger.warning("event=transcript_follow_error path=%s reason=%s", path, exc) + time.sleep(poll_seconds) + continue + + if chunk: + # Defer a trailing partial line — the write may still be in + # progress. We only yield lines that end with '\n'. + lines = chunk.split("\n") + # If chunk ended with '\n', the last element is ''. If it didn't, + # the last element is the incomplete line — put it back by NOT + # advancing offset past it. + if chunk.endswith("\n"): + complete_lines = lines[:-1] # drop trailing empty string + offset = new_offset + else: + complete_lines = lines[:-1] # all but the incomplete tail + # Rewind offset to just before the incomplete line starts. + # We do this by subtracting the byte length of the incomplete + # fragment plus the preceding newline we already skipped. + incomplete = lines[-1] + offset = new_offset - len(incomplete.encode("utf-8")) + + for line in complete_lines: + if not line.strip(): + continue + yield parse_line(line, runtime) + + time.sleep(poll_seconds) + + +# --------------------------------------------------------------------------- +# Internal: Claude JSONL mapping +# --------------------------------------------------------------------------- + + +def _map_claude(record: dict[str, Any], raw_kind: str) -> tuple[SessionEventKind, str | None]: + """Map a parsed Claude JSONL record to (kind, text). + + Claude 'assistant' records embed both plain-text responses and tool + invocations as content blocks under message.content. We inspect the + block types to distinguish tool_call from assistant_turn — if ANY block + has type='tool_use', the event is a tool_call even if text blocks are + also present (the call IS the dominant event). + """ + if raw_kind == "user": + text = _extract_claude_user_text(record) + return SessionEventKind.user_turn, text + + if raw_kind == "assistant": + content_blocks = ( + record.get("message", {}).get("content", []) + if isinstance(record.get("message"), dict) + else [] + ) + if isinstance(content_blocks, list) and any( + isinstance(b, dict) and b.get("type") == "tool_use" for b in content_blocks + ): + # Tool call: the assistant is invoking a tool, not producing text. + tool_name = next( + ( + b.get("name") + for b in content_blocks + if isinstance(b, dict) and b.get("type") == "tool_use" + ), + None, + ) + text = tool_name # tool name is the most useful text preview for tool_call events + return SessionEventKind.tool_call, text + + # Regular text turn. + text = _extract_claude_assistant_text(record) + return SessionEventKind.assistant_turn, text + + if raw_kind == "tool_result": + text = _extract_claude_tool_result_text(record) + return SessionEventKind.tool_result, text + + if raw_kind in ("system", "ai-title"): + # Session-level metadata: system prompt or auto-generated title. + text = record.get("title") or record.get("system") + if isinstance(text, str): + text = _truncate(text) + else: + text = None + return SessionEventKind.session_meta, text + + # Everything else (attachment, queue-operation, future types) → unknown. + return SessionEventKind.unknown, None + + +def _extract_claude_user_text(record: dict[str, Any]) -> str | None: + """Extract human-readable text from a Claude 'user' record. + + Handles both string content and list-of-blocks content (the latter is + used when the user sends a multimodal message with file attachments). + """ + msg = record.get("message") + if not isinstance(msg, dict): + return None + content = msg.get("content") + if isinstance(content, str): + return _truncate(content) + if isinstance(content, list): + # Concatenate all text blocks. + parts = [ + b["text"] + for b in content + if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str) + ] + return _truncate(" ".join(parts)) if parts else None + return None + + +def _extract_claude_assistant_text(record: dict[str, Any]) -> str | None: + """Extract text from Claude 'assistant' record (non-tool-call path).""" + msg = record.get("message") + if not isinstance(msg, dict): + return None + content = msg.get("content") + if isinstance(content, list): + parts = [ + b["text"] + for b in content + if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str) + ] + return _truncate(" ".join(parts)) if parts else None + if isinstance(content, str): + return _truncate(content) + return None + + +def _extract_claude_tool_result_text(record: dict[str, Any]) -> str | None: + """Extract a short preview from a Claude tool_result record.""" + content = record.get("content") + if isinstance(content, list): + parts = [ + b.get("content", "") + for b in content + if isinstance(b, dict) and b.get("type") == "tool_result" + ] + joined = " ".join(str(p) for p in parts if p) + return _truncate(joined) if joined else None + if isinstance(content, str): + return _truncate(content) + return None + + +# --------------------------------------------------------------------------- +# Internal: Codex rollout mapping +# --------------------------------------------------------------------------- + + +def _map_codex(record: dict[str, Any], raw_kind: str) -> tuple[SessionEventKind, str | None]: + """Map a parsed Codex rollout record to (kind, text). + + Codex rollout format: {"type": "...", "timestamp": "...", "payload": {...}}. + The 'event_msg' type uses a nested payload.type sub-field to distinguish + the specific event kind (task_started, agent_message, task_complete, etc.). + """ + if raw_kind == "session_meta": + payload = record.get("payload", {}) + # Use model or id as the session metadata preview + text: str | None = None + if isinstance(payload, dict): + text = payload.get("model") or payload.get("id") + if isinstance(text, str): + text = _truncate(text) + else: + text = None + return SessionEventKind.session_meta, text + + if raw_kind == "turn_context": + payload = record.get("payload", {}) + text = payload.get("input") if isinstance(payload, dict) else None + if isinstance(text, str): + text = _truncate(text) + else: + text = None + return SessionEventKind.user_turn, text + + if raw_kind == "response_item": + payload = record.get("payload", {}) + text = payload.get("output") if isinstance(payload, dict) else None + if isinstance(text, str): + text = _truncate(text) + else: + text = None + return SessionEventKind.assistant_turn, text + + if raw_kind == "event_msg": + return _map_codex_event_msg(record) + + # Unknown Codex record type — preserve raw_kind for diagnostics. + return SessionEventKind.unknown, None + + +def _map_codex_event_msg(record: dict[str, Any]) -> tuple[SessionEventKind, str | None]: + """Dispatch Codex 'event_msg' records by their nested payload.type sub-field. + + event_msg is the most common Codex record type and carries many different + payload shapes. The payload.type field is the discriminator. + """ + payload = record.get("payload", {}) + if not isinstance(payload, dict): + return SessionEventKind.unknown, None + + sub_type = payload.get("type", "") + + if sub_type == "task_started": + task_id = payload.get("task_id") + text: str | None = _truncate(str(task_id)) if task_id else None + return SessionEventKind.session_meta, text + + if sub_type in ("agent_message", "agent_reasoning"): + # Both are assistant-produced content — reasoning is chain-of-thought, + # agent_message is the user-visible response. Both are assistant_turn. + msg = payload.get("message") or payload.get("reasoning", "") + text = _truncate(str(msg)) if msg else None + return SessionEventKind.assistant_turn, text + + if sub_type == "task_complete": + return SessionEventKind.end, None + + # Anything else (token_count, tool_call, etc.) → unknown. + return SessionEventKind.unknown, None + + +# --------------------------------------------------------------------------- +# Internal: shared helpers +# --------------------------------------------------------------------------- + + +def _extract_timestamp(record: dict[str, Any]) -> datetime | None: + """Best-effort timestamp extraction from a parsed record dict. + + Supports ISO 8601 strings with 'Z' suffix (common in both Claude and + Codex transcripts). Falls back to None on any parse failure — a missing + timestamp never degrades the kind assignment. + """ + raw = record.get("timestamp") + if not isinstance(raw, str): + return None + try: + # Python 3.11+ fromisoformat handles 'Z' suffix; earlier versions need + # a manual replacement. We support 3.12+, so fromisoformat works. + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except (ValueError, TypeError): + return None + + +def _truncate(text: str) -> str: + """Truncate text to _TEXT_MAX_CHARS characters, appending '…' when cut.""" + if len(text) <= _TEXT_MAX_CHARS: + return text + return text[:_TEXT_MAX_CHARS] + "…" diff --git a/src/bach/services/afk_runner.py b/src/bach/services/afk_runner.py new file mode 100644 index 0000000..907ecdd --- /dev/null +++ b/src/bach/services/afk_runner.py @@ -0,0 +1,474 @@ +"""Headless multi-turn AFK driver. + +Drives a Bach task autonomously through a capped turn loop: + 1. Claim the artifact: afk_queue (or executing) → executing, source="afk". + 2. Loop ≤ afk_max_turns AND ≤ afk_time_budget_minutes: + a. Run one headless turn via turn_runner. + b. Run the verify gate; pass → set qa + stop. + c. Run the judge; needs_human=True → set hitl_queue + stop. + d. Otherwise build a continuation message and loop again. + 3. On bounds exhaustion: escalate to hitl_queue (human reviews). + 4. On turn_runner error: escalate to hitl_queue, reason="error". + 5. Print resume command + artifact path on exit (fallback pattern). + +Architecture constraints (from dag.md / PRD): + - NEVER sets status=done. Human review is required before completion. + - source="afk" is a NORMAL-authority write (not observer) but is + self-restricted in this module to {executing, qa, hitl_queue}. + - All external seams (turn_runner, judge, gate, clock) are injected + as keyword args so tests never touch real LLM / subprocess / clock. + - set_task_status is imported at the top but also injectable for + hermetic unit tests that don't need real artifact writes. + - Logging uses structured event= key-value style (matches settings.py). + +Injected seam signatures: + turn_runner(**kwargs) -> str + Runs one headless turn. Receives artifact_path, message, runtime, + session_id as keyword args. Returns the turn's output text. + Raises on subprocess failure (caught; reason="error"). + + judge(**kwargs) -> JudgeVerdict | None + Assesses the session state. Receives the same kwargs as + judge_service.judge_session. Returns None for no-op. + + gate(**kwargs) -> bool + Evaluates the verify gate. Returns True if verification passes + (set qa), False to continue. Receives session_id, artifact_path. + + clock() -> float + Returns the current time in seconds (monotonic). Defaults to + time.monotonic so real usage needs no override. +""" + +import logging +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from bach.config.settings import BachConfig +from bach.domain.models import JudgeVerdict +from bach.services.status_service import set_task_status + +logger = logging.getLogger(__name__) + +# source tag used for all status writes from this runner. +# Normal (human) authority path — NOT observer. The runner is code- +# restricted here to {executing, qa, hitl_queue} but status_service +# allows any valid status for non-observer sources. +_AFK_SOURCE = "afk" + +# The only statuses this runner is allowed to write. Enforced locally +# so the policy is visible in one place rather than scattered through +# branching logic. +_ALLOWED_TARGETS: frozenset[str] = frozenset({"executing", "qa", "hitl_queue"}) + + +@dataclass(frozen=True) +class AfkResult: + """Summary returned by run_afk_task. + + Consumed by the CLI to print a human-readable summary and by tests + to assert invariants without reading the artifact again. + + reason values (from dag.md): + "verify_passed" — gate passed after a turn → status=qa. + "needs_human" — judge returned needs_human=True → hitl_queue. + "max_turns" — hard turn budget exhausted → hitl_queue. + "time_budget" — hard time budget exhausted → hitl_queue. + "error" — turn_runner raised unexpectedly → hitl_queue. + """ + + turns_used: int + final_status: str + reason: str + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _read_payload(artifact: Path) -> dict[str, Any] | None: + """Parse artifact YAML frontmatter; returns None on any failure. + + Uses split("---", 2) with maxsplit=2 so "---" rules inside the Markdown + body (e.g. HR elements or YAML rule examples) do not corrupt the parse. + The artifact format is always: '' | frontmatter | body, so exactly 3 + parts after the first two '---' separators. + """ + try: + text = artifact.read_text() + parts = text.split("---", 2) + if len(parts) < 3: + logger.warning( + "event=afk_artifact_malformed path=%s reason=no_frontmatter", + artifact, + ) + return None + result: dict[str, Any] = yaml.safe_load(parts[1]) or {} + return result + except (OSError, yaml.YAMLError) as exc: + logger.warning( + "event=afk_artifact_read_error path=%s reason=%s", + artifact, + type(exc).__name__, + ) + return None + + +def _set_status( + artifact: Path, + new_status: str, + source: str, +) -> None: + """Write a status to the artifact, validating it against allowed targets. + + The runner only calls this helper, which guards against accidentally + writing a forbidden status (especially 'done') regardless of what + the judge suggests. Any error is re-raised so the caller can decide + whether to treat it as fatal. + """ + if new_status not in _ALLOWED_TARGETS: + # Defensive: the runner must never set done or pre-execution stages. + # If we reach here with a bad target, log and escalate to hitl_queue + # rather than silently writing a forbidden status. + logger.error( + "event=afk_forbidden_status artifact=%s target=%r allowed=%r", + artifact, + new_status, + sorted(_ALLOWED_TARGETS), + ) + new_status = "hitl_queue" + set_task_status(artifact, new_status, source) + + +def _build_continuation_message( + turn_output: str, + gate_failure_reason: str | None, + judge_summary: str | None, +) -> str: + """Build a message to send as the next turn's prompt. + + The continuation message surfaces what the gate or judge observed + so the agent has context for what to fix next. + """ + parts: list[str] = ["Continue working on the task."] + if gate_failure_reason: + # Include trimmed gate output so the agent knows what failed. + preview = gate_failure_reason[:500] + parts.append(f"Verification failed: {preview}") + if judge_summary: + parts.append(f"Session assessment: {judge_summary}") + if turn_output and not gate_failure_reason: + # Include a snippet of the prior turn output for context. + preview = turn_output[:200] + parts.append(f"Prior turn output (summary): {preview}") + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + + +def run_afk_task( + artifact_path: Path, + *, + config: BachConfig, + turn_runner: Callable[..., str], + judge: Callable[..., JudgeVerdict | None], + gate: Callable[..., bool], + clock: Callable[[], float] = time.monotonic, +) -> AfkResult: + """Run a task headlessly until a gate passes, escalation, or bounds fire. + + Args: + artifact_path: Path to the task artifact (.md with YAML frontmatter). + config: BachConfig with afk_max_turns and afk_time_budget_minutes. + turn_runner: Injected callable that runs one headless turn. + Signature: (**kwargs) -> str + judge: Injected callable that assesses session events. + Signature: (**kwargs) -> JudgeVerdict | None + gate: Injected callable that evaluates the verify condition. + Signature: (**kwargs) -> bool (True=pass, False=fail) + clock: Injected callable returning current time in seconds. + Defaults to time.monotonic; override in tests. + + Returns: + AfkResult with turns_used, final_status, and reason. + """ + payload = _read_payload(artifact_path) + if payload is None: + logger.error("event=afk_artifact_missing_or_unreadable path=%s", artifact_path) + # Cannot proceed without a readable artifact. + return AfkResult(turns_used=0, final_status="error", reason="error") + + session_id = str( + payload.get("agent", {}).get("runtime_session_id", "") + or payload.get("agent", {}).get("bach_session_id", "") + ) + runtime = str(payload.get("agent", {}).get("runtime", "claude-code")) + task_title = str(payload.get("task", {}).get("title", "") or payload.get("id", "")) + resume_command = str(payload.get("agent", {}).get("resume_command", "") or "") + + logger.info( + "event=afk_start artifact=%s session_id=%s max_turns=%d budget_min=%d", + artifact_path, + session_id, + config.afk_max_turns, + config.afk_time_budget_minutes, + ) + + # --- Step 1: Claim the task (afk_queue → executing) --- + current_status = str(payload.get("status", "")) + if current_status == "afk_queue": + try: + _set_status(artifact_path, "executing", _AFK_SOURCE) + current_status = "executing" + logger.info( + "event=afk_claimed artifact=%s from=afk_queue to=executing", + artifact_path, + ) + except Exception as exc: # noqa: BLE001 + logger.error( + "event=afk_claim_failed artifact=%s reason=%s", + artifact_path, + type(exc).__name__, + ) + return AfkResult(turns_used=0, final_status="error", reason="error") + + # Budget: convert minutes to seconds for comparison with clock(). + budget_seconds = config.afk_time_budget_minutes * 60 + t0 = clock() + + turns_used = 0 + continuation_message = "Begin the task. Work autonomously toward completion." + + # --- Step 2: Main turn loop --- + for turn_index in range(config.afk_max_turns): + # Hard time budget check BEFORE running the turn. + elapsed = clock() - t0 + if elapsed >= budget_seconds: + logger.info( + "event=afk_time_budget_exceeded artifact=%s elapsed_s=%.1f budget_s=%d turns=%d", + artifact_path, + elapsed, + budget_seconds, + turns_used, + ) + _escalate_to_hitl(artifact_path, "time_budget") + _print_exit_info(artifact_path, resume_command) + return AfkResult( + turns_used=turns_used, + final_status="hitl_queue", + reason="time_budget", + ) + + # --- Run one headless turn --- + turn_output = "" + try: + turn_output = turn_runner( + artifact_path=artifact_path, + message=continuation_message, + runtime=runtime, + session_id=session_id, + ) + turns_used += 1 + logger.info( + "event=afk_turn_complete artifact=%s turn=%d/%d output_chars=%d", + artifact_path, + turn_index + 1, + config.afk_max_turns, + len(turn_output), + ) + except Exception as exc: # noqa: BLE001 + # Turn runner failure: non-fatal at the runner level, but we + # cannot continue without a turn result — escalate to hitl_queue. + logger.error( + "event=afk_turn_error artifact=%s turn=%d reason=%s", + artifact_path, + turn_index + 1, + type(exc).__name__, + ) + _escalate_to_hitl(artifact_path, "turn_error") + _print_exit_info(artifact_path, resume_command) + return AfkResult( + turns_used=turns_used, + final_status="hitl_queue", + reason="error", + ) + + # --- Verify gate --- + gate_passed = False + gate_failure_reason: str | None = None + try: + gate_passed = gate( + session_id=session_id, + artifact_path=artifact_path, + ) + except Exception as exc: # noqa: BLE001 + # Gate failure is non-fatal: treat as "gate did not pass" and continue. + logger.warning( + "event=afk_gate_error artifact=%s turn=%d reason=%s", + artifact_path, + turn_index + 1, + type(exc).__name__, + ) + gate_failure_reason = f"gate error: {type(exc).__name__}" + + if gate_passed: + logger.info( + "event=afk_gate_passed artifact=%s turn=%d", + artifact_path, + turn_index + 1, + ) + try: + _set_status(artifact_path, "qa", _AFK_SOURCE) + except Exception as exc: # noqa: BLE001 + logger.error( + "event=afk_qa_write_error artifact=%s reason=%s", + artifact_path, + type(exc).__name__, + ) + _print_exit_info(artifact_path, resume_command) + return AfkResult( + turns_used=turns_used, + final_status="qa", + reason="verify_passed", + ) + + # --- Check for hitl_queue escalation by the gate (cap exhaustion) --- + # The gate (e.g. loop_gate.evaluate_stop) can set the artifact to + # hitl_queue when the continuation cap is exhausted, returning block=False. + # The _gate_fn wrapper in afk_cmd.py maps that to gate_passed=False here. + # We must detect this escalation and stop rather than continue the loop, + # otherwise we would eventually hit max_turns and _escalate_to_hitl would + # try to write hitl_queue again (harmless but confusing). More critically, + # without this check the loop would continue running turns despite the + # human-review escalation already being committed to the artifact. + try: + mid_payload = _read_payload(artifact_path) + if mid_payload is not None and str(mid_payload.get("status", "")) == "hitl_queue": + logger.info( + "event=afk_hitl_detected_after_gate artifact=%s turn=%d " + "reason=gate_escalated_cap_exhaustion", + artifact_path, + turn_index + 1, + ) + _print_exit_info(artifact_path, resume_command) + return AfkResult( + turns_used=turns_used, + final_status="hitl_queue", + reason="needs_human", + ) + except Exception as exc: # noqa: BLE001 — read failure is non-fatal; continue loop + logger.warning( + "event=afk_gate_status_check_failed artifact=%s reason=%s", + artifact_path, + type(exc).__name__, + ) + + # --- Judge --- + judge_verdict: JudgeVerdict | None = None + try: + # Re-read payload to pass the freshest status/title to the judge. + fresh_payload = _read_payload(artifact_path) or payload + judge_verdict = judge( + events=[], # headless runner has no transcript events + task_title=task_title, + current_status=str(fresh_payload.get("status", current_status)), + model=config.normalize_model, + ) + except Exception as exc: # noqa: BLE001 + # Judge failure is non-fatal — continue the loop. + logger.warning( + "event=afk_judge_error artifact=%s turn=%d reason=%s", + artifact_path, + turn_index + 1, + type(exc).__name__, + ) + + if judge_verdict is not None and judge_verdict.needs_human: + logger.info( + "event=afk_needs_human artifact=%s turn=%d summary=%r", + artifact_path, + turn_index + 1, + judge_verdict.summary, + ) + _escalate_to_hitl(artifact_path, "needs_human") + _print_exit_info(artifact_path, resume_command) + return AfkResult( + turns_used=turns_used, + final_status="hitl_queue", + reason="needs_human", + ) + + # Gate failed, no escalation — build continuation message for next turn. + judge_summary = judge_verdict.summary if judge_verdict else None + continuation_message = _build_continuation_message( + turn_output, + gate_failure_reason, + judge_summary, + ) + logger.info( + "event=afk_continue artifact=%s turn=%d gate_passed=%s has_judge=%s", + artifact_path, + turn_index + 1, + gate_passed, + judge_verdict is not None, + ) + + # --- Step 3: Hard max_turns bound exhausted --- + logger.info( + "event=afk_max_turns_reached artifact=%s turns=%d", + artifact_path, + turns_used, + ) + _escalate_to_hitl(artifact_path, "max_turns") + _print_exit_info(artifact_path, resume_command) + return AfkResult( + turns_used=turns_used, + final_status="hitl_queue", + reason="max_turns", + ) + + +def _escalate_to_hitl(artifact_path: Path, reason: str) -> None: + """Write hitl_queue to the artifact. Non-fatal on failure. + + Centralises the escalation write so every exit path goes through + the same logging and the never-done invariant is enforced in one spot. + """ + try: + _set_status(artifact_path, "hitl_queue", _AFK_SOURCE) + logger.info( + "event=afk_escalated artifact=%s reason=%s", + artifact_path, + reason, + ) + except Exception as exc: # noqa: BLE001 + logger.error( + "event=afk_escalate_write_error artifact=%s reason=%s cause=%s", + artifact_path, + reason, + type(exc).__name__, + ) + + +def _print_exit_info(artifact_path: Path, resume_command: str) -> None: + """Print resume command and artifact path on exit. + + The printed fallback command lets the user resume the session manually + even if iTerm automation is unavailable. This mirrors the existing + launch-time fallback pattern (runtimes/iterm.py). + """ + print("\nAFK runner exited.") + print(f" Artifact: {artifact_path}") + if resume_command: + print(f" Resume command: {resume_command}") + else: + print(" Resume command: (not available — check artifact agent.resume_command)") diff --git a/src/bach/services/hooks_service.py b/src/bach/services/hooks_service.py new file mode 100644 index 0000000..3a534f3 --- /dev/null +++ b/src/bach/services/hooks_service.py @@ -0,0 +1,280 @@ +"""Hook installation for Claude Code and Codex runtimes. + +This service provides idempotent, non-destructive hook installer logic. +Callers (cli/hooks_cmd.py) pass explicit paths so no real ~/.claude or +~/.codex is ever touched in unit tests. + +Design constraints: + - NEVER clobber pre-existing unrelated user hooks. Parse-merge-rewrite + only: read existing JSON, splice in Bach entries if absent, write back. + - Idempotent: running install twice is a no-op — detected by matching + the command string, not by position. + - Never raise on missing parent dirs — create on demand. + - `HookInstallResult` reports what changed so the CLI can print it. + +Claude Code hook format (settings.json): + { + "hooks": { + "SessionEnd": [{"command": "...", "run": "always"}, ...], + "Stop": [{"command": "...", "run": "always"}, ...], + "PostToolUse": [{"command": "...", "run": "always"}, ...] + } + } + Bach adds entries that call `bach internal hook-event` piping stdin. + The Stop hook additionally calls `bach internal hook-event` with + the stop payload (the session-stop gate in a later phase will be + added separately by L1 — we only add hook-event routing here). + +Codex hook format (~/.codex/hooks.json): + { + "sessionStop": [{"command": "..."}, ...], + "sessionStart": [{"command": "..."}, ...] + } + Bach adds a sessionStop entry that pipes stdin to `bach internal hook-event`. +""" + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# The exact command Bach injects into Claude Code hooks. Uses shell pipe +# so the hook payload (on stdin) flows into the bach command. +_CLAUDE_HOOK_CMD = "bach internal hook-event" + +# The equivalent command for Codex hooks (same target, same stdin pipe). +_CODEX_HOOK_CMD = "bach internal hook-event" + +# Claude Code event names Bach cares about. SessionEnd is the existing +# integration point; Stop and PostToolUse are Phase 16 additions. +_CLAUDE_HOOK_EVENTS = ("SessionEnd", "Stop", "PostToolUse") + + +@dataclass(frozen=True) +class HookInstallResult: + """Summary of what install_* changed on disk. + + `added` lists (runtime_event_name, command) pairs that were inserted. + `already_present` lists the same shape for entries that were already there. + Both lists may be empty (fully idempotent call). + """ + + added: tuple[tuple[str, str], ...] = () + already_present: tuple[tuple[str, str], ...] = () + + +def install_claude_hooks(*, project_dir: Path) -> HookInstallResult: + """Parse-merge-rewrite .claude/settings.json under `project_dir`. + + For each Bach-owned hook event (SessionEnd, Stop, PostToolUse), add + a `bach internal hook-event` entry if one is not already present. + Pre-existing user entries are preserved verbatim — we only append. + + `project_dir` is injected so tests use tmp_path. Production callers + pass the CWD of the target project (where `.claude/` lives). + """ + settings_path = project_dir / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + # Read existing config or start from empty dict. + existing: dict[str, Any] = {} + if settings_path.exists(): + try: + existing = json.loads(settings_path.read_text(encoding="utf-8")) or {} + except (json.JSONDecodeError, OSError) as exc: + # Abort rather than proceed from an empty dict. Proceeding would + # silently OVERWRITE the user's entire settings.json with only Bach's + # hooks, destroying any existing configuration. The user must fix + # their settings file before Bach can install hooks. + raise RuntimeError( + f"Cannot install Claude Code hooks: {settings_path} failed to parse " + f"({type(exc).__name__}: {exc}). " + "Please fix or delete the settings file and re-run." + ) from exc + if not isinstance(existing, dict): + existing = {} + + hooks: dict[str, list[dict[str, Any]]] = existing.setdefault("hooks", {}) + if not isinstance(hooks, dict): + # Corrupted hooks key — start fresh but warn so the user knows. + logger.warning( + "event=claude_hooks_corrupted path=%s replacing_with_empty_dict", + settings_path, + ) + hooks = {} + existing["hooks"] = hooks + + added: list[tuple[str, str]] = [] + already_present: list[tuple[str, str]] = [] + + for event_name in _CLAUDE_HOOK_EVENTS: + entries: list[dict[str, Any]] = hooks.setdefault(event_name, []) + if not isinstance(entries, list): + entries = [] + hooks[event_name] = entries + + # Check whether a Bach hook-event entry is already present. + # Match on command string — run/other fields may differ. + existing_cmds = {e.get("command", "") for e in entries if isinstance(e, dict)} + if _CLAUDE_HOOK_CMD in existing_cmds: + already_present.append((event_name, _CLAUDE_HOOK_CMD)) + else: + entries.append({"command": _CLAUDE_HOOK_CMD, "run": "always"}) + added.append((event_name, _CLAUDE_HOOK_CMD)) + logger.info( + "event=claude_hook_added event_name=%s command=%r", + event_name, + _CLAUDE_HOOK_CMD, + ) + + settings_path.write_text( + json.dumps(existing, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return HookInstallResult( + added=tuple(added), + already_present=tuple(already_present), + ) + + +def install_codex_hooks(*, codex_home: Path) -> HookInstallResult: + """Parse-merge-rewrite `codex_home/hooks.json`. + + Adds a `sessionStop` entry that pipes the stop payload to + `bach internal hook-event`. Pre-existing entries survive untouched. + + `codex_home` is injected (default: ~/.codex) so tests use tmp_path. + """ + hooks_path = codex_home / "hooks.json" + codex_home.mkdir(parents=True, exist_ok=True) + + # Read existing hooks or start from empty dict. + existing: dict[str, Any] = {} + if hooks_path.exists(): + try: + existing = json.loads(hooks_path.read_text(encoding="utf-8")) or {} + except (json.JSONDecodeError, OSError) as exc: + # Abort rather than proceed from an empty dict. Proceeding would + # OVERWRITE the user's hooks.json with only Bach's entry, destroying + # any existing codex hooks. The user must fix their hooks file first. + raise RuntimeError( + f"Cannot install Codex hooks: {hooks_path} failed to parse " + f"({type(exc).__name__}: {exc}). " + "Please fix or delete the hooks file and re-run." + ) from exc + if not isinstance(existing, dict): + existing = {} + + added: list[tuple[str, str]] = [] + already_present: list[tuple[str, str]] = [] + + # Codex uses "sessionStop" as the event name for session end. + event_name = "sessionStop" + entries: list[Any] = existing.setdefault(event_name, []) + if not isinstance(entries, list): + entries = [] + existing[event_name] = entries + + # Detect existing Bach entry by matching the command substring. + def _is_bach(entry: Any) -> bool: + if isinstance(entry, dict): + return _CODEX_HOOK_CMD in entry.get("command", "") + if isinstance(entry, str): + return _CODEX_HOOK_CMD in entry + return False + + if any(_is_bach(e) for e in entries): + already_present.append((event_name, _CODEX_HOOK_CMD)) + else: + entries.append({"command": _CODEX_HOOK_CMD}) + added.append((event_name, _CODEX_HOOK_CMD)) + logger.info( + "event=codex_hook_added event_name=%s command=%r", + event_name, + _CODEX_HOOK_CMD, + ) + + hooks_path.write_text( + json.dumps(existing, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return HookInstallResult( + added=tuple(added), + already_present=tuple(already_present), + ) + + +def get_hooks_status(*, project_dir: Path, codex_home: Path) -> dict[str, Any]: + """Inspect whether Bach hooks are installed in both runtimes. + + Returns a dict with keys: + claude_installed: bool — at least one Bach entry in each event + codex_installed: bool — Bach entry in codex sessionStop + claude_detail: list of event names where Bach hooks are present + codex_detail: list of event names where Bach hooks are present + + All paths injected so tests run without touching real config. + """ + claude_installed = False + claude_detail: list[str] = [] + codex_installed = False + codex_detail: list[str] = [] + + # --- Claude Code --- + settings_path = project_dir / ".claude" / "settings.json" + if settings_path.exists(): + try: + data = json.loads(settings_path.read_text(encoding="utf-8")) or {} + if isinstance(data, dict): + hooks = data.get("hooks", {}) + if isinstance(hooks, dict): + for event_name in _CLAUDE_HOOK_EVENTS: + entries = hooks.get(event_name, []) + cmds = {e.get("command", "") for e in entries if isinstance(e, dict)} + if _CLAUDE_HOOK_CMD in cmds: + claude_detail.append(event_name) + claude_installed = len(claude_detail) == len(_CLAUDE_HOOK_EVENTS) + except (json.JSONDecodeError, OSError) as exc: + # Log corruption rather than silently hiding it — the user needs to + # know their settings file is unreadable so they can fix it. + logger.warning( + "event=hooks_status_read_failed path=%s reason=%s", + settings_path, + type(exc).__name__, + ) + + # --- Codex --- + hooks_path = codex_home / "hooks.json" + if hooks_path.exists(): + try: + data = json.loads(hooks_path.read_text(encoding="utf-8")) or {} + if isinstance(data, dict): + entries = data.get("sessionStop", []) + + def _is_bach(entry: Any) -> bool: + if isinstance(entry, dict): + return _CODEX_HOOK_CMD in entry.get("command", "") + if isinstance(entry, str): + return _CODEX_HOOK_CMD in entry + return False + + if any(_is_bach(e) for e in entries): + codex_detail.append("sessionStop") + codex_installed = True + except (json.JSONDecodeError, OSError) as exc: + # Log corruption — see claude block above for rationale. + logger.warning( + "event=hooks_status_read_failed path=%s reason=%s", + hooks_path, + type(exc).__name__, + ) + + return { + "claude_installed": claude_installed, + "claude_detail": claude_detail, + "codex_installed": codex_installed, + "codex_detail": codex_detail, + } diff --git a/src/bach/services/judge_service.py b/src/bach/services/judge_service.py new file mode 100644 index 0000000..4d095e2 --- /dev/null +++ b/src/bach/services/judge_service.py @@ -0,0 +1,271 @@ +"""LLM-based session quality judge. + +Public API: + judge_session(events, *, task_title, current_status, model, llm_runner) + -> JudgeVerdict | None + +Wraps runtimes/llm_call.run_codex_json with: + - A bounded text window (total-char cap) built from the tail of events. + - A fixed JSON schema for {summary, suggested_status, needs_human, confidence}. + - Strict result validation: invalid status, out-of-range confidence, or + schema mismatch → return None. Caller treats None as "no-op". + - Runner failure (any exception) → return None, never propagate. + +Why None instead of raising: + The watcher loop calls judge on a best-effort basis. A transient codex + failure or an LLM response that doesn't match the schema must not crash + the watcher. The caller (session_watcher) logs the None and continues. + +Why a bounded window: + Transcripts can be arbitrarily long. The judge only needs context to + answer "is this session on track / blocked / done?" — recent events are + more informative than early ones. We take the tail of the event list + and hard-cap the total characters in the prompt section to keep latency + and cost predictable. +""" + +import logging +from collections.abc import Callable +from typing import Any + +from bach.domain.models import JudgeVerdict, SessionEvent +from bach.runtimes.llm_call import run_codex_json +from bach.services.status_service import VALID_STATUSES + +logger = logging.getLogger(__name__) + +# Maximum total characters for the events section of the judge prompt. +# Chosen so that even with the surrounding prompt boilerplate the full +# message stays under typical LLM context limits for a short call. +_MAX_EVENTS_CHARS = 8_000 + +# How many recent events to include before applying the char cap. +# Taking the tail keeps the most recent (most relevant) context. +_TAIL_EVENTS = 50 + + +def _build_judge_schema() -> dict[str, Any]: + """JSON Schema that constrains the LLM's output to the verdict shape. + + All four fields are required — the LLM must always emit them, even + when uncertain (emit 0.0 confidence and the current status as a + 'no change' suggestion). OpenAI structured-outputs rules: + - additionalProperties: false on every object + - every property in required + """ + return { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "suggested_status": {"type": "string"}, + "needs_human": {"type": "boolean"}, + "confidence": {"type": "number"}, + }, + "required": ["summary", "suggested_status", "needs_human", "confidence"], + "additionalProperties": False, + } + + +def _build_judge_prompt( + events: list[SessionEvent], + task_title: str, + current_status: str, + valid_statuses: frozenset[str], +) -> str: + """Build the judge prompt from a bounded window of recent events. + + Only the tail of `events` is included, and the events section is + capped at _MAX_EVENTS_CHARS to keep prompt size predictable regardless + of transcript length. + + The prompt embeds task_title and current_status so the judge has the + context needed to decide whether the session is progressing correctly + and what status transition (if any) is warranted. + """ + # Take the most recent events first, then apply char budget. + tail = events[-_TAIL_EVENTS:] if len(events) > _TAIL_EVENTS else events + + lines: list[str] = [] + total_chars = 0 + for event in reversed(tail): + # Format: "[kind] text_preview" + text_part = (event.text or "").strip() + line = f"[{event.kind}] {text_part}" + if total_chars + len(line) > _MAX_EVENTS_CHARS: + # Budget exhausted — stop adding older events. + break + lines.append(line) + total_chars += len(line) + 1 # +1 for newline + + # Reverse so chronological order is preserved in the prompt. + event_section = "\n".join(reversed(lines)) if lines else "(no events)" + + valid_list = ", ".join(sorted(valid_statuses)) + + return f"""You are a Bach session quality judge. + +Task: {task_title} +Current status: {current_status} +Valid statuses: {valid_list} + +Recent session events (most recent last): +{event_section} + +Assess this session and respond with a JSON object containing: + - summary: one sentence in present tense describing the session state. + - suggested_status: one of the valid statuses above that best fits the + current situation. Use the current status if no change is warranted. + - needs_human: true if a human must intervene (blocked, unclear direction, + ethics concern, etc.); false otherwise. + - confidence: your confidence in the suggested_status (0.0 = no idea, + 1.0 = certain). When uncertain, prefer a low confidence over inventing. + +Output ONLY the JSON object. No commentary. +""" + + +def _validate_raw(raw: dict[str, Any]) -> JudgeVerdict | None: + """Validate LLM response dict and construct JudgeVerdict, or return None. + + Returns None on any schema mismatch, invalid status, or out-of-range + confidence — the caller logs and treats None as a no-op. + + Why validate here and not rely on the JSON schema alone: + run_codex_json's --output-schema flag enforces structure, but + value-level constraints (status membership, numeric range) are + semantic — the schema can only enforce type. We need a second + layer here. + """ + # --- Required key presence and type checks --- + summary = raw.get("summary") + suggested_status = raw.get("suggested_status") + needs_human = raw.get("needs_human") + confidence = raw.get("confidence") + + if not isinstance(summary, str): + logger.warning( + "event=judge_verdict_invalid reason=bad_summary type=%s", + type(summary).__name__, + ) + return None + + if not isinstance(suggested_status, str): + logger.warning( + "event=judge_verdict_invalid reason=bad_suggested_status type=%s", + type(suggested_status).__name__, + ) + return None + + if not isinstance(needs_human, bool): + # Strict: "yes"/"no" strings or integers are NOT valid. + logger.warning( + "event=judge_verdict_invalid reason=bad_needs_human type=%s", + type(needs_human).__name__, + ) + return None + + if not isinstance(confidence, (int, float)) or isinstance(confidence, bool): + # isinstance(True, int) is True in Python — exclude bools explicitly. + logger.warning( + "event=judge_verdict_invalid reason=bad_confidence type=%s", + type(confidence).__name__, + ) + return None + + # --- Value-level validation --- + if suggested_status not in VALID_STATUSES: + logger.warning( + "event=judge_verdict_invalid reason=unknown_status suggested_status=%r", + suggested_status, + ) + return None + + confidence_float = float(confidence) + if not (0.0 <= confidence_float <= 1.0): + logger.warning( + "event=judge_verdict_invalid reason=confidence_out_of_range value=%s", + confidence_float, + ) + return None + + return JudgeVerdict( + summary=summary, + suggested_status=suggested_status, + needs_human=needs_human, + confidence=confidence_float, + ) + + +def judge_session( + events: list[SessionEvent], + *, + task_title: str, + current_status: str, + model: str, + llm_runner: Callable[..., dict[str, Any]] = run_codex_json, +) -> JudgeVerdict | None: + """Assess a session's health by asking the LLM to judge recent events. + + Args: + events: Full event list from transcript.read_tail_events or + accumulated by the watcher. The function itself caps + the window — callers need not pre-truncate. + task_title: Human-readable title from the artifact, embedded in + the prompt so the judge knows what the task is about. + current_status: The artifact's current status string, used both in + the prompt context and as the default suggestion when + the judge is uncertain. + model: Model slug forwarded verbatim to llm_runner. + llm_runner: Callable with the same signature as run_codex_json. + Injected so tests can supply a fake without subprocess. + + Returns: + JudgeVerdict on success; None on runner failure, schema mismatch, + invalid status, or out-of-range confidence. Caller must treat None + as "no information — keep current state." + """ + prompt = _build_judge_prompt( + events=events, + task_title=task_title, + current_status=current_status, + valid_statuses=VALID_STATUSES, + ) + schema = _build_judge_schema() + + logger.info( + "event=judge_session_start task_title=%r current_status=%r model=%r events=%d", + task_title, + current_status, + model, + len(events), + ) + + try: + raw = llm_runner(prompt=prompt, schema=schema, model=model) + except Exception: + # Any subprocess/network/parse failure is non-fatal — the watcher + # must keep running even if the judge can't reach the LLM. + logger.warning( + "event=judge_session_runner_failed task_title=%r", + task_title, + exc_info=True, + ) + return None + + verdict = _validate_raw(raw) + if verdict is None: + logger.warning( + "event=judge_session_invalid_response task_title=%r raw=%r", + task_title, + raw, + ) + return None + + logger.info( + "event=judge_session_done task_title=%r suggested_status=%r needs_human=%s confidence=%.2f", + task_title, + verdict.suggested_status, + verdict.needs_human, + verdict.confidence, + ) + return verdict diff --git a/src/bach/services/loop_gate.py b/src/bach/services/loop_gate.py new file mode 100644 index 0000000..e0f9da8 --- /dev/null +++ b/src/bach/services/loop_gate.py @@ -0,0 +1,404 @@ +"""Stop-hook verification gating for AFK loop continuations. + +When Claude Code fires the Stop hook, `evaluate_stop` decides whether to +block continuation and re-prompt the agent, or to let the session end. + +Opt-in: only gates when the artifact's frontmatter contains a `loop_gate` +block with `enabled: true` AND the task has recorded execution approval +(Mode 1 contract). Missing block or enabled=false → always pass through. + +Frontmatter block (added by the user or the grilling skill): + loop_gate: + enabled: true + verify_command: "make gate" # shell command, run in project cwd + max_continuations: 3 # default 3 — gate auto-releases at cap + +Why the Mode 1 check? Bach Mode 1 forbids the agent from starting +implementation unless Jordi has explicitly approved it (post_grill.readiness +== "ready" and post_grill.next_action in the execution-approved set). A +stop hook fires even during grilling — we must not gate those sessions. + +Cap exhaustion semantics: when continuation_count >= max_continuations +we STOP blocking (not keep blocking) and escalate to hitl_queue so a +human can review. This prevents an infinite blocking loop. The cap is +inclusive: `count=3, max=3` → exhausted on the third failure. +""" + +import logging +import subprocess +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +from bach.storage.artifacts import TaskArtifactStore + +logger = logging.getLogger(__name__) + +# The two next_action values that represent explicit execution approval. +# Both mean "this task is ready for an agent to execute code / run commands". +# "pause" / "stop" / "undecided" do NOT constitute approval. +_EXECUTION_APPROVED_ACTIONS: frozenset[str] = frozenset( + { + "execute_same_session", + "fresh_session", + } +) + +# Default continuation cap when max_continuations is absent or malformed. +# Matches the contract in dag.md. +_DEFAULT_MAX_CONTINUATIONS: int = 3 + +# Maximum chars of verify output to include in the block message. +# Keeps the continuation prompt tight while still giving the agent context. +_MAX_OUTPUT_CHARS: int = 2000 + + +@dataclass(frozen=True) +class StopDecision: + """Result of evaluating the stop hook gate. + + When block=True, `message` is the continuation prompt: it includes the + verify command failure output so the agent can act on the root cause. + When block=False, `message` is a brief explanation (for logs / tests). + """ + + block: bool + message: str + + +def evaluate_stop( + session_id: str, + *, + sessions_dir: Path, + store: TaskArtifactStore, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + timeout_s: int = 300, + set_status_fn: Callable[..., Any] | None = None, +) -> StopDecision: + """Evaluate whether to block a Claude Code Stop hook. + + Returns StopDecision(block=False) for all non-gating conditions so + the hook command exits 0 cleanly. Returns StopDecision(block=True, + message=...) only when the gate is fully armed AND verify fails. + + Args: + session_id: Claude Code session UUID from the Stop hook payload. + sessions_dir: Bach sessions dir (injected for testability; real + value is bach_home()/SESSIONS_DIRNAME). + store: TaskArtifactStore instance (may be unused when the + sidecar is absent — caller still injects it). + runner: Callable with the same signature as subprocess.run. + Injected for tests; defaults to real subprocess.run. + timeout_s: Passed to runner as `timeout=`. Default 300 s. + set_status_fn: Injected status-setter for testability. Defaults to + bach.services.status_service.set_task_status. + """ + # Resolve the set_status function. Lazy import to avoid a circular dep + # at module level (status_service → artifacts → ... back to services). + if set_status_fn is None: + from bach.services.status_service import set_task_status # noqa: PLC0415 + + set_status_fn = set_task_status + + # ------------------------------------------------------------------ + # 1. Look up the sidecar to find the artifact path. + # Missing sidecar → not a Bach-managed session → never gate. + # ------------------------------------------------------------------ + sidecar_path = sessions_dir / f"{session_id}.yaml" + if not sidecar_path.exists(): + logger.info( + "event=gate_skip_no_sidecar session_id=%s", + session_id, + ) + return StopDecision(block=False, message="no sidecar — not a Bach session") + + try: + sidecar_data = yaml.safe_load(sidecar_path.read_text()) or {} + except yaml.YAMLError as exc: + # Malformed sidecar — skip gating, don't corrupt the hook flow. + logger.warning( + "event=gate_skip_malformed_sidecar session_id=%s reason=%s", + session_id, + type(exc).__name__, + ) + return StopDecision(block=False, message="malformed sidecar") + + artifact_path_str = sidecar_data.get("artifact", "") + if not artifact_path_str: + logger.warning( + "event=gate_skip_sidecar_no_artifact session_id=%s", + session_id, + ) + return StopDecision(block=False, message="sidecar missing artifact field") + + artifact = Path(str(artifact_path_str)) + + # ------------------------------------------------------------------ + # 2. Load the artifact frontmatter. + # Missing artifact → skip gating non-fatally. + # ------------------------------------------------------------------ + if not artifact.exists(): + logger.warning( + "event=gate_skip_artifact_missing session_id=%s artifact=%s", + session_id, + artifact, + ) + return StopDecision(block=False, message="artifact not found") + + try: + payload = store.read_frontmatter(artifact) + except Exception as exc: # noqa: BLE001 — defensive; artifact read failures are non-fatal + logger.warning( + "event=gate_skip_artifact_read_failed session_id=%s reason=%s", + session_id, + type(exc).__name__, + ) + return StopDecision(block=False, message="artifact read error") + + # ------------------------------------------------------------------ + # 3. Check loop_gate enabled in frontmatter. + # Absent block or enabled=false → never gate. + # ------------------------------------------------------------------ + gate_cfg = payload.get("loop_gate") + if not isinstance(gate_cfg, dict): + logger.debug( + "event=gate_skip_no_config session_id=%s artifact=%s", + session_id, + artifact, + ) + return StopDecision(block=False, message="no loop_gate config") + + if not gate_cfg.get("enabled", False): + logger.debug( + "event=gate_skip_disabled session_id=%s artifact=%s", + session_id, + artifact, + ) + return StopDecision(block=False, message="loop_gate disabled") + + verify_command = gate_cfg.get("verify_command", "") + if not verify_command: + logger.warning( + "event=gate_skip_no_verify_command session_id=%s artifact=%s", + session_id, + artifact, + ) + return StopDecision(block=False, message="no verify_command configured") + + # ------------------------------------------------------------------ + # 4. Mode 1 — check execution approval. + # Gate only fires if Jordi approved execution (post_grill fields). + # ------------------------------------------------------------------ + post_grill = payload.get("post_grill", {}) + pg = post_grill if isinstance(post_grill, dict) else {} + readiness = pg.get("readiness", "not_ready") + next_action = pg.get("next_action", "undecided") + + if readiness != "ready" or next_action not in _EXECUTION_APPROVED_ACTIONS: + logger.info( + "event=gate_skip_no_approval session_id=%s readiness=%s next_action=%s", + session_id, + readiness, + next_action, + ) + return StopDecision(block=False, message="execution not approved (Mode 1)") + + # ------------------------------------------------------------------ + # 5. Cap exhaustion check — if already at max, escalate to HITL + # without blocking (avoids infinite block loop). + # ------------------------------------------------------------------ + max_continuations = _DEFAULT_MAX_CONTINUATIONS + raw_max = gate_cfg.get("max_continuations") + if isinstance(raw_max, int) and raw_max > 0: + max_continuations = raw_max + + current_count = gate_cfg.get("continuation_count", 0) + if not isinstance(current_count, int): + current_count = 0 + + if current_count >= max_continuations: + logger.warning( + "event=gate_exhausted session_id=%s count=%d max=%d artifact=%s", + session_id, + current_count, + max_continuations, + artifact, + ) + _append_log( + store, + artifact, + "gate_exhausted", + { + "count": current_count, + "max": max_continuations, + }, + ) + # Escalate to hitl_queue via observer authority. + try: + set_status_fn(artifact, "hitl_queue", "observer") + except Exception as exc: # noqa: BLE001 — escalation failure is non-fatal + logger.warning( + "event=gate_exhausted_status_failed session_id=%s reason=%s", + session_id, + type(exc).__name__, + ) + return StopDecision( + block=False, + message=( + f"gate cap exhausted after {current_count} continuations — escalated to hitl_queue" + ), + ) + + # ------------------------------------------------------------------ + # 6. Run the verify command. + # cwd is the project directory from the artifact's project.path. + # ------------------------------------------------------------------ + project_path_str = payload.get("project", {}).get("path", "") + cwd = Path(project_path_str) if project_path_str else artifact.parent + + verify_failed = False + output_for_message = "" + + try: + proc = runner( + verify_command, + shell=True, + cwd=str(cwd), + capture_output=True, + text=True, + timeout=timeout_s, + ) + if proc.returncode != 0: + verify_failed = True + # Combine stdout + stderr for the block message; trim to cap. + combined = (proc.stdout or "") + (proc.stderr or "") + output_for_message = combined[:_MAX_OUTPUT_CHARS] + except subprocess.TimeoutExpired: + verify_failed = True + output_for_message = f"verify_command timed out after {timeout_s}s" + logger.warning( + "event=gate_verify_timeout session_id=%s command=%r timeout_s=%d", + session_id, + verify_command, + timeout_s, + ) + except Exception as exc: # noqa: BLE001 — runner errors treated as verify failure + verify_failed = True + output_for_message = f"verify_command error: {type(exc).__name__}" + logger.warning( + "event=gate_verify_error session_id=%s command=%r reason=%s", + session_id, + verify_command, + type(exc).__name__, + ) + + # ------------------------------------------------------------------ + # 7. Act on the verify result. + # ------------------------------------------------------------------ + if not verify_failed: + logger.info( + "event=gate_pass session_id=%s command=%r artifact=%s", + session_id, + verify_command, + artifact, + ) + _append_log(store, artifact, "gate_pass", {"command": verify_command}) + return StopDecision(block=False, message="verify passed") + + # Verify failed — increment counter and block. + new_count = current_count + 1 + _write_continuation_count(store, artifact, payload, new_count) + + logger.info( + "event=gate_block session_id=%s count=%d max=%d command=%r artifact=%s", + session_id, + new_count, + max_continuations, + verify_command, + artifact, + ) + _append_log( + store, + artifact, + "gate_block", + { + "command": verify_command, + "count": new_count, + "max": max_continuations, + }, + ) + + # Build the continuation prompt: tell the agent WHY it was blocked + # and include the trimmed failure output so it can self-correct. + block_message = ( + f"Verification failed ({new_count}/{max_continuations} continuations used).\n" + f"Command: {verify_command}\n" + f"Output:\n{output_for_message.strip()}" + ) + return StopDecision(block=True, message=block_message) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _append_log( + store: TaskArtifactStore, + artifact: Path, + event: str, + extra: dict[str, Any] | None = None, +) -> None: + """Append a structured log entry to the artifact. Best-effort.""" + try: + payload = store.read_frontmatter(artifact) + log = payload.setdefault("log", []) + if not isinstance(log, list): + log = [] + payload["log"] = log + entry: dict[str, Any] = { + "timestamp": datetime.now(tz=UTC).isoformat(), + "event": event, + } + if extra: + entry.update(extra) + log.append(entry) + store.write_payload(artifact, payload) + except Exception as exc: # noqa: BLE001 — log failure must never break gate flow + logger.warning( + "event=gate_log_failed artifact=%s event=%s reason=%s", + artifact, + event, + type(exc).__name__, + ) + + +def _write_continuation_count( + store: TaskArtifactStore, + artifact: Path, + payload: dict[str, Any], + new_count: int, +) -> None: + """Persist the updated continuation_count into the artifact frontmatter. + + We also append the gate_block log entry separately in the caller, so + this function only updates the counter field. Non-fatal on failure. + """ + try: + # Re-read to get the latest state (log may have been written between reads). + latest = store.read_frontmatter(artifact) + gate_cfg = latest.get("loop_gate", {}) + if isinstance(gate_cfg, dict): + gate_cfg["continuation_count"] = new_count + latest["loop_gate"] = gate_cfg + store.write_payload(artifact, latest) + except Exception as exc: # noqa: BLE001 + logger.warning( + "event=gate_counter_write_failed artifact=%s reason=%s", + artifact, + type(exc).__name__, + ) diff --git a/src/bach/services/session_tracker.py b/src/bach/services/session_tracker.py index cc651a1..1b7ff42 100644 --- a/src/bach/services/session_tracker.py +++ b/src/bach/services/session_tracker.py @@ -1,4 +1,4 @@ -"""Sidecar lifecycle for SessionEnd-hook bookkeeping. +"""Sidecar lifecycle for SessionEnd-hook bookkeeping, plus spool helpers. When Bach launches a Claude/Codex session it stamps a small "sidecar" YAML file at `~/.bach/sessions/.yaml`. That file @@ -8,6 +8,7 @@ artifact: absolute path to the task artifact status_at_start: the artifact's `status:` value at launch time started_at: ISO-8601 wall-clock of launch (for GC) + runtime: AgentRuntime value ("claude-code" or "codex") — Phase 16 When the SessionEnd hook fires (`bach internal session-ended`), it reads the sidecar to find the artifact and the pre-session status. @@ -30,8 +31,18 @@ - Orphan sidecars (hook never fired, e.g. SIGKILL) accumulate. `gc_stale_sidecars(max_age_days)` prunes them — called best- effort from /refresh or on demand. + +Spool helpers (Phase 16): + `spool_path`, `append_hook_event`, `read_spool_events` form the + lightweight JSONL spool for hook-event delivery to the session + watcher. The watcher polls read_spool_events; hook commands call + append_hook_event via `bach internal hook-event`. The spool lives + alongside the YAML sidecars in the same sessions_dir, keyed by + session_id (separate file per session). """ +import fcntl +import json import logging from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -72,12 +83,19 @@ def record_session_start( session_id: str, artifact: Path, status_at_start: str, + runtime: str | None = None, ) -> Path: """Write the sidecar before launching the session. Idempotent. Returns the sidecar path so callers can log it. If a sidecar for this session_id already exists (replay, retry), it gets overwritten — the latest launch is always the source of truth. + + Phase 16: `runtime` is the AgentRuntime string value ("claude-code" + or "codex"). Stored so downstream consumers (session watcher, spool + drain) know which transcript format to parse. Backward-compatible: + existing callers that omit runtime get None persisted, and consumers + treat None as "unknown" rather than raising. """ sessions = _sessions_dir() sessions.mkdir(parents=True, exist_ok=True) @@ -87,6 +105,7 @@ def record_session_start( "artifact": str(artifact), "status_at_start": status_at_start, "started_at": datetime.now(tz=UTC).isoformat(), + "runtime": runtime, } # Atomic-ish write: write to .tmp then rename. Avoids a half-written # sidecar if disk fills up mid-write. (`.write_text` is one syscall @@ -95,10 +114,11 @@ def record_session_start( tmp.write_text(yaml.safe_dump(payload, sort_keys=True)) tmp.replace(target) logger.info( - "event=session_sidecar_written session_id=%s path=%s status_at_start=%s", + "event=session_sidecar_written session_id=%s path=%s status_at_start=%s runtime=%s", session_id, target, status_at_start, + runtime, ) return target @@ -117,8 +137,7 @@ def consume_session_end(session_id: str) -> SessionSidecar | None: sidecar_path = _sidecar_path(session_id) if not sidecar_path.exists(): logger.info( - "event=session_sidecar_missing session_id=%s " - "(not a Bach session, or already consumed)", + "event=session_sidecar_missing session_id=%s (not a Bach session, or already consumed)", session_id, ) return None @@ -184,3 +203,121 @@ def gc_stale_sidecars(max_age_days: int = 7) -> int: deleted, ) return deleted + + +# --------------------------------------------------------------------------- +# Phase 16 — Spool helpers (hook-event IPC channel) +# --------------------------------------------------------------------------- +# +# The spool is a lightweight JSONL file per session that carries hook-event +# payloads from short-lived hook commands (`bach internal hook-event`) to the +# long-running session watcher. The design keeps hook commands fast (one +# append, exit 0) while the watcher polls `read_spool_events` incrementally. +# +# Spool files share the sessions_dir with YAML sidecars but use a separate +# `.events.jsonl` extension so GC patterns remain independent. + + +def spool_path(session_id: str, *, sessions_dir: Path) -> Path: + """Return the spool file path for `session_id`. Pure computation, no I/O. + + The filename convention `.events.jsonl` is separate from the YAML + sidecar `.yaml` so GC and stat operations on each type stay clean. + """ + return sessions_dir / f"{session_id}.events.jsonl" + + +def append_hook_event( + session_id: str, + payload: dict[str, Any], + *, + sessions_dir: Path, +) -> None: + """Append one JSON line to the session's event spool. + + Designed to be fast and non-blocking — hook commands call this and + exit 0 immediately. The session watcher reads the spool separately. + + `sessions_dir` is injected so the tests never touch a real ~/.bach. + Creates `sessions_dir` on demand — a missing directory means this is + the first event for a fresh session, not an error. + """ + sessions_dir.mkdir(parents=True, exist_ok=True) + sp = spool_path(session_id, sessions_dir=sessions_dir) + line = json.dumps(payload, separators=(",", ":")) + # Use an exclusive advisory lock (LOCK_EX) around the write so concurrent + # hook processes — multiple Stop/PostToolUse hooks can fire in rapid + # succession for the same session — do not interleave large writes and + # corrupt a JSONL line. macOS/Linux only; this project is macOS-only. + with sp.open("a", encoding="utf-8") as fh: + fcntl.flock(fh, fcntl.LOCK_EX) + try: + fh.write(line + "\n") + finally: + fcntl.flock(fh, fcntl.LOCK_UN) + logger.debug( + "event=spool_event_appended session_id=%s spool=%s", + session_id, + sp, + ) + + +def read_spool_events( + session_id: str, + *, + sessions_dir: Path, + from_offset: int = 0, +) -> tuple[list[dict[str, Any]], int]: + """Read new events from the session spool, starting at `from_offset`. + + Returns (events, new_offset) where `new_offset` is the total number of + lines seen so far (caller stores it and passes as `from_offset` on the + next poll to avoid re-reading). + + Blank lines and malformed JSON lines are silently skipped — the spool + is append-only by hook commands; a partial last line is extremely rare, + but we must not raise if it happens. + + A missing spool file returns ([], 0). This is the normal case for + sessions that haven't received any hook events yet. + """ + sp = spool_path(session_id, sessions_dir=sessions_dir) + if not sp.exists(): + return [], 0 + + try: + all_lines = sp.read_text(encoding="utf-8").splitlines() + except OSError as exc: + # Spool vanished between exists() check and read — harmless race. + logger.warning( + "event=spool_read_failed session_id=%s reason=%s", + session_id, + type(exc).__name__, + ) + return [], from_offset + + total_lines = len(all_lines) + new_lines = all_lines[from_offset:] + + events: list[dict[str, Any]] = [] + for line in new_lines: + stripped = line.strip() + if not stripped: + continue # blank line — common with text-appended newlines + try: + obj = json.loads(stripped) + if isinstance(obj, dict): + events.append(obj) + # Non-dict JSON (array, scalar) silently dropped — spool + # contract is "one JSON object per line". + except json.JSONDecodeError: + # Malformed line — skip, don't raise. The watcher is + # consuming a live file; a partial last write can happen if + # the hook process was killed mid-write. + logger.debug( + "event=spool_malformed_line session_id=%s line=%r", + session_id, + stripped[:80], + ) + + return events, total_lines diff --git a/src/bach/services/session_watcher.py b/src/bach/services/session_watcher.py new file mode 100644 index 0000000..17d89c0 --- /dev/null +++ b/src/bach/services/session_watcher.py @@ -0,0 +1,594 @@ +"""Session observer loop — foreground run-and-exit watcher. + +Watches a single Bach task session by tailing its transcript and draining +the spool of hook events. When the session ends (or a stop/end spool event +arrives), it runs a final judge call and applies the verdict. + +Architecture constraints (from dag.md / PRD): + - NO threads, NO asyncio. Pure synchronous iteration with injected deps. + - follow_events + drain spool run in the same poll cycle. + - Judge fires at most once per judge_interval_seconds mid-session. + - Judge ALWAYS fires on stop/end spool events, regardless of interval. + - Verdict application: observer writes only via set_task_status with + source="observer" (enforced inside status_service — we don't re-check + the authority matrix here; we respect the confidence threshold and the + observer_moves config flag). + - All failures are non-fatal (log + continue) EXCEPT a missing artifact + (no artifact → nothing to watch; exit non-zero). + - Logs use structured event= key-value style matching settings.py. + +Injected seams (all required keyword args so tests can override without +monkey-patching): + follow_events_fn: replaces runtimes.transcript.follow_events + read_spool_events_fn: replaces services.session_tracker.read_spool_events + judge_fn: replaces services.judge_service.judge_session + set_task_status_fn: replaces services.status_service.set_task_status + (defaults to the real function; overridable for error-path tests) +""" + +import glob +import logging +import time +from collections.abc import Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +from bach.config.settings import BachConfig +from bach.domain.models import ( + AgentRuntime, + JudgeVerdict, + SessionEvent, + SessionEventKind, +) +from bach.runtimes.transcript import follow_events as _default_follow_events +from bach.services.status_service import OBSERVER_SOURCE, set_task_status +from bach.storage.artifacts import TaskArtifactStore + +logger = logging.getLogger(__name__) + +# Spool event type strings that signal session termination. +# These trigger a final judge call regardless of the judge interval. +_STOP_SPOOL_TYPES: frozenset[str] = frozenset({"stop", "session_end"}) + + +# --------------------------------------------------------------------------- +# Type aliases for injected callables — mirror the real function signatures +# so mypy stays happy without depending on the S3-owned module. +# --------------------------------------------------------------------------- + +# runtimes/transcript.follow_events signature (simplified for injection) +FollowEventsFn = Callable[ + [Path, AgentRuntime], # path, runtime (positional) + # keyword-only: poll_seconds, should_stop + # Returns an Iterator[SessionEvent] + Any, +] + +# services/session_tracker.read_spool_events signature +ReadSpoolEventsFn = Callable[ + ..., # (session_id, *, sessions_dir, from_offset) → (list[dict], int) + tuple[list[dict[str, Any]], int], +] + +# services/judge_service.judge_session signature +JudgeFn = Callable[..., JudgeVerdict | None] + +# services/status_service.set_task_status signature +SetTaskStatusFn = Callable[[Path, str, str], Any] + + +def _default_set_task_status( + artifact_path: Path, + new_status: str, + source: str, +) -> Any: + """Default set_task_status binding — delegates to status_service.""" + return set_task_status(artifact_path, new_status, source) + + +def _default_read_spool_events( + session_id: str, + *, + sessions_dir: Path, + from_offset: int = 0, +) -> tuple[list[dict[str, Any]], int]: + """ + Default spool reader — delegates to session_tracker.read_spool_events. + + Imported lazily to avoid coupling at module load time to the S3-owned + session_tracker module (which may not exist yet in other agents' branches). + The watcher test suite always injects a fake, so this default never + runs in unit tests. + """ + try: + from bach.services.session_tracker import read_spool_events + + return read_spool_events(session_id, sessions_dir=sessions_dir, from_offset=from_offset) + except ImportError: + # S3 module not yet available (parallel branch work). + logger.debug("event=observer_spool_import_missing session_id=%s", session_id) + return [], 0 + + +def _read_artifact_payload(artifact: Path) -> dict[str, Any] | None: + """Parse artifact YAML frontmatter via TaskArtifactStore; returns None on any failure. + + Delegates to TaskArtifactStore._split (which uses split("---", 2) and is safe + for artifacts whose markdown body contains "---" horizontal rules) and applies + Phase-10 status migration. Never does raw text.split("---") without maxsplit. + """ + try: + store = TaskArtifactStore.from_artifact_path(artifact) + result: dict[str, Any] = store.read_frontmatter(artifact) + return result + except (OSError, yaml.YAMLError, ValueError) as exc: + logger.warning( + "event=observer_artifact_read_error path=%s reason=%s", + artifact, + type(exc).__name__, + ) + return None + + +def _append_log_event(artifact: Path, entry: dict[str, Any]) -> None: + """Append one entry to the artifact's log array. Non-fatal on failure. + + Delegates to TaskArtifactStore so the frontmatter/body split is always + done with maxsplit=2 (safe for body sections that contain "---" horizontal + rules). We read the payload, mutate the log list, then write_payload back + — body is preserved verbatim by the store's _split / _write pair. + """ + try: + store = TaskArtifactStore.from_artifact_path(artifact) + payload = store.read_frontmatter(artifact) + log = payload.get("log", []) + if not isinstance(log, list): + log = [] + log.append(entry) + payload["log"] = log + store.write_payload(artifact, payload) + except Exception as exc: # noqa: BLE001 + logger.warning( + "event=observer_log_append_error path=%s reason=%s", + artifact, + type(exc).__name__, + ) + + +def _apply_verdict( + artifact: Path, + verdict: JudgeVerdict, + *, + config: BachConfig, + current_status: str, + session_id: str, + set_task_status_fn: SetTaskStatusFn, +) -> None: + """ + Apply a judge verdict to the artifact, respecting observer authority rules. + + Decision tree: + 1. If observer_moves=False OR confidence < threshold → log suggestion only. + 2. Else → call set_task_status with source="observer". + Status service enforces the authority matrix (executing→qa, etc.). + Any exception is caught and logged; non-fatal. + + `session_id` is included in every log line so the full judge/verdict decision + trail can be grepped end-to-end for a single session. + """ + log_only = not config.observer_moves or verdict.confidence < config.judge_confidence_threshold + + if log_only: + logger.info( + "event=observer_suggestion session_id=%s artifact=%s suggested=%r confidence=%.2f " + "needs_human=%s observer_moves=%s threshold=%.2f", + session_id, + artifact, + verdict.suggested_status, + verdict.confidence, + verdict.needs_human, + config.observer_moves, + config.judge_confidence_threshold, + ) + # Append the suggestion to the artifact log so the user can see it. + _append_log_event( + artifact, + { + "timestamp": datetime.now(UTC).isoformat(), + "event": "observer_suggestion", + "suggested_status": verdict.suggested_status, + "confidence": verdict.confidence, + "needs_human": verdict.needs_human, + "summary": verdict.summary, + "reason": ( + "observer_moves_disabled" + if not config.observer_moves + else "confidence_below_threshold" + ), + }, + ) + return + + # Observer move: attempt to write the status. + try: + set_task_status_fn(artifact, verdict.suggested_status, OBSERVER_SOURCE) + logger.info( + "event=observer_status_written session_id=%s artifact=%s from=%r to=%r " + "confidence=%.2f needs_human=%s", + session_id, + artifact, + current_status, + verdict.suggested_status, + verdict.confidence, + verdict.needs_human, + ) + except Exception as exc: # noqa: BLE001 + # Status service may raise InvalidStatusError (observer authority violation) + # or OSError (disk issue). Both are non-fatal — log and continue. + logger.warning( + "event=observer_status_write_error artifact=%s target=%r reason=%s", + artifact, + verdict.suggested_status, + type(exc).__name__, + ) + + +def _run_judge( + events: list[SessionEvent], + *, + artifact: Path, + config: BachConfig, + payload: dict[str, Any], + session_id: str, + judge_fn: JudgeFn, +) -> JudgeVerdict | None: + """ + Call the judge and return its verdict, or None on any failure. + + Isolation: judge exceptions are non-fatal per the spec. The watcher + must keep running even if the LLM is unreachable. + + `session_id` is included in log lines so judge decisions can be grepped + end-to-end for a single session. + """ + task_title = str(payload.get("task", {}).get("title", "") or payload.get("id", "")) + current_status = str(payload.get("status", "")) + + try: + verdict = judge_fn( + events=events, + task_title=task_title, + current_status=current_status, + model=config.normalize_model, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "event=observer_judge_error artifact=%s reason=%s", + artifact, + type(exc).__name__, + ) + return None + + if verdict is None: + logger.info( + "event=observer_judge_no_verdict session_id=%s artifact=%s task=%r status=%r", + session_id, + artifact, + task_title, + current_status, + ) + return verdict + + +def _is_stop_or_end_spool_event(event: dict[str, Any]) -> bool: + """True if this spool event signals session termination.""" + return event.get("type", "") in _STOP_SPOOL_TYPES + + +# --------------------------------------------------------------------------- +# Transcript path resolution (injectable for tests) +# --------------------------------------------------------------------------- + +# Default claude projects directory — mirrors sessions_view.py and cli/sessions.py. +_DEFAULT_CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" +# Default codex sessions directory — mirrors runtimes/commands.py prompt recipe. +_DEFAULT_CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions" + + +def _transcript_path_for( + runtime: AgentRuntime, + session_id: str, + project_path: Path, + *, + claude_projects_dir: Path = _DEFAULT_CLAUDE_PROJECTS_DIR, + codex_sessions_dir: Path = _DEFAULT_CODEX_SESSIONS_DIR, +) -> Path | None: + """Resolve the real transcript file path from runtime + session_id. + + Claude Code layout (mirrors session_discovery._decode_claude_slug in REVERSE): + ~/.claude/projects//.jsonl + Slug encoding: absolute project path with every "/" and "." replaced by "-". + Example: /home/user/myproject → -home-user-myproject + Source: runtimes/session_discovery._decode_claude_slug (which decodes + the slug) and runtimes/commands._build_claude_launch (which shows that + claude uses the cwd-flattened slug). + + Codex layout: + ~/.codex/sessions/**/ rollout-*-.jsonl + Codex generates session IDs internally; we glob for the file. + + Returns None when: + - session_id is empty/unknown (Codex before writeback). + - The file doesn't exist on disk (not yet created or wrong path). + - Runtime is unknown. + + This function never raises — callers treat None as "no transcript yet" + and fall back to spool-only mode. + """ + if not session_id: + return None + + if runtime is AgentRuntime.claude_code: + # Build the slug: replace every "/" and "." in the absolute path with "-". + # The leading "/" becomes a leading "-", producing the same encoding as + # what Claude Code creates when it flattens the cwd into a directory name. + # This mirrors the decode in session_discovery._decode_claude_slug which + # does the inverse: replace "-" back to "/" (with a leading "/" prefix). + slug = str(project_path).replace("/", "-").replace(".", "-") + candidate = claude_projects_dir / slug / f"{session_id}.jsonl" + if candidate.exists(): + return candidate + return candidate # return the expected path even if not yet created + + if runtime is AgentRuntime.codex: + # Glob for the rollout file — Codex embeds the session_id as the UUID + # suffix of the filename (rollout--.jsonl). We search the + # full sessions tree recursively because the date-tree YYYY/MM/DD nesting + # means we can't know the date without the file. This mirrors + # session_discovery._scan_codex which uses rglob("rollout-*.jsonl"). + pattern = str(codex_sessions_dir / "**" / f"rollout-*-{session_id}.jsonl") + matches = glob.glob(pattern, recursive=True) + if matches: + return Path(matches[0]) + return None + + return None + + +def watch_session( + artifact_path: Path, + *, + config: BachConfig, + sessions_dir: Path, + follow_events_fn: Callable[..., Any] = _default_follow_events, + read_spool_events_fn: ReadSpoolEventsFn = _default_read_spool_events, + judge_fn: JudgeFn, + set_task_status_fn: SetTaskStatusFn = _default_set_task_status, + transcript_path_fn: Callable[[AgentRuntime, str, Path], Path | None] = _transcript_path_for, +) -> int: + """Run the session observation loop for one task artifact. + + The loop alternates between consuming transcript events (from + follow_events_fn) and draining the spool (from read_spool_events_fn). + It exits when: + - A SessionEventKind.end event is seen in the transcript. + - A stop or session_end spool event is received. + - follow_events_fn exhausts its iterator (session ended externally). + + `transcript_path_fn` resolves the runtime transcript file path from + (runtime, session_id, project_path). Injectable for tests so no real + ~/.claude or ~/.codex directories are touched. When resolution returns + None (unknown session_id, Codex before writeback, etc.) the watcher + continues in spool-only mode — follow_events_fn still runs but will + receive the artifact path as a fallback (harmless since follow_events_fn + is fully faked in tests, and in production follow_events handles a + missing/empty file gracefully). + + Returns: + 0 on clean exit. + 1 if the artifact is missing or unreadable at startup (the one + fatal condition — nothing to watch). + + All other failures (judge errors, status write errors, spool errors) + are non-fatal: logged at WARNING and the loop continues. + """ + # Verify artifact exists before entering the loop — this is the one + # condition we cannot recover from: no artifact means no task to observe. + if not artifact_path.exists(): + logger.error( + "event=observer_artifact_missing path=%s", + artifact_path, + ) + return 1 + + payload = _read_artifact_payload(artifact_path) + if payload is None: + logger.error( + "event=observer_artifact_unreadable path=%s", + artifact_path, + ) + return 1 + + # Extract session ID from the artifact to address the spool. + session_id = str( + payload.get("agent", {}).get("runtime_session_id", "") + or payload.get("agent", {}).get("bach_session_id", "") + ) + + # Derive the runtime for transcript parsing — default to claude_code. + runtime_str = str(payload.get("agent", {}).get("runtime", "claude-code")) + try: + runtime = AgentRuntime(runtime_str) + except ValueError: + runtime = AgentRuntime.claude_code + + # Resolve the real runtime transcript path from session_id + project path. + # For claude-code: ~/.claude/projects//.jsonl + # For codex: glob ~/.codex/sessions/**/rollout-*-.jsonl + # Falls back to artifact_path if resolution fails (spool-only mode is still + # valid — follow_events handles a non-JSONL path gracefully in production, + # and tests always inject a fake follow_events_fn). + project_path = Path(str(payload.get("project", {}).get("path", ""))) + resolved = transcript_path_fn(runtime, session_id, project_path) + if resolved is None: + logger.info( + "event=observer_no_transcript session_id=%s runtime=%s " + "reason=path_unresolved — continuing in spool-only mode", + session_id, + runtime, + ) + transcript_path: Path = artifact_path # harmless fallback + else: + transcript_path = resolved + + logger.info( + "event=observer_start artifact=%s session_id=%s runtime=%s transcript=%s", + artifact_path, + session_id, + runtime, + transcript_path, + ) + + # Accumulated events across all poll cycles — passed to judge as context. + accumulated_events: list[SessionEvent] = [] + + spool_offset = 0 + last_judge_time: float = 0.0 # epoch seconds; 0 means never judged + should_exit = False + force_judge = False # True when a stop/end spool event arrives + + # should_stop is passed to follow_events_fn so it can exit its inner loop. + def _should_stop() -> bool: + return should_exit + + # Consume transcript events from follow_events_fn. + # The generator yields events until should_stop() returns True. + for event in follow_events_fn( + transcript_path, + runtime, + poll_seconds=1.0, + should_stop=_should_stop, + ): + accumulated_events.append(event) + + # Transcript end event → final judge and exit. + if event.kind is SessionEventKind.end: + logger.info( + "event=observer_transcript_end artifact=%s accumulated=%d", + artifact_path, + len(accumulated_events), + ) + force_judge = True + should_exit = True + + # Drain spool for any new hook events in this same cycle. + try: + spool_events, new_offset = read_spool_events_fn( + session_id, + sessions_dir=sessions_dir, + from_offset=spool_offset, + ) + spool_offset = new_offset + except Exception as exc: # noqa: BLE001 + logger.warning( + "event=observer_spool_error session_id=%s reason=%s", + session_id, + type(exc).__name__, + ) + spool_events = [] + + for spool_event in spool_events: + if _is_stop_or_end_spool_event(spool_event): + logger.info( + "event=observer_spool_stop session_id=%s type=%r", + session_id, + spool_event.get("type"), + ) + force_judge = True + should_exit = True + + # Judge decision: fire when forced (stop/end event) or when + # enough time has elapsed since the last judge call. + now = time.monotonic() + interval_elapsed = (now - last_judge_time) >= config.judge_interval_seconds + + should_judge = force_judge or (interval_elapsed and len(accumulated_events) > 0) + + if should_judge: + # Re-read the payload before judging to get the freshest status. + fresh_payload = _read_artifact_payload(artifact_path) or payload + verdict = _run_judge( + accumulated_events, + artifact=artifact_path, + config=config, + payload=fresh_payload, + session_id=session_id, + judge_fn=judge_fn, + ) + last_judge_time = time.monotonic() + + if verdict is not None: + current_status = str(fresh_payload.get("status", "")) + _apply_verdict( + artifact_path, + verdict, + config=config, + current_status=current_status, + session_id=session_id, + set_task_status_fn=set_task_status_fn, + ) + + if should_exit: + break + + # After follow_events_fn is exhausted: drain spool one final time. + # This handles the case where the transcript ended but a spool stop + # event arrived after the last transcript line. + try: + spool_events, _ = read_spool_events_fn( + session_id, + sessions_dir=sessions_dir, + from_offset=spool_offset, + ) + for spool_event in spool_events: + if _is_stop_or_end_spool_event(spool_event): + force_judge = True + except Exception as exc: # noqa: BLE001 + logger.warning( + "event=observer_final_spool_error session_id=%s reason=%s", + session_id, + type(exc).__name__, + ) + + # If a stop event was seen but we haven't judged yet (e.g. transcript + # was empty and only the spool had events), judge now. + if force_judge and last_judge_time == 0.0: + fresh_payload = _read_artifact_payload(artifact_path) or payload + verdict = _run_judge( + accumulated_events, + artifact=artifact_path, + config=config, + payload=fresh_payload, + session_id=session_id, + judge_fn=judge_fn, + ) + if verdict is not None: + current_status = str(fresh_payload.get("status", "")) + _apply_verdict( + artifact_path, + verdict, + config=config, + current_status=current_status, + session_id=session_id, + set_task_status_fn=set_task_status_fn, + ) + + logger.info( + "event=observer_done artifact=%s total_events=%d", + artifact_path, + len(accumulated_events), + ) + return 0 diff --git a/src/bach/services/sessions_service.py b/src/bach/services/sessions_service.py new file mode 100644 index 0000000..b42e7eb --- /dev/null +++ b/src/bach/services/sessions_service.py @@ -0,0 +1,522 @@ +"""Join layer between DiscoveredSession (runtime plane) and Bach task artifacts. + +This service answers one question: given what the runtime says is running, +which Bach task (if any) owns each session? It bridges runtimes/session_discovery +(which knows only filesystem layout) and storage/artifacts (which knows task state). + +Public API: + SessionRow — DiscoveredSession + task linkage (frozen dataclass) + list_sessions — discover, filter, join, preview + adopt_session — link a session to a task artifact (writes runtime_session_id) + resume_session — return/launch the resume command for a session + scan_artifact_paths — recursively find .md artifacts under a directory + find_artifact_for_ref — find artifact Path for a task-id or session-id ref + +Design notes: + - All external I/O (discover_sessions, read_tail_events, iTerm launch) is + injected so callers and tests can swap them without touching real state. + - adopt_session is the ONLY write path. All other operations are read-only. + - Prefix matching on session IDs: unique prefix → resolved; ambiguous → error. + - "already linked" guard: refuses to re-link unless force=True OR the currently + linked session is ended (safe to overwrite). This prevents accidentally + stomping an in-flight session. + - Frontmatter reads/writes delegate to TaskArtifactStore to avoid duplicating + the "---" split logic (which breaks on bodies containing horizontal rules + if maxsplit is omitted). +""" + +import logging +import shlex +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from bach.domain.models import ( + AgentRuntime, + DiscoveredSession, + SessionEvent, + SessionEventKind, + SessionLiveness, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Domain type +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SessionRow: + """One discovered session, optionally linked to a Bach task artifact. + + Frozen because this is a point-in-time snapshot — nothing should mutate + it after construction. list_sessions is the sole producer. + + task_id / artifact_path are None when no artifact's runtime_session_id + matches this session's session_id. preview is None when read_tail_events + fails or produces no assistant/user events (degrade gracefully). + """ + + session: DiscoveredSession + task_id: str | None # e.g. "t47" when linked, else None + artifact_path: Path | None # path to matched artifact, else None + preview: str | None # last assistant or user turn text (~200 chars) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _read_artifact_frontmatter(artifact_path: Path) -> dict[str, Any] | None: + """Parse frontmatter from a Bach artifact via the canonical store. + + Delegates to TaskArtifactStore._split so the "---" split is done with + maxsplit=2 — bodies that contain a horizontal rule ("---") are not + corrupted. Returns None on any parse error. + + Best-effort: a corrupted artifact must not break the whole session list. + """ + try: + # Import here to avoid a module-level circular dep: storage.artifacts + # imports services.dag_service which is fine, but keeping the import + # local mirrors the pattern used elsewhere in this file. + from bach.storage.artifacts import TaskArtifactStore # noqa: PLC0415 + + payload, _body = TaskArtifactStore._split(artifact_path) + return payload + except (OSError, ValueError) as exc: + logger.debug( + "event=artifact_read_failed path=%s reason=%s", + artifact_path, + type(exc).__name__, + ) + return None + + +def _build_session_index( + artifact_paths: list[Path], +) -> dict[str, tuple[str, Path]]: + """Build {runtime_session_id → (task_id, artifact_path)} from artifact list. + + Only artifacts with non-null runtime_session_id are indexed. Others are + silently skipped — they are unlinked tasks. + """ + index: dict[str, tuple[str, Path]] = {} + for artifact in artifact_paths: + payload = _read_artifact_frontmatter(artifact) + if payload is None: + continue + agent = payload.get("agent") or {} + sid = agent.get("runtime_session_id") + task_id = payload.get("task_id") + if sid and task_id: + index[str(sid)] = (str(task_id), artifact) + return index + + +def _get_preview( + session: DiscoveredSession, + read_events_fn: Callable[[Path, AgentRuntime], list[SessionEvent]], +) -> str | None: + """Return the last assistant_turn text, falling back to last user_turn. + + Degrades to None when read_events_fn raises or produces no suitable event. + The text field is already truncated by transcript.py (~200 chars), so we + don't re-truncate here. + """ + try: + events = read_events_fn(session.transcript_path, session.runtime) + except Exception as exc: + logger.debug( + "event=preview_read_failed session=%s reason=%s", + session.session_id, + type(exc).__name__, + ) + return None + + # Prefer the last assistant turn; fall back to last user turn. + last_assistant: str | None = None + last_user: str | None = None + for event in events: + if event.kind == SessionEventKind.assistant_turn and event.text: + last_assistant = event.text + elif event.kind == SessionEventKind.user_turn and event.text: + last_user = event.text + + return last_assistant or last_user + + +def _scan_artifact_paths(artifacts_dir: Path) -> list[Path]: + """Recursively find all .md files under artifacts_dir. Empty if not present. + + The Bach layout is /.bach/runs/YYYY-MM-DD/task-*.md. We glob + broadly so any project structure under artifacts_dir is covered. + """ + if not artifacts_dir.exists(): + return [] + return list(artifacts_dir.rglob("*.md")) + + +def _resolve_session_ref( + session_ref: str, + discovered: list[DiscoveredSession], +) -> DiscoveredSession: + """Resolve a session_ref (full ID or unique prefix) to a DiscoveredSession. + + Matching rules: + 1. Exact match on session_id → returns immediately. + 2. Prefix match (session_id.startswith(ref)) → if exactly one match, + returns it; if multiple, raises ValueError listing candidates; + if zero, raises ValueError (not found). + + Raises: + ValueError: no match or ambiguous prefix. + """ + # Try exact match first (fast path). + for s in discovered: + if s.session_id == session_ref: + return s + + # Prefix scan. + candidates = [s for s in discovered if s.session_id.startswith(session_ref)] + if len(candidates) == 1: + return candidates[0] + if len(candidates) > 1: + ids = ", ".join(c.session_id for c in candidates) + raise ValueError(f"Ambiguous session prefix {session_ref!r} — matches: {ids}") + raise ValueError(f"No session found matching {session_ref!r}") + + +def _resolve_task_ref( + task_ref: str, + artifact_paths: list[Path], +) -> tuple[Path, dict[str, Any]]: + """Find the artifact and its payload for a given task_ref (stable ID like 't5'). + + Raises: + ValueError: no artifact with that task_id found. + """ + for artifact in artifact_paths: + payload = _read_artifact_frontmatter(artifact) + if payload and str(payload.get("task_id", "")) == task_ref: + return artifact, payload + raise ValueError(f"No task found with id {task_ref!r}") + + +def _is_session_live( + session_id: str, + discovered: list[DiscoveredSession], +) -> bool: + """Return True if the session exists AND is not ended.""" + for s in discovered: + if s.session_id == session_id: + return s.liveness != SessionLiveness.ended + return False # not discovered → treat as ended + + +def _write_artifact_field( + artifact: Path, + dotted_path: str, + value: Any, +) -> None: + """Write a single nested YAML field to an artifact's frontmatter. + + Uses TaskArtifactStore to read/write so the "---" split is safe for + artifact bodies that contain horizontal-rule "---" lines. + + dotted_path like "agent.runtime_session_id" navigates nested dicts. + Raises ValueError if the frontmatter is not parseable. + """ + from bach.storage.artifacts import TaskArtifactStore # noqa: PLC0415 + + payload, body = TaskArtifactStore._split(artifact) + + # Navigate / create intermediate dicts along the dotted path. + parts = dotted_path.split(".") + current: dict[str, Any] = payload + for part in parts[:-1]: + if part not in current or not isinstance(current[part], dict): + current[part] = {} + current = current[part] + current[parts[-1]] = value + + TaskArtifactStore._write(artifact, payload, body) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def scan_artifact_paths(artifacts_dir: Path) -> list[Path]: + """Public alias for _scan_artifact_paths for use by CLI handlers. + + Keeps the service boundary clean: the CLI should only import from the + public API, not reach into private helpers. + """ + return _scan_artifact_paths(artifacts_dir) + + +def find_artifact_for_ref( + ref: str, + artifact_paths: list[Path], +) -> Path | None: + """Find an artifact by task_id or session_id prefix. + + Resolution order: + 1. Exact task_id match (ref looks like "tN" or any task_id value). + 2. Session-ID prefix match via the artifact index. + + Returns the first matching artifact Path, or None when nothing matches. + This is the single public lookup used by CLI handlers that previously + imported private helpers (_scan_artifact_paths, _read_artifact_frontmatter, + _build_session_index) inline. + """ + # Pass 1: try task_id match. + for ap in artifact_paths: + payload = _read_artifact_frontmatter(ap) + if payload and str(payload.get("task_id", "")) == ref: + return ap + + # Pass 2: session-id prefix match via the artifact index. + index = _build_session_index(artifact_paths) + for sid, (_task_id, ap) in index.items(): + if sid.startswith(ref): + return ap + + return None + + +def list_sessions( + *, + sessions_dir: Path, + artifacts_dir: Path, + discover_fn: Callable[[], list[DiscoveredSession]], + read_events_fn: Callable[[Path, AgentRuntime], list[SessionEvent]], + project: str | None = None, + runtime: str | None = None, + state: str | None = None, + artifact_paths: list[Path] | None = None, +) -> list[SessionRow]: + """Discover sessions and join them with Bach task artifacts. + + Filters are applied in order (project → runtime → state). All filters + are AND-ed — a session must match every supplied filter. + + artifact_paths can be injected for testing; when None, the function + scans artifacts_dir recursively. This allows tests to provide specific + artifacts without touching real project directories. + + Preview is best-effort: any failure in read_events_fn degrades to None + without propagating. + """ + discovered = discover_fn() + + # Apply filters before the join to avoid reading events for filtered-out sessions. + if project is not None: + discovered = [ + s for s in discovered if s.project_dir is not None and str(s.project_dir) == project + ] + if runtime is not None: + discovered = [s for s in discovered if s.runtime.value == runtime] + if state is not None: + discovered = [s for s in discovered if s.liveness.value == state] + + # Build artifact lookup from injected paths or scanned directory. + paths = artifact_paths if artifact_paths is not None else _scan_artifact_paths(artifacts_dir) + index = _build_session_index(paths) + + rows: list[SessionRow] = [] + for session in discovered: + linkage = index.get(session.session_id) + task_id: str | None = None + artifact_path: Path | None = None + if linkage is not None: + task_id, artifact_path = linkage + + preview = _get_preview(session, read_events_fn) + + rows.append( + SessionRow( + session=session, + task_id=task_id, + artifact_path=artifact_path, + preview=preview, + ) + ) + + logger.debug( + "event=list_sessions total=%d filtered_project=%s filtered_runtime=%s filtered_state=%s", + len(rows), + project, + runtime, + state, + ) + return rows + + +def adopt_session( + session_ref: str, + task_ref: str, + *, + artifact_paths: list[Path], + discover_fn: Callable[[], list[DiscoveredSession]], + force: bool = False, +) -> Path: + """Link a discovered session to a Bach task artifact. + + Writes agent.runtime_session_id into the artifact's frontmatter and + appends a log event. The session_ref can be a full UUID or a unique prefix. + + Guard: if the artifact is already linked to a DIFFERENT session that is + still live (not ended), refuse unless force=True. If the existing linked + session is ended, proceed without force (safe to overwrite). + + Uses TaskArtifactStore for all reads/writes so bodies with "---" horizontal + rules are handled correctly (split uses maxsplit=2 internally). + + Raises: + ValueError: session_ref not found, task_ref not found, or ambiguous prefix. + ValueError: task already linked to a live session (without force=True). + """ + from bach.storage.artifacts import TaskArtifactStore # noqa: PLC0415 + + discovered = discover_fn() + + # Resolve both ends of the link before writing anything. + session = _resolve_session_ref(session_ref, discovered) + artifact, payload = _resolve_task_ref(task_ref, artifact_paths) + + # Check if the task is already linked to a different session. + agent = payload.get("agent") or {} + existing_sid = agent.get("runtime_session_id") + if existing_sid and existing_sid != session.session_id: + if not force and _is_session_live(existing_sid, discovered): + raise ValueError( + f"Task {task_ref!r} is already linked to session " + f"{existing_sid!r} which is still live. " + f"Use force=True to overwrite." + ) + + # Write the session_id into the artifact using the canonical store helpers. + # This read-modify-write is safe: _split uses maxsplit=2 so "---" in the + # body does not corrupt the frontmatter parse. + fm, body = TaskArtifactStore._split(artifact) + fm_agent = fm.setdefault("agent", {}) + if not isinstance(fm_agent, dict): + fm["agent"] = {} + fm_agent = fm["agent"] + fm_agent["runtime_session_id"] = session.session_id + + # Append log event (best-effort — don't crash if key is missing). + log = fm.get("log") or [] + if not isinstance(log, list): + log = [] + log.append( + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "event": f"session_adopted session_id={session.session_id}", + } + ) + fm["log"] = log + + TaskArtifactStore._write(artifact, fm, body) + + logger.info( + "event=session_adopted session_id=%s task_ref=%s artifact=%s force=%s", + session.session_id, + task_ref, + artifact, + force, + ) + return artifact + + +def resume_session( + session_ref: str, + *, + artifact_paths: list[Path], + discover_fn: Callable[[], list[DiscoveredSession]], + launch_fn: Callable[[str, Path | None, str], bool], +) -> str: + """Return (and launch) the resume command for a session. + + Resolution: + 1. Find the session via session_ref (exact or unique prefix). + 2. If linked to an artifact with a resume_command: use it (persisted). + 3. Otherwise (orphan or no resume_command): build from runtime info. + + Launches via launch_fn (usually iterm.launch_in_iterm). On failure, + prints the fallback command so the user can run it manually — consistent + with how task launch works in services/task_service.py. + + Raises: + ValueError: session_ref not found. + """ + discovered = discover_fn() + session = _resolve_session_ref(session_ref, discovered) + + # Build artifact lookup to find a linked resume_command. + index = _build_session_index(artifact_paths) + linkage = index.get(session.session_id) + + resume_cmd: str | None = None + if linkage is not None: + _task_id, artifact = linkage + payload = _read_artifact_frontmatter(artifact) + if payload: + agent = payload.get("agent") or {} + resume_cmd = agent.get("resume_command") or None + + if resume_cmd is None: + # Orphan session: build command from what we know. + resume_cmd = _build_orphan_resume_command(session) + + # Launch via iTerm with printed fallback (mirrors task_service pattern). + project_path = session.project_dir + title = f"resume {session.session_id[:8]}" + ok = launch_fn(resume_cmd, project_path, title) + if not ok: + # Printed fallback is the safety net when iTerm automation fails. + print(f"Run manually:\n {resume_cmd}") + + logger.info( + "event=session_resume session_id=%s linked=%s launched=%s", + session.session_id, + linkage is not None, + ok, + ) + return resume_cmd + + +def _build_orphan_resume_command(session: DiscoveredSession) -> str: + """Build a best-effort resume command for a session with no Bach artifact. + + For Claude Code: cd && claude --resume + For Codex: codex --cd --skip-git-repo-check resume + + When project_dir is None (unknown cwd), the cd/--cd prefix is omitted. + The command may not work perfectly, but it's the best we can produce + without an artifact. + + Paths and session IDs are shell-quoted via shlex.quote so that paths + containing single quotes, spaces, or other special characters produce + a valid shell command (not a broken one). + """ + sid = shlex.quote(session.session_id) + proj = session.project_dir + + if session.runtime == AgentRuntime.claude_code: + if proj: + return f"cd {shlex.quote(str(proj))} && claude --resume {sid}" + return f"claude --resume {sid}" + + # Codex + if proj: + return f"codex --cd {shlex.quote(str(proj))} --skip-git-repo-check resume {sid}" + return f"codex resume {sid}" diff --git a/src/bach/services/status_service.py b/src/bach/services/status_service.py index d07d6ed..e686d16 100644 --- a/src/bach/services/status_service.py +++ b/src/bach/services/status_service.py @@ -43,19 +43,21 @@ # build a throwaway spike (prototype) before committing to a PRD. The # validator (`storage/artifacts._ENUM_VALIDATORS["status"]`) mirrors this # set — if you add a value here, mirror it there. -VALID_STATUSES: frozenset[str] = frozenset({ - "inbox", - "research", - "prototype", - "prd", - "kanban_issues", - "afk_queue", - "hitl_queue", - "executing", - "qa", - "done", - "carried_forward", -}) +VALID_STATUSES: frozenset[str] = frozenset( + { + "inbox", + "research", + "prototype", + "prd", + "kanban_issues", + "afk_queue", + "hitl_queue", + "executing", + "qa", + "done", + "carried_forward", + } +) # Recommended next-step transitions for each status. **Not enforced** — @@ -109,6 +111,27 @@ class InvalidStatusError(ValueError): """ +# Sentinel source value for automated observer writes (session watcher, judge). +# Observer writes are intentionally restricted: they can only hand off work to +# qa or escalate to hitl_queue — they must never declare completion (done), +# move backward, or touch pre-execution stages. This constant is the single +# choke-point where that authority is checked. +OBSERVER_SOURCE = "observer" + +# Allowed (old_status, new_status) pairs when source == OBSERVER_SOURCE. +# Derived directly from the PRD architecture rule: +# executing→qa (hand-off after session) and +# {executing,qa,afk_queue}→hitl_queue (escalation to human judgment). +_OBSERVER_ALLOWED_TRANSITIONS: frozenset[tuple[str, str]] = frozenset( + { + ("executing", "qa"), + ("executing", "hitl_queue"), + ("qa", "hitl_queue"), + ("afk_queue", "hitl_queue"), + } +) + + class StatusChange(NamedTuple): """Result of a status transition. @@ -149,8 +172,7 @@ def set_task_status( """ if new_status not in VALID_STATUSES: raise InvalidStatusError( - f"{new_status!r} is not a valid status. " - f"Must be one of: {sorted(VALID_STATUSES)}" + f"{new_status!r} is not a valid status. Must be one of: {sorted(VALID_STATUSES)}" ) store = TaskArtifactStore.from_artifact_path(artifact) @@ -159,6 +181,17 @@ def set_task_status( old_status = str(payload.get("status", "")) suggestions = suggested_next(new_status) + # Observer authority enforcement: automated observers (source="observer") + # may only perform the two safe transitions defined in the PRD architecture + # rule. Checked after reading old_status so the error message can name + # both ends of the rejected move. + if source == OBSERVER_SOURCE and (old_status, new_status) not in _OBSERVER_ALLOWED_TRANSITIONS: + raise InvalidStatusError( + f"observer source may not transition {old_status!r} → {new_status!r}. " + f"Allowed observer moves: executing→qa, " + f"{{executing,qa,afk_queue}}→hitl_queue." + ) + if old_status == new_status: # No-op: don't bump the file's mtime, but still log so the audit # trail shows the (would-be) transition. Suggestions for the @@ -166,7 +199,9 @@ def set_task_status( # "you're at qa; consider: done / executing / carried_forward". logger.info( "event=task_status_noop path=%s status=%s source=%s", - artifact, new_status, source, + artifact, + new_status, + source, ) return StatusChange(old=old_status, new=new_status, suggestions=suggestions) @@ -182,18 +217,23 @@ def set_task_status( # user's status update. log = [] payload["log"] = log - log.append({ - "timestamp": datetime.now(tz=UTC).isoformat(), - "event": "status_changed", - "from": old_status, - "to": new_status, - "source": source, - }) + log.append( + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "event": "status_changed", + "from": old_status, + "to": new_status, + "source": source, + } + ) store.write_payload(artifact, payload) logger.info( "event=task_status_change path=%s from=%s to=%s source=%s", - artifact, old_status, new_status, source, + artifact, + old_status, + new_status, + source, ) return StatusChange(old=old_status, new=new_status, suggestions=suggestions) @@ -211,12 +251,15 @@ def append_session_ended_log(artifact: Path) -> None: if not isinstance(log, list): log = [] payload["log"] = log - log.append({ - "timestamp": datetime.now(tz=UTC).isoformat(), - "event": "session_ended", - }) + log.append( + { + "timestamp": datetime.now(tz=UTC).isoformat(), + "event": "session_ended", + } + ) store.write_payload(artifact, payload) logger.info( "event=task_session_ended_logged path=%s status=%s", - artifact, payload.get("status"), + artifact, + payload.get("status"), ) diff --git a/src/bach/services/task_service.py b/src/bach/services/task_service.py index 4de5bff..2fcf3c9 100644 --- a/src/bach/services/task_service.py +++ b/src/bach/services/task_service.py @@ -14,6 +14,8 @@ """ import logging +import subprocess +import sys from collections.abc import Callable from pathlib import Path @@ -79,8 +81,7 @@ def add_task( # decision that picked the mode. selected_mode = mode or project.default_mode or self._suggest_mode(project) logger.info( - "event=mode_resolved project=%s explicit=%s project_default=%s " - "resolved=%s", + "event=mode_resolved project=%s explicit=%s project_default=%s resolved=%s", project.key, mode.value if mode else "none", project.default_mode.value if project.default_mode else "none", @@ -164,24 +165,25 @@ def launch_task( # Stamp a session sidecar so the SessionEnd hook can recognise this # session when it fires and apply the conservative status update. - # Best-effort: a sidecar write failure shouldn't block the launch - # (the user still wants their iTerm tab). Only Claude Code sessions - # get tracked — codex has a different session lifecycle and no - # SessionEnd-equivalent hook today. + # Phase 16: Codex sessions now also get sidecars (removed the earlier + # Claude-only guard). Both runtimes can use the spool/watcher pipeline. + # Best-effort: a sidecar write failure shouldn't block the launch. runtime_str = str(payload["agent"]["runtime"]) runtime_session_id = payload["agent"].get("runtime_session_id") current_status = str(payload.get("status", "")) - if runtime_str == AgentRuntime.claude_code.value and runtime_session_id: + if runtime_session_id: try: record_session_start( session_id=str(runtime_session_id), artifact=artifact, status_at_start=current_status, + runtime=runtime_str, ) except Exception as exc: # noqa: BLE001 — best-effort, never block logger.warning( "event=session_sidecar_write_failed path=%s reason=%s", - artifact, type(exc).__name__, + artifact, + type(exc).__name__, ) title = str(payload["agent"]["runtime_session_name"]) @@ -211,6 +213,46 @@ def launch_task( bach_id, ) + # Phase 16: when watch_on_launch is enabled, spawn a detached background + # session watcher. The watcher follows transcript events and the spool + # to drive status updates via the judge. + # Detached (no stdin/stdout, no wait) so it outlives this process. + if config.watch_on_launch: + self._spawn_session_watcher(artifact) + + def _spawn_session_watcher(self, artifact: Path) -> None: + """Spawn `bach internal session-watch ` as a detached child. + + Uses the same Python interpreter so the watcher inherits the same + environment (venv, PATH, etc.) without requiring `bach` to be on PATH. + subprocess.Popen with close_fds=True + stdout/stderr DEVNULL detaches + the child from the parent terminal. We do NOT wait for it. + + Best-effort: a spawn failure logs a warning but never blocks the + primary launch. The user has their iTerm tab; the watcher is bonus. + """ + try: + # Construct the same invocation as `bach internal session-watch` + # by re-using the current interpreter and module entry point. + cmd = [sys.executable, "-m", "bach", "internal", "session-watch", str(artifact)] + subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ) + logger.info( + "event=session_watcher_spawned artifact=%s", + artifact, + ) + except Exception as exc: # noqa: BLE001 — best-effort, never block launch + logger.warning( + "event=session_watcher_spawn_failed artifact=%s reason=%s", + artifact, + type(exc).__name__, + ) + def validate_task(self, artifact: Path) -> list[str]: """Return a list of validation errors for the artifact, empty if OK.""" return TaskArtifactStore.from_artifact_path(artifact).validate(artifact) diff --git a/tests/unit/test_afk_runner.py b/tests/unit/test_afk_runner.py new file mode 100644 index 0000000..cc3856a --- /dev/null +++ b/tests/unit/test_afk_runner.py @@ -0,0 +1,761 @@ +"""Tests for services/afk_runner.py — run_afk_task headless driver. + +Strategy: + - All external seams (turn_runner, judge, gate, clock) are injected callables. + - Artifact is written to tmp_path — no real ~/.bach touched. + - Never calls real LLM, real iTerm, real subprocess, or real status_service. + - Assertions cover the three spec-mandated invariants: + 1. Hard bounds: max_turns AND time_budget are both enforced. + 2. Escalation: needs_human→hitl_queue, verify_pass→qa. NEVER done. + 3. never-done: the runner NEVER sets status=done under any path. + +Contract from dag.md: + - Claims afk_queue task → executing (source="afk"). + - Loop ≤ afk_max_turns AND ≤ time budget (minutes). + - Each iteration: run one turn via turn_runner; verify via gate; + gate pass → qa + stop; judge needs_human → hitl_queue + stop; + else continue with a continuation message. + - Bounds enforced regardless of judge outcome. + - Prints resume command + artifact path on exit (not tested here — + that's stdout behavior; we test via return value + artifact state). + - source="afk" is a normal-authority write; the only allowed targets + for this source are {executing, qa, hitl_queue}. +""" + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import yaml + +from bach.config.settings import BachConfig +from bach.domain.models import JudgeVerdict +from bach.services.afk_runner import AfkResult, run_afk_task + +# --------------------------------------------------------------------------- +# Artifact helpers — mirror the style from test_session_watcher.py +# --------------------------------------------------------------------------- + + +def _write_artifact( + tmp_path: Path, + status: str = "afk_queue", + title: str = "Test AFK Task", + runtime: str = "claude-code", + session_id: str = "test-session-uuid", + project_path: str | None = None, +) -> Path: + """Write a minimal valid task artifact for AFK runner tests.""" + project_dir = tmp_path / "proj" + date_dir = project_dir / ".bach" / "runs" / "2026-06-10" + date_dir.mkdir(parents=True) + artifact = date_dir / "task-100000-afk-test.md" + proj_path = project_path or str(project_dir) + payload: dict[str, Any] = { + "id": "task-100000-afk-test", + "date": "2026-06-10", + "status": status, + "project": {"name": "proj", "path": proj_path}, + "agent": { + "runtime": runtime, + "bach_session_id": "bach-afk-id", + "runtime_session_id": session_id, + "runtime_session_name": "bach-test-proj", + "launch_command": None, + "resume_command": f"cd {proj_path!r} && claude --resume {session_id}", + "restart_command": None, + }, + "skill": {"name": "grill-me", "status": "not_started"}, + "task": { + "title": title, + "original_description": title, + "objective": None, + "non_goals": [], + "constraints": [], + "likely_files_or_areas": [], + "acceptance_checks": [], + "risks_or_unknowns": [], + }, + "post_grill": {"readiness": "not_ready", "next_action": "undecided"}, + "log": [{"timestamp": "2026-06-10T00:00:00+00:00", "event": "created"}], + } + body = "\n## Grill Transcript / Notes\n\n## Final Task Contract\n\n## Carry Forward\n" + artifact.write_text(f"---\n{yaml.safe_dump(payload, sort_keys=False)}---\n{body}") + return artifact + + +def _read_status(artifact: Path) -> str: + """Parse artifact and return the current status.""" + return str(yaml.safe_load(artifact.read_text().split("---")[1])["status"]) + + +def _read_log(artifact: Path) -> list[dict[str, Any]]: + """Parse artifact and return the log array.""" + raw = yaml.safe_load(artifact.read_text().split("---")[1]) + return list(raw.get("log", [])) + + +# --------------------------------------------------------------------------- +# Fake callables — all injected; never touch real LLM or disk paths +# --------------------------------------------------------------------------- + + +def _make_turn_runner( + responses: list[str] | None = None, +) -> Callable[..., str]: + """Return a turn_runner that returns canned responses in order. + + If the list runs out, subsequent calls return an empty string. + Signature: (artifact_path, message, *, runtime, session_id, ...) -> str + """ + queue: list[str] = list(responses or []) + + def _runner(**_kwargs: Any) -> str: + return queue.pop(0) if queue else "" + + return _runner + + +def _make_judge_fn( + *, + suggested_status: str = "executing", + needs_human: bool = False, + confidence: float = 0.9, + returns_none: bool = False, +) -> Callable[..., JudgeVerdict | None]: + """Return a judge callable that always returns the same verdict.""" + + def _judge(**_kwargs: Any) -> JudgeVerdict | None: + if returns_none: + return None + return JudgeVerdict( + summary="test judge verdict", + suggested_status=suggested_status, + needs_human=needs_human, + confidence=confidence, + ) + + return _judge + + +def _make_gate_fn(*, passes: bool = True) -> Callable[..., bool]: + """Return a gate callable that always passes or always fails. + + gate signature from loop_gate.evaluate_stop contract: + (session_id, *, sessions_dir, store, ...) -> StopDecision + For afk_runner tests, we inject a simplified bool-returning gate: + True → gate passes (continue or stop with qa) + False → gate fails (build continuation message) + """ + + def _gate(**_kwargs: Any) -> bool: + return passes + + return _gate + + +def _make_clock(times: list[float]) -> Callable[[], float]: + """Return a fake clock that pops values from the list. + + The runner uses clock() to check the time budget. Supply an + ascending sequence that will cause budget expiry at the right moment. + """ + queue = list(times) + + def _clock() -> float: + return queue.pop(0) if queue else float("inf") + + return _clock + + +def _make_config( + *, + afk_max_turns: int = 5, + afk_time_budget_minutes: int = 60, +) -> BachConfig: + """Build a BachConfig with AFK-relevant fields set.""" + return BachConfig( + afk_max_turns=afk_max_turns, + afk_time_budget_minutes=afk_time_budget_minutes, + ) + + +# --------------------------------------------------------------------------- +# Initial claim: afk_queue → executing on entry +# --------------------------------------------------------------------------- + + +def test_run_afk_task_claims_afk_queue_to_executing(tmp_path: Path) -> None: + """run_afk_task must transition afk_queue → executing before the first turn.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + # Gate passes immediately so the runner exits after one turn. + run_afk_task( + artifact, + config=_make_config(afk_max_turns=1), + turn_runner=_make_turn_runner(["done"]), + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=True), + clock=_make_clock([0.0, 1.0]), + ) + + # The artifact log must include an afk_queue→executing transition. + log = _read_log(artifact) + transitions = [e for e in log if e.get("event") == "status_changed"] + assert any(e.get("from") == "afk_queue" and e.get("to") == "executing" for e in transitions), ( + f"Expected afk_queue→executing in log; got transitions={transitions}" + ) + + +# --------------------------------------------------------------------------- +# Gate pass → qa + stop +# --------------------------------------------------------------------------- + + +def test_run_afk_task_gate_pass_sets_qa(tmp_path: Path) -> None: + """When gate passes after a turn, the runner must set status=qa and stop.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=10), + turn_runner=_make_turn_runner(["output"]), + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=True), + clock=_make_clock([0.0, 1.0, 2.0]), + ) + + assert _read_status(artifact) == "qa" + assert result.final_status == "qa" + assert result.reason == "verify_passed" + + +def test_run_afk_task_gate_pass_uses_at_least_one_turn(tmp_path: Path) -> None: + """At least one turn must run before a gate pass can close the loop.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + turn_count: list[int] = [] + + def _counting_runner(**_kwargs: Any) -> str: + turn_count.append(1) + return "output" + + run_afk_task( + artifact, + config=_make_config(afk_max_turns=10), + turn_runner=_counting_runner, + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=True), + clock=_make_clock([0.0] * 20), + ) + + assert len(turn_count) >= 1, "Expected at least one turn before gate pass" + + +# --------------------------------------------------------------------------- +# needs_human → hitl_queue + stop +# --------------------------------------------------------------------------- + + +def test_run_afk_task_needs_human_sets_hitl_queue(tmp_path: Path) -> None: + """Judge returning needs_human=True → hitl_queue + stop loop.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=10), + turn_runner=_make_turn_runner(["output"]), + # Gate fails so we do not exit via verify_passed — judge takes over. + judge=_make_judge_fn(needs_human=True, suggested_status="hitl_queue", confidence=0.9), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert _read_status(artifact) == "hitl_queue" + assert result.final_status == "hitl_queue" + assert result.reason == "needs_human" + + +def test_run_afk_task_needs_human_stops_loop_immediately(tmp_path: Path) -> None: + """Once needs_human is True, the runner must not run another turn.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + turn_count: list[int] = [] + + def _counting_runner(**_kwargs: Any) -> str: + turn_count.append(1) + return "output" + + run_afk_task( + artifact, + config=_make_config(afk_max_turns=10), + turn_runner=_counting_runner, + judge=_make_judge_fn(needs_human=True, suggested_status="hitl_queue", confidence=0.9), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + # With needs_human=True after turn 1, we must stop — not continue to turn 2. + assert len(turn_count) == 1, ( + f"Expected exactly 1 turn before needs_human stop, got {len(turn_count)}" + ) + + +# --------------------------------------------------------------------------- +# Hard bounds: max_turns +# --------------------------------------------------------------------------- + + +def test_run_afk_task_stops_at_max_turns(tmp_path: Path) -> None: + """Hard turn limit: runner must stop after afk_max_turns iterations.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + turn_count: list[int] = [] + + def _counting_runner(**_kwargs: Any) -> str: + turn_count.append(1) + return "output" + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=3), + turn_runner=_counting_runner, + # Gate never passes; judge returns no-op. + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert len(turn_count) == 3, f"Expected exactly 3 turns, got {len(turn_count)}" + assert result.turns_used == 3 + assert result.reason == "max_turns" + + +def test_run_afk_task_max_turns_returns_correct_afk_result(tmp_path: Path) -> None: + """AfkResult fields are consistent when max_turns limit fires.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=2), + turn_runner=_make_turn_runner(["t1", "t2"]), + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert result.turns_used == 2 + assert result.reason == "max_turns" + assert result.final_status in {"executing", "hitl_queue"} # not done, not afk_queue + + +def test_run_afk_task_max_turns_escalates_to_hitl(tmp_path: Path) -> None: + """Hitting max_turns with no gate pass and no needs_human → hitl_queue.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=2), + turn_runner=_make_turn_runner(["t1", "t2"]), + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + # When no clear success signal, the runner should escalate to hitl_queue + # so a human reviews the stalled work. + assert _read_status(artifact) == "hitl_queue" + assert result.final_status == "hitl_queue" + + +# --------------------------------------------------------------------------- +# Hard bounds: time budget +# --------------------------------------------------------------------------- + + +def test_run_afk_task_stops_at_time_budget(tmp_path: Path) -> None: + """Hard time budget: runner must stop when elapsed >= budget regardless of turns left.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + turn_count: list[int] = [] + + def _counting_runner(**_kwargs: Any) -> str: + turn_count.append(1) + return "output" + + # Budget is 1 minute (60 seconds). Clock returns: + # - 0.0 at start (recorded as t0) + # - 0.0 for turn 1 pre-check (budget OK) + # - 70.0 for turn 2 pre-check (70s > 60s → budget exceeded) + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=10, afk_time_budget_minutes=1), + turn_runner=_counting_runner, + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0, 0.0, 70.0]), + ) + + # Must have stopped after at most 1 turn (budget expired before turn 2). + assert len(turn_count) <= 1 + assert result.reason == "time_budget" + + +def test_run_afk_task_time_budget_returns_afk_result(tmp_path: Path) -> None: + """AfkResult has reason='time_budget' when time limit fires.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + # t0=0, turn1 check OK (elapsed=0), turn2 check fails (elapsed=3600s > 60s*60=3600s? no, 61min) + # Budget = 1 minute = 60 seconds. + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=10, afk_time_budget_minutes=1), + turn_runner=_make_turn_runner(["t1"]), + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0, 0.0, 120.0]), # 120s > 60s budget + ) + + assert result.reason == "time_budget" + + +def test_run_afk_task_time_budget_escalates_to_hitl(tmp_path: Path) -> None: + """Time budget exceeded with no gate pass → hitl_queue.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + run_afk_task( + artifact, + config=_make_config(afk_max_turns=10, afk_time_budget_minutes=1), + turn_runner=_make_turn_runner(["t1"]), + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0, 0.0, 120.0]), + ) + + # Budget exhausted → escalate so human can review. + assert _read_status(artifact) in {"hitl_queue", "executing"} + + +# --------------------------------------------------------------------------- +# NEVER done invariant +# --------------------------------------------------------------------------- + + +def test_run_afk_task_never_sets_done(tmp_path: Path) -> None: + """The AFK runner MUST NEVER set status=done under any code path.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + # Even if the judge suggests 'done', the runner must not honor it. + run_afk_task( + artifact, + config=_make_config(afk_max_turns=3), + turn_runner=_make_turn_runner(["t1", "t2", "t3"]), + judge=_make_judge_fn(suggested_status="done", needs_human=False, confidence=0.99), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert _read_status(artifact) != "done", ( + "AfkRunner must never set status=done — human review required before completion" + ) + + +def test_run_afk_task_result_final_status_never_done(tmp_path: Path) -> None: + """AfkResult.final_status must never be 'done', regardless of judge verdict.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=2), + turn_runner=_make_turn_runner(["t1", "t2"]), + # Even a 'done' verdict must be ignored by the runner. + judge=_make_judge_fn(suggested_status="done", needs_human=False, confidence=0.99), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert result.final_status != "done", "AfkResult.final_status must never be 'done'" + + +def test_run_afk_task_never_done_even_on_gate_pass(tmp_path: Path) -> None: + """Gate pass must set qa, not done — done is a human-only transition.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=5), + turn_runner=_make_turn_runner(["output"]), + judge=_make_judge_fn(suggested_status="done", confidence=0.99), + gate=_make_gate_fn(passes=True), + clock=_make_clock([0.0] * 20), + ) + + assert result.final_status != "done" + assert _read_status(artifact) != "done" + + +# --------------------------------------------------------------------------- +# AfkResult dataclass contract +# --------------------------------------------------------------------------- + + +def test_run_afk_task_returns_afk_result(tmp_path: Path) -> None: + """run_afk_task must return an AfkResult instance.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=1), + turn_runner=_make_turn_runner(["output"]), + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=True), + clock=_make_clock([0.0] * 20), + ) + + assert isinstance(result, AfkResult) + + +def test_run_afk_task_afk_result_fields_populated(tmp_path: Path) -> None: + """AfkResult must have all three required fields populated.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=2), + turn_runner=_make_turn_runner(["t1"]), + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=True), + clock=_make_clock([0.0] * 20), + ) + + assert isinstance(result.turns_used, int) + assert isinstance(result.final_status, str) + assert isinstance(result.reason, str) + assert result.reason in {"verify_passed", "needs_human", "max_turns", "time_budget", "error"} + + +def test_run_afk_task_turns_used_matches_actual_turns(tmp_path: Path) -> None: + """AfkResult.turns_used must equal the number of turn_runner calls.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + turn_count: list[int] = [] + + def _counting_runner(**_kwargs: Any) -> str: + turn_count.append(1) + return "output" + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=4), + turn_runner=_counting_runner, + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert result.turns_used == len(turn_count) + + +# --------------------------------------------------------------------------- +# Error robustness: turn_runner raises +# --------------------------------------------------------------------------- + + +def test_run_afk_task_turn_runner_exception_returns_error(tmp_path: Path) -> None: + """If turn_runner raises, AfkResult.reason must be 'error' (non-fatal).""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + def _exploding_runner(**_kwargs: Any) -> str: + raise RuntimeError("subprocess failed") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=5), + turn_runner=_exploding_runner, + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert result.reason == "error" + # After error, runner must not leave status as afk_queue (already claimed). + assert _read_status(artifact) != "afk_queue" + + +def test_run_afk_task_turn_runner_exception_escalates(tmp_path: Path) -> None: + """A turn_runner failure must escalate to hitl_queue so a human reviews.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + + def _exploding_runner(**_kwargs: Any) -> str: + raise OSError("disk full") + + run_afk_task( + artifact, + config=_make_config(afk_max_turns=5), + turn_runner=_exploding_runner, + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert _read_status(artifact) == "hitl_queue" + + +# --------------------------------------------------------------------------- +# Gate fail + judge no-op: continuation loop +# --------------------------------------------------------------------------- + + +def test_run_afk_task_continues_when_gate_fails_and_no_escalation( + tmp_path: Path, +) -> None: + """When gate fails and judge returns no escalation, the loop continues.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + turn_count: list[int] = [] + + def _counting_runner(**_kwargs: Any) -> str: + turn_count.append(1) + return "output" + + # max_turns=3, gate always fails, judge returns no-op → all 3 turns run. + run_afk_task( + artifact, + config=_make_config(afk_max_turns=3), + turn_runner=_counting_runner, + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_fn(passes=False), + clock=_make_clock([0.0] * 20), + ) + + assert len(turn_count) == 3 + + +# --------------------------------------------------------------------------- +# Resume command printed on exit (functional check via stdout capture) +# --------------------------------------------------------------------------- + + +def test_run_afk_task_prints_resume_command_on_exit(tmp_path: Path, capsys: Any) -> None: + """The runner must print the resume command + artifact path on exit. + + This is the 'always print a fallback command' pattern from the spec. + We verify something is printed to stdout (not exhaustively parsing the + exact format, which may evolve). + """ + artifact = _write_artifact(tmp_path, status="afk_queue") + + run_afk_task( + artifact, + config=_make_config(afk_max_turns=1), + turn_runner=_make_turn_runner(["output"]), + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=True), + clock=_make_clock([0.0] * 20), + ) + + captured = capsys.readouterr() + # Must print at least the artifact path so the user can find the task. + assert str(artifact) in captured.out or str(artifact) in captured.err + + +# --------------------------------------------------------------------------- +# Non-afk_queue initial status: still executable +# --------------------------------------------------------------------------- + + +def test_run_afk_task_executing_status_proceeds(tmp_path: Path) -> None: + """If artifact is already 'executing' (resume scenario), the runner proceeds.""" + artifact = _write_artifact(tmp_path, status="executing") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=1), + turn_runner=_make_turn_runner(["output"]), + judge=_make_judge_fn(), + gate=_make_gate_fn(passes=True), + clock=_make_clock([0.0] * 20), + ) + + # Should still exit cleanly. + assert result.reason in {"verify_passed", "needs_human", "max_turns", "time_budget", "error"} + + +# --------------------------------------------------------------------------- +# Cap exhaustion: gate sets hitl_queue → runner must NOT overwrite with qa +# --------------------------------------------------------------------------- + + +def _make_gate_that_escalates(artifact: Path) -> Callable[..., bool]: + """Simulate loop_gate cap exhaustion: sets artifact to hitl_queue, returns False. + + This mimics what _gate_fn in afk_cmd.py does when evaluate_stop has already + written hitl_queue (cap exhausted) and re-reading the artifact reveals + the escalated status. The gate returns False so the runner stops without + overwriting with qa. + """ + import yaml + + def _gate(**_kwargs: Any) -> bool: + # Write hitl_queue directly to the artifact (simulating cap exhaustion). + raw = artifact.read_text() + parts = raw.split("---", 2) + if len(parts) == 3: + payload = yaml.safe_load(parts[1]) or {} + payload["status"] = "hitl_queue" + artifact.write_text(f"---\n{yaml.safe_dump(payload, sort_keys=False)}---\n{parts[2]}") + # Return False — the gate detected hitl_queue and signals NOT passed. + return False + + return _gate + + +def test_cap_exhaustion_gate_false_stops_runner_as_hitl(tmp_path: Path) -> None: + """Cap exhaustion: gate returns False + artifact is hitl_queue → runner stops, + final status is hitl_queue, never qa. + + This pins the invariant described in the STOP review finding: when + evaluate_stop exhausts the continuation cap it sets hitl_queue on the + artifact and returns block=False. _gate_fn must detect the hitl_queue + status and return False so the runner stops with needs_human, not qa. + """ + artifact = _write_artifact(tmp_path, status="afk_queue") + + result = run_afk_task( + artifact, + config=_make_config(afk_max_turns=10), + turn_runner=_make_turn_runner(["output"]), + # Gate simulates cap exhaustion: writes hitl_queue then returns False. + judge=_make_judge_fn(returns_none=True), + gate=_make_gate_that_escalates(artifact), + clock=_make_clock([0.0] * 20), + ) + + # The runner must not have overwritten hitl_queue with qa. + assert _read_status(artifact) == "hitl_queue", ( + "Cap exhaustion must leave artifact in hitl_queue, not overwrite with qa" + ) + assert result.final_status == "hitl_queue", ( + "AfkResult.final_status must be hitl_queue when cap is exhausted" + ) + assert result.final_status != "qa", "Runner must never set qa when cap was exhausted" + + +def test_cap_exhaustion_gate_false_runner_stops_not_continues(tmp_path: Path) -> None: + """When gate returns False due to cap exhaustion, the runner must stop, not loop.""" + artifact = _write_artifact(tmp_path, status="afk_queue") + turn_count: list[int] = [] + + def _counting_runner(**_kwargs: Any) -> str: + turn_count.append(1) + return "output" + + gate = _make_gate_that_escalates(artifact) + run_afk_task( + artifact, + config=_make_config(afk_max_turns=10), + turn_runner=_counting_runner, + judge=_make_judge_fn(returns_none=True), + gate=gate, + clock=_make_clock([0.0] * 20), + ) + + # Gate fires after turn 1 and writes hitl_queue — runner must not loop. + # Without the fix the runner would continue to turn 2, 3, ... up to max_turns. + assert len(turn_count) == 1, ( + f"Runner must stop after cap-exhaustion gate, but ran {len(turn_count)} turns" + ) diff --git a/tests/unit/test_docs_d2.py b/tests/unit/test_docs_d2.py new file mode 100644 index 0000000..4ce7d82 --- /dev/null +++ b/tests/unit/test_docs_d2.py @@ -0,0 +1,234 @@ +"""Tests for Phase 16 D2 documentation changes. + +Strategy: + - Each test asserts that a specific required section, phrase, or command + is present in its target documentation file. + - Tests are pure filesystem reads — no imports from bach source required. + - Failing before the docs are updated proves TDD contract. + +Why test docs? + The DAG spec mandates TDD for all agents. For documentation owners the + equivalent is a content contract: downstream agents, users, and tools + that parse these files rely on the specific sections and command names + being present and accurate. +""" + +from pathlib import Path + +# Root of the repo — resolve relative to this file so tests work from any cwd. +_REPO_ROOT = Path(__file__).parent.parent.parent + + +# --------------------------------------------------------------------------- +# README.md — Session radar & observer section +# --------------------------------------------------------------------------- + + +def test_readme_has_session_radar_section() -> None: + """README must contain a 'Session radar & observer' section header.""" + content = (_REPO_ROOT / "README.md").read_text() + assert "Session radar" in content, ( + "README.md is missing the 'Session radar' section required by Phase 16 D2" + ) + + +def test_readme_session_table_has_columns() -> None: + """README session radar section must include a table with required columns. + + The table documents the observer in action — users need to see what + columns to expect from `bach sessions list`. + """ + content = (_REPO_ROOT / "README.md").read_text() + # Table header columns per spec example + assert "Session ID" in content, "README session table must show 'Session ID' column" + assert "Runtime" in content, "README session table must show 'Runtime' column" + assert "State" in content, "README session table must show 'State' column" + assert "Task" in content, "README session table must show 'Task' column" + + +def test_readme_session_radar_mentions_observer() -> None: + """README session section must describe the observer concept.""" + content = (_REPO_ROOT / "README.md").read_text() + assert "observer" in content.lower(), ( + "README must describe the observer authority concept in the session section" + ) + + +def test_readme_session_radar_mentions_bach_sessions() -> None: + """README must reference the `bach sessions` CLI command.""" + content = (_REPO_ROOT / "README.md").read_text() + assert "bach sessions" in content, ( + "README must reference `bach sessions` command for discoverability" + ) + + +# --------------------------------------------------------------------------- +# CLAUDE.md — new Phase 16 commands +# --------------------------------------------------------------------------- + + +def test_claude_md_has_sessions_commands() -> None: + """CLAUDE.md Commands section must list `bach sessions list`.""" + content = (_REPO_ROOT / "CLAUDE.md").read_text() + assert "bach sessions" in content, ( + "CLAUDE.md must document `bach sessions` commands added in Phase 16" + ) + + +def test_claude_md_has_hooks_commands() -> None: + """CLAUDE.md Commands section must list `bach hooks install`.""" + content = (_REPO_ROOT / "CLAUDE.md").read_text() + assert "bach hooks" in content, ( + "CLAUDE.md must document `bach hooks` commands added in Phase 16" + ) + + +def test_claude_md_has_afk_run_command() -> None: + """CLAUDE.md Commands section must list `bach afk run`.""" + content = (_REPO_ROOT / "CLAUDE.md").read_text() + assert "bach afk run" in content, ( + "CLAUDE.md must document `bach afk run` command added in Phase 16" + ) + + +# --------------------------------------------------------------------------- +# AGENTS.md — new Phase 16 commands +# --------------------------------------------------------------------------- + + +def test_agents_md_has_sessions_command() -> None: + """AGENTS.md must mention `bach sessions` for agent discoverability.""" + content = (_REPO_ROOT / "AGENTS.md").read_text() + assert "bach sessions" in content, ( + "AGENTS.md must document `bach sessions` for agents running Bach commands" + ) + + +def test_agents_md_has_hooks_command() -> None: + """AGENTS.md must mention `bach hooks` for agents setting up integrations.""" + content = (_REPO_ROOT / "AGENTS.md").read_text() + assert "bach hooks" in content, ( + "AGENTS.md must document `bach hooks` for agents installing hook integrations" + ) + + +def test_agents_md_has_afk_run_command() -> None: + """AGENTS.md must mention `bach afk run` for automated task execution.""" + content = (_REPO_ROOT / "AGENTS.md").read_text() + assert "bach afk run" in content, ( + "AGENTS.md must document `bach afk run` for headless task execution by agents" + ) + + +# --------------------------------------------------------------------------- +# docs/how-to/wire-up-session-hook.md — Stop + PostToolUse hooks + Codex installer +# --------------------------------------------------------------------------- + + +def test_wire_up_hook_doc_has_stop_hook() -> None: + """Hook how-to must document the Stop hook event.""" + content = (_REPO_ROOT / "docs" / "how-to" / "wire-up-session-hook.md").read_text() + assert "Stop" in content, ( + "wire-up-session-hook.md must document the Stop hook event for loop gate" + ) + + +def test_wire_up_hook_doc_has_posttooluse_hook() -> None: + """Hook how-to must document the PostToolUse hook event.""" + content = (_REPO_ROOT / "docs" / "how-to" / "wire-up-session-hook.md").read_text() + assert "PostToolUse" in content, "wire-up-session-hook.md must document PostToolUse hook event" + + +def test_wire_up_hook_doc_has_bach_hooks_install() -> None: + """Hook how-to must reference `bach hooks install` as the recommended setup path.""" + content = (_REPO_ROOT / "docs" / "how-to" / "wire-up-session-hook.md").read_text() + assert "bach hooks install" in content, ( + "wire-up-session-hook.md must reference `bach hooks install` command" + ) + + +def test_wire_up_hook_doc_has_codex_section() -> None: + """Hook how-to must include a Codex hook installer section.""" + content = (_REPO_ROOT / "docs" / "how-to" / "wire-up-session-hook.md").read_text() + # The old doc said "Codex doesn't have an equivalent"; new content should + # document the codex installer path. + assert "codex" in content.lower(), "wire-up-session-hook.md must document Codex hook setup" + # Must reference the installer command + assert "bach hooks install codex" in content, ( + "wire-up-session-hook.md must show `bach hooks install codex` command" + ) + + +def test_wire_up_hook_doc_has_hook_event_command() -> None: + """Hook how-to must reference `bach internal hook-event` for advanced users.""" + content = (_REPO_ROOT / "docs" / "how-to" / "wire-up-session-hook.md").read_text() + assert "hook-event" in content, ( + "wire-up-session-hook.md must document `bach internal hook-event` for the spool path" + ) + + +def test_wire_up_hook_doc_has_session_stop_command() -> None: + """Hook how-to must mention `bach internal session-stop` for the Stop hook.""" + content = (_REPO_ROOT / "docs" / "how-to" / "wire-up-session-hook.md").read_text() + assert "session-stop" in content, ( + "wire-up-session-hook.md must document `bach internal session-stop` for loop gate" + ) + + +# --------------------------------------------------------------------------- +# docs/internal/progress.txt — Phase 16 entry +# --------------------------------------------------------------------------- + + +def test_progress_txt_has_phase_16() -> None: + """progress.txt must contain a Phase 16 entry.""" + content = (_REPO_ROOT / "docs" / "internal" / "progress.txt").read_text() + assert "Phase 16" in content, ( + "progress.txt must record Phase 16 completion per the house format" + ) + + +def test_progress_txt_phase_16_mentions_session_observability() -> None: + """progress.txt Phase 16 entry must mention session observability.""" + content = (_REPO_ROOT / "docs" / "internal" / "progress.txt").read_text() + assert "session" in content.lower() and "observab" in content.lower(), ( + "progress.txt Phase 16 entry must describe the session observability feature" + ) + + +def test_progress_txt_phase_16_mentions_key_modules() -> None: + """progress.txt Phase 16 entry must reference the key new modules.""" + content = (_REPO_ROOT / "docs" / "internal" / "progress.txt").read_text() + # At least one of the major new modules should be mentioned + has_key_module = any( + name in content + for name in [ + "session_watcher", + "session_discovery", + "judge_service", + "loop_gate", + "afk_runner", + "hooks_service", + ] + ) + assert has_key_module, ( + "progress.txt Phase 16 entry must reference at least one new Phase 16 module" + ) + + +def test_progress_txt_phase_16_mentions_test_count() -> None: + """progress.txt Phase 16 entry must record a test count (house format).""" + content = (_REPO_ROOT / "docs" / "internal" / "progress.txt").read_text() + # Find the Phase 16 block and check it has a test count mention + idx = content.find("Phase 16") + assert idx != -1 + # Look in the 1500 chars after the Phase 16 marker for a test count pattern + phase16_block = content[idx : idx + 1500] + import re + + # Pattern: any mention of N tests (e.g. "812 tests", "N passed") + has_count = bool(re.search(r"\d+ tests?|\d+ passed", phase16_block, re.IGNORECASE)) + assert has_count, ( + "progress.txt Phase 16 entry must record a test count per house format " + "(e.g. '812 tests' or 'N passed')" + ) diff --git a/tests/unit/test_hooks_service.py b/tests/unit/test_hooks_service.py new file mode 100644 index 0000000..866efa6 --- /dev/null +++ b/tests/unit/test_hooks_service.py @@ -0,0 +1,348 @@ +"""Tests for services/hooks_service.py — idempotent hook installation. + +Strategy: + - All functions accept injected paths (project_dir, codex_home, etc.) — no + real ~/.claude or ~/.codex touched. + - Idempotency: running install twice must not duplicate entries. + - No-clobber: pre-existing unrelated hooks must survive intact. + - Tests assert ONE behaviour per test. +""" + +import json +from pathlib import Path +from typing import Any + +from bach.services.hooks_service import ( + HookInstallResult, + get_hooks_status, + install_claude_hooks, + install_codex_hooks, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_claude_settings(project_dir: Path, data: dict[str, Any]) -> None: + """Write a .claude/settings.json under project_dir.""" + settings_path = project_dir / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + settings_path.write_text(json.dumps(data)) + + +def _read_claude_settings(project_dir: Path) -> dict[str, Any]: + settings_path = project_dir / ".claude" / "settings.json" + data: dict[str, Any] = json.loads(settings_path.read_text()) + return data + + +def _write_codex_hooks(codex_home: Path, data: dict[str, Any]) -> None: + codex_home.mkdir(parents=True, exist_ok=True) + (codex_home / "hooks.json").write_text(json.dumps(data)) + + +def _read_codex_hooks(codex_home: Path) -> dict[str, Any]: + data: dict[str, Any] = json.loads((codex_home / "hooks.json").read_text()) + return data + + +# --------------------------------------------------------------------------- +# install_claude_hooks — basic install +# --------------------------------------------------------------------------- + + +def test_install_claude_hooks_creates_settings_file(tmp_path: Path) -> None: + """With no pre-existing settings.json, the file is created.""" + result = install_claude_hooks(project_dir=tmp_path) + assert (tmp_path / ".claude" / "settings.json").exists() + assert isinstance(result, HookInstallResult) + + +def test_install_claude_hooks_adds_session_end_entry(tmp_path: Path) -> None: + """SessionEnd hook must be present after install.""" + install_claude_hooks(project_dir=tmp_path) + settings = _read_claude_settings(tmp_path) + hooks = settings.get("hooks", {}) + session_end = hooks.get("SessionEnd", []) + # At least one entry in SessionEnd calls bach internal hook-event. + commands = [ + entry.get("command", "") if isinstance(entry, dict) else "" for entry in session_end + ] + assert any("bach internal hook-event" in cmd for cmd in commands) + + +def test_install_claude_hooks_adds_stop_entry(tmp_path: Path) -> None: + """Stop hook must be present after install.""" + install_claude_hooks(project_dir=tmp_path) + settings = _read_claude_settings(tmp_path) + hooks = settings.get("hooks", {}) + stop = hooks.get("Stop", []) + commands = [entry.get("command", "") if isinstance(entry, dict) else "" for entry in stop] + assert any("bach internal hook-event" in cmd for cmd in commands) + + +def test_install_claude_hooks_is_idempotent(tmp_path: Path) -> None: + """Running install twice must not duplicate hook entries.""" + install_claude_hooks(project_dir=tmp_path) + install_claude_hooks(project_dir=tmp_path) + settings = _read_claude_settings(tmp_path) + hooks = settings.get("hooks", {}) + # No list should have duplicate bach entries. + for event_name, entries in hooks.items(): + bach_cmds = [ + e.get("command", "") + for e in entries + if isinstance(e, dict) and "bach" in e.get("command", "") + ] + assert len(bach_cmds) == len(set(bach_cmds)), ( + f"Duplicate bach hook command found under {event_name}" + ) + + +def test_install_claude_hooks_preserves_unrelated_hooks(tmp_path: Path) -> None: + """Pre-existing user hooks must survive — never clobber unrelated hooks.""" + _write_claude_settings( + tmp_path, {"hooks": {"SessionEnd": [{"command": "echo user_hook", "run": "always"}]}} + ) + install_claude_hooks(project_dir=tmp_path) + settings = _read_claude_settings(tmp_path) + session_end = settings["hooks"]["SessionEnd"] + commands = [e.get("command", "") for e in session_end if isinstance(e, dict)] + assert "echo user_hook" in commands, "User hook was removed" + + +def test_install_claude_hooks_works_with_empty_settings(tmp_path: Path) -> None: + """An empty settings.json ({}) is a valid base — don't fail on it.""" + _write_claude_settings(tmp_path, {}) + result = install_claude_hooks(project_dir=tmp_path) + assert isinstance(result, HookInstallResult) + settings = _read_claude_settings(tmp_path) + assert "hooks" in settings + + +# --------------------------------------------------------------------------- +# install_codex_hooks — basic install +# --------------------------------------------------------------------------- + + +def test_install_codex_hooks_creates_hooks_file(tmp_path: Path) -> None: + """With no pre-existing hooks.json, the file is created.""" + codex_home = tmp_path / ".codex" + result = install_codex_hooks(codex_home=codex_home) + assert (codex_home / "hooks.json").exists() + assert isinstance(result, HookInstallResult) + + +def test_install_codex_hooks_adds_session_stop_entry(tmp_path: Path) -> None: + """A sessionStop entry calling bach internal hook-event is added.""" + codex_home = tmp_path / ".codex" + install_codex_hooks(codex_home=codex_home) + data = _read_codex_hooks(codex_home) + # Codex hooks.json: top-level keys are event names, values are lists of + # command strings or dicts with command fields. + entries = data.get("sessionStop", data.get("stop", [])) + cmds = [e.get("command", e) if isinstance(e, dict) else e for e in entries] + assert any("bach internal hook-event" in str(c) for c in cmds) + + +def test_install_codex_hooks_is_idempotent(tmp_path: Path) -> None: + """Running install twice must not duplicate entries in hooks.json.""" + codex_home = tmp_path / ".codex" + install_codex_hooks(codex_home=codex_home) + install_codex_hooks(codex_home=codex_home) + data = _read_codex_hooks(codex_home) + for event_name, entries in data.items(): + bach_entries = [ + e for e in entries if "bach" in (e.get("command", e) if isinstance(e, dict) else e) + ] + bach_cmds = [e.get("command", e) if isinstance(e, dict) else e for e in bach_entries] + assert len(bach_cmds) == len(set(str(c) for c in bach_cmds)), ( + f"Duplicate bach hook entry found under {event_name}" + ) + + +def test_install_codex_hooks_preserves_unrelated_hooks(tmp_path: Path) -> None: + """Pre-existing user hooks survive codex install — never clobber.""" + codex_home = tmp_path / ".codex" + _write_codex_hooks(codex_home, {"sessionStop": [{"command": "echo user_hook"}]}) + install_codex_hooks(codex_home=codex_home) + data = _read_codex_hooks(codex_home) + entries = data.get("sessionStop", data.get("stop", [])) + cmds = [e.get("command", e) if isinstance(e, dict) else e for e in entries] + assert any("user_hook" in str(c) for c in cmds), "User hook was removed" + + +# --------------------------------------------------------------------------- +# get_hooks_status +# --------------------------------------------------------------------------- + + +def test_get_hooks_status_returns_dict(tmp_path: Path) -> None: + """get_hooks_status must return a dict (may contain 'claude' and 'codex').""" + codex_home = tmp_path / ".codex" + status = get_hooks_status(project_dir=tmp_path, codex_home=codex_home) + assert isinstance(status, dict) + + +def test_get_hooks_status_shows_not_installed_before_install(tmp_path: Path) -> None: + """Before install, neither runtime reports installed.""" + codex_home = tmp_path / ".codex" + status = get_hooks_status(project_dir=tmp_path, codex_home=codex_home) + # At minimum, the keys are present with falsy values. + assert not status.get("claude_installed", True) + assert not status.get("codex_installed", True) + + +def test_get_hooks_status_shows_installed_after_install(tmp_path: Path) -> None: + """After install, the relevant key becomes truthy.""" + install_claude_hooks(project_dir=tmp_path) + codex_home = tmp_path / ".codex" + status = get_hooks_status(project_dir=tmp_path, codex_home=codex_home) + assert status.get("claude_installed") + + +def test_get_hooks_status_codex_installed_after_install(tmp_path: Path) -> None: + """After codex install, codex_installed is truthy.""" + codex_home = tmp_path / ".codex" + install_codex_hooks(codex_home=codex_home) + status = get_hooks_status(project_dir=tmp_path, codex_home=codex_home) + assert status.get("codex_installed") + + +# --------------------------------------------------------------------------- +# install_claude_hooks — abort on malformed settings.json (WATCH finding) +# --------------------------------------------------------------------------- + + +def test_install_claude_hooks_raises_on_malformed_json(tmp_path: Path) -> None: + """Malformed settings.json must raise RuntimeError — never silently overwrite. + + The old behaviour (log + proceed with empty dict) would silently destroy + the user's entire settings file. The new behaviour is to abort with a + clear message so the user can fix their file first. + """ + import pytest + + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + settings_path.write_text("this is not valid json {{{") + + with pytest.raises(RuntimeError, match="settings.json"): + install_claude_hooks(project_dir=tmp_path) + + # The malformed file must NOT have been overwritten. + assert "this is not valid json" in settings_path.read_text() + + +def test_install_claude_hooks_does_not_write_on_parse_failure(tmp_path: Path) -> None: + """On parse failure, install_claude_hooks must not write anything to disk.""" + import pytest + + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + original_content = "not json at all" + settings_path.write_text(original_content) + + with pytest.raises(RuntimeError): + install_claude_hooks(project_dir=tmp_path) + + # Content must be exactly unchanged. + assert settings_path.read_text() == original_content + + +# --------------------------------------------------------------------------- +# install_codex_hooks — abort on malformed hooks.json (WATCH finding) +# --------------------------------------------------------------------------- + + +def test_install_codex_hooks_raises_on_malformed_json(tmp_path: Path) -> None: + """Malformed hooks.json must raise RuntimeError — never silently overwrite.""" + import pytest + + codex_home = tmp_path / ".codex" + codex_home.mkdir(parents=True) + hooks_path = codex_home / "hooks.json" + hooks_path.write_text("{ broken json !") + + with pytest.raises(RuntimeError, match="hooks.json"): + install_codex_hooks(codex_home=codex_home) + + # The malformed file must NOT have been overwritten. + assert "broken json" in hooks_path.read_text() + + +def test_install_codex_hooks_does_not_write_on_parse_failure(tmp_path: Path) -> None: + """On parse failure, install_codex_hooks must not write anything to disk.""" + import pytest + + codex_home = tmp_path / ".codex" + codex_home.mkdir(parents=True) + hooks_path = codex_home / "hooks.json" + original_content = "not json" + hooks_path.write_text(original_content) + + with pytest.raises(RuntimeError): + install_codex_hooks(codex_home=codex_home) + + assert hooks_path.read_text() == original_content + + +# --------------------------------------------------------------------------- +# get_hooks_status — logs on read failure (WATCH finding) +# --------------------------------------------------------------------------- + + +def test_get_hooks_status_returns_not_installed_on_malformed_claude( + tmp_path: Path, + caplog: Any, +) -> None: + """Malformed settings.json → claude_installed=False, logged at WARNING.""" + import logging + + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + settings_path.write_text("not json") + + codex_home = tmp_path / ".codex" + # configure_logging() (run by CLI tests earlier in the suite) sets + # propagate=False on the "bach" logger, which blinds caplog's root + # handler — force propagation for the duration of this assertion. + bach_logger = logging.getLogger("bach") + prev = bach_logger.propagate + bach_logger.propagate = True + try: + with caplog.at_level(logging.WARNING, logger="bach.services.hooks_service"): + status = get_hooks_status(project_dir=tmp_path, codex_home=codex_home) + finally: + bach_logger.propagate = prev + + assert not status.get("claude_installed") + # A WARNING must have been logged. + assert any("hooks_status_read_failed" in r.message for r in caplog.records) + + +def test_get_hooks_status_returns_not_installed_on_malformed_codex( + tmp_path: Path, + caplog: Any, +) -> None: + """Malformed hooks.json → codex_installed=False, logged at WARNING.""" + import logging + + codex_home = tmp_path / ".codex" + codex_home.mkdir(parents=True) + (codex_home / "hooks.json").write_text("{broken}") + + # See the claude variant above: undo configure_logging's propagate=False + # so caplog's root handler can observe the WARNING. + bach_logger = logging.getLogger("bach") + prev = bach_logger.propagate + bach_logger.propagate = True + try: + with caplog.at_level(logging.WARNING, logger="bach.services.hooks_service"): + status = get_hooks_status(project_dir=tmp_path, codex_home=codex_home) + finally: + bach_logger.propagate = prev + + assert not status.get("codex_installed") + assert any("hooks_status_read_failed" in r.message for r in caplog.records) diff --git a/tests/unit/test_judge_service.py b/tests/unit/test_judge_service.py new file mode 100644 index 0000000..55de1a1 --- /dev/null +++ b/tests/unit/test_judge_service.py @@ -0,0 +1,637 @@ +"""Tests for services/judge_service.py — judge_session. + +Strategy: inject a fake llm_runner instead of calling real codex. +The four required cases per the spec: + 1. Happy path — runner returns valid dict, verdict is returned. + 2. Invalid status — suggested_status not in VALID_STATUSES → None. + 3. Confidence out of range — value outside [0.0, 1.0] → None. + 4. Runner raises (any exception) — returns None, does not propagate. + +All tests are pure in-memory; no filesystem, no subprocesses, no +network. The fake llm_runner signature mirrors run_codex_json: + (prompt, schema, model, timeout?) -> dict[str, Any] +""" + +from collections.abc import Callable +from typing import Any + +from bach.domain.models import ( + JudgeVerdict, + SessionEvent, + SessionEventKind, +) +from bach.services.judge_service import judge_session + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_runner(payload: dict[str, Any]) -> Callable[..., dict[str, Any]]: + """Return a fake llm_runner that always returns the given payload.""" + + def _runner(prompt: str, schema: dict[str, Any], model: str, **_kwargs: Any) -> dict[str, Any]: + return payload + + return _runner + + +def _raising_runner(exc: Exception) -> Callable[..., dict[str, Any]]: + """Return a fake llm_runner that raises the given exception.""" + + def _runner(prompt: str, schema: dict[str, Any], model: str, **_kwargs: Any) -> dict[str, Any]: + raise exc + + return _runner + + +def _make_events(n: int = 3) -> list[SessionEvent]: + """Build a minimal list of SessionEvents for testing.""" + return [ + SessionEvent( + kind=SessionEventKind.assistant_turn, + timestamp=None, + text=f"assistant message {i}", + raw_kind="assistant", + ) + for i in range(n) + ] + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_judge_session_happy_path_returns_verdict() -> None: + """Valid LLM response with a known status → JudgeVerdict is returned.""" + runner = _make_runner( + { + "summary": "Task is progressing well", + "suggested_status": "executing", + "needs_human": False, + "confidence": 0.9, + } + ) + + result = judge_session( + _make_events(), + task_title="Implement feature X", + current_status="executing", + model="gpt-5.4-mini", + llm_runner=runner, + ) + + assert isinstance(result, JudgeVerdict) + assert result.summary == "Task is progressing well" + assert result.suggested_status == "executing" + assert result.needs_human is False + assert result.confidence == 0.9 + + +def test_judge_session_happy_path_needs_human_true() -> None: + """needs_human=True is a valid verdict field — must be preserved.""" + runner = _make_runner( + { + "summary": "Blocked on external API", + "suggested_status": "hitl_queue", + "needs_human": True, + "confidence": 0.8, + } + ) + + result = judge_session( + _make_events(), + task_title="API integration", + current_status="executing", + model="gpt-5.4-mini", + llm_runner=runner, + ) + + assert result is not None + assert result.needs_human is True + assert result.suggested_status == "hitl_queue" + + +def test_judge_session_passes_events_and_task_context_to_runner() -> None: + """The runner must receive the task title and current status in the prompt. + + We verify they appear in the prompt string (not the schema) so the + judge has the context it needs to produce a meaningful verdict. + """ + captured: list[dict[str, Any]] = [] + + def _capture_runner( + prompt: str, schema: dict[str, Any], model: str, **_kwargs: Any + ) -> dict[str, Any]: + captured.append({"prompt": prompt, "schema": schema, "model": model}) + return { + "summary": "ok", + "suggested_status": "qa", + "needs_human": False, + "confidence": 0.75, + } + + judge_session( + _make_events(2), + task_title="My important task", + current_status="executing", + model="gpt-5.4-mini", + llm_runner=_capture_runner, + ) + + assert len(captured) == 1 + prompt = captured[0]["prompt"] + # The judge needs task title and current status to make a sensible decision. + assert "My important task" in prompt + assert "executing" in prompt + + +def test_judge_session_passes_model_to_runner() -> None: + """The model slug must be forwarded to the llm_runner unchanged.""" + captured_model: list[str] = [] + + def _capture_runner( + prompt: str, schema: dict[str, Any], model: str, **_kwargs: Any + ) -> dict[str, Any]: + captured_model.append(model) + return { + "summary": "ok", + "suggested_status": "qa", + "needs_human": False, + "confidence": 0.8, + } + + judge_session( + _make_events(), + task_title="t", + current_status="qa", + model="gpt-custom-model", + llm_runner=_capture_runner, + ) + + assert captured_model == ["gpt-custom-model"] + + +def test_judge_session_passes_schema_with_required_fields() -> None: + """The schema passed to the runner must include all four verdict fields.""" + captured_schema: list[dict[str, Any]] = [] + + def _capture_runner( + prompt: str, schema: dict[str, Any], model: str, **_kwargs: Any + ) -> dict[str, Any]: + captured_schema.append(schema) + return { + "summary": "ok", + "suggested_status": "qa", + "needs_human": False, + "confidence": 0.8, + } + + judge_session( + _make_events(), + task_title="t", + current_status="qa", + model="m", + llm_runner=_capture_runner, + ) + + assert len(captured_schema) == 1 + schema = captured_schema[0] + props = schema.get("properties", {}) + assert "summary" in props + assert "suggested_status" in props + assert "needs_human" in props + assert "confidence" in props + + +def test_judge_session_confidence_at_boundary_zero() -> None: + """confidence=0.0 is valid (lower boundary) → verdict returned.""" + runner = _make_runner( + { + "summary": "Uncertain", + "suggested_status": "executing", + "needs_human": False, + "confidence": 0.0, + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is not None + assert result.confidence == 0.0 + + +def test_judge_session_confidence_at_boundary_one() -> None: + """confidence=1.0 is valid (upper boundary) → verdict returned.""" + runner = _make_runner( + { + "summary": "Very certain", + "suggested_status": "qa", + "needs_human": False, + "confidence": 1.0, + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is not None + assert result.confidence == 1.0 + + +# --------------------------------------------------------------------------- +# Invalid status +# --------------------------------------------------------------------------- + + +def test_judge_session_invalid_status_returns_none() -> None: + """suggested_status not in VALID_STATUSES → None (caller treats as no-op).""" + runner = _make_runner( + { + "summary": "Task seems done", + "suggested_status": "finished", # not a valid status + "needs_human": False, + "confidence": 0.9, + } + ) + + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + + assert result is None + + +def test_judge_session_empty_status_returns_none() -> None: + """Empty string is also not a valid status → None.""" + runner = _make_runner( + { + "summary": "Something", + "suggested_status": "", + "needs_human": False, + "confidence": 0.8, + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is None + + +def test_judge_session_all_valid_statuses_accepted() -> None: + """Every status in VALID_STATUSES must be accepted by judge_session.""" + from bach.services.status_service import VALID_STATUSES + + for status in sorted(VALID_STATUSES): + runner = _make_runner( + { + "summary": f"status={status}", + "suggested_status": status, + "needs_human": False, + "confidence": 0.8, + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is not None, f"Expected verdict for valid status {status!r}, got None" + assert result.suggested_status == status + + +# --------------------------------------------------------------------------- +# Confidence out of range +# --------------------------------------------------------------------------- + + +def test_judge_session_confidence_above_one_returns_none() -> None: + """confidence > 1.0 is out of range → None.""" + runner = _make_runner( + { + "summary": "Something", + "suggested_status": "qa", + "needs_human": False, + "confidence": 1.1, + } + ) + + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + + assert result is None + + +def test_judge_session_confidence_below_zero_returns_none() -> None: + """confidence < 0.0 is out of range → None.""" + runner = _make_runner( + { + "summary": "Something", + "suggested_status": "qa", + "needs_human": False, + "confidence": -0.1, + } + ) + + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + + assert result is None + + +def test_judge_session_confidence_large_positive_returns_none() -> None: + """Very large confidence values (e.g. 100.0 percent style) → None.""" + runner = _make_runner( + { + "summary": "Something", + "suggested_status": "qa", + "needs_human": False, + "confidence": 100.0, + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is None + + +# --------------------------------------------------------------------------- +# Runner failure +# --------------------------------------------------------------------------- + + +def test_judge_session_runner_raises_returns_none() -> None: + """Any exception from llm_runner → None (caller treats as no-op).""" + import subprocess + + runner = _raising_runner(subprocess.CalledProcessError(1, "codex", stderr="timeout")) + + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + + assert result is None + + +def test_judge_session_runner_raises_file_not_found_returns_none() -> None: + """codex CLI not installed (FileNotFoundError) → None.""" + runner = _raising_runner(FileNotFoundError("codex not found")) + + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + + assert result is None + + +def test_judge_session_runner_raises_timeout_returns_none() -> None: + """Subprocess timeout → None.""" + import subprocess + + runner = _raising_runner(subprocess.TimeoutExpired("codex", 60)) + + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + + assert result is None + + +def test_judge_session_runner_raises_value_error_returns_none() -> None: + """ValueError from runner (e.g. bad schema response) → None.""" + runner = _raising_runner(ValueError("output was not a JSON object")) + + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + + assert result is None + + +# --------------------------------------------------------------------------- +# Schema-mismatch (malformed LLM response) +# --------------------------------------------------------------------------- + + +def test_judge_session_missing_summary_returns_none() -> None: + """Response missing 'summary' key → None (schema mismatch).""" + runner = _make_runner( + { + "suggested_status": "qa", + "needs_human": False, + "confidence": 0.9, + # 'summary' intentionally absent + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is None + + +def test_judge_session_missing_confidence_returns_none() -> None: + """Response missing 'confidence' key → None (schema mismatch).""" + runner = _make_runner( + { + "summary": "ok", + "suggested_status": "qa", + "needs_human": False, + # 'confidence' intentionally absent + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is None + + +def test_judge_session_non_bool_needs_human_returns_none() -> None: + """needs_human must be a bool; a string value → None (schema mismatch).""" + runner = _make_runner( + { + "summary": "ok", + "suggested_status": "qa", + "needs_human": "yes", # string, not bool + "confidence": 0.9, + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is None + + +def test_judge_session_non_numeric_confidence_returns_none() -> None: + """confidence must be a number; a string → None (schema mismatch).""" + runner = _make_runner( + { + "summary": "ok", + "suggested_status": "qa", + "needs_human": False, + "confidence": "high", # string, not float + } + ) + result = judge_session( + _make_events(), + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + assert result is None + + +# --------------------------------------------------------------------------- +# Event window bounding +# --------------------------------------------------------------------------- + + +def test_judge_session_empty_events_list_still_calls_runner() -> None: + """Zero events is a valid (degenerate) input; the runner should still be called.""" + called: list[bool] = [] + + def _runner(prompt: str, schema: dict[str, Any], model: str, **_kwargs: Any) -> dict[str, Any]: + called.append(True) + return { + "summary": "no events yet", + "suggested_status": "executing", + "needs_human": False, + "confidence": 0.5, + } + + result = judge_session( + [], + task_title="t", + current_status="executing", + model="m", + llm_runner=_runner, + ) + + assert called == [True] + assert result is not None + + +def test_judge_session_large_event_list_does_not_raise() -> None: + """Hundreds of events must not crash — the window cap keeps the prompt bounded.""" + events = _make_events(500) + runner = _make_runner( + { + "summary": "lots of events", + "suggested_status": "executing", + "needs_human": False, + "confidence": 0.7, + } + ) + + result = judge_session( + events, + task_title="t", + current_status="executing", + model="m", + llm_runner=runner, + ) + + assert result is not None + + +def test_judge_session_prompt_is_bounded_for_large_input() -> None: + """With many events, the prompt sent to the runner is capped in character count. + + The spec requires a 'bounded compact text window' — we verify the prompt + is under a reasonable ceiling (32 KB) even when fed thousands of events. + """ + captured_prompts: list[str] = [] + + def _capture_runner( + prompt: str, schema: dict[str, Any], model: str, **_kwargs: Any + ) -> dict[str, Any]: + captured_prompts.append(prompt) + return { + "summary": "bounded", + "suggested_status": "executing", + "needs_human": False, + "confidence": 0.7, + } + + # 1000 events each with a moderately long text body + events = [ + SessionEvent( + kind=SessionEventKind.assistant_turn, + timestamp=None, + text="x" * 200, + raw_kind="assistant", + ) + for _ in range(1000) + ] + + judge_session( + events, + task_title="t", + current_status="executing", + model="m", + llm_runner=_capture_runner, + ) + + assert len(captured_prompts) == 1 + # 32 KB ceiling: even 1000×200 chars should be well below after capping. + assert len(captured_prompts[0]) < 32_768 diff --git a/tests/unit/test_loop_gate.py b/tests/unit/test_loop_gate.py new file mode 100644 index 0000000..7b88546 --- /dev/null +++ b/tests/unit/test_loop_gate.py @@ -0,0 +1,682 @@ +"""Tests for services/loop_gate.py — evaluate_stop. + +Strategy: + - All filesystem access uses tmp_path — no real ~/.bach. + - Command runner is a fake callable, never runs a real subprocess. + - Store (TaskArtifactStore) is real but backed by a tmp_path artifact. + - Cases covered: + 1. No sidecar → never gate (no block). + 2. Gate disabled in frontmatter → no block. + 3. Gate enabled but no execution approval (Mode 1) → no block. + 4. Verify command passes → no block, counter not incremented. + 5. Verify command fails → block, continuation counter incremented. + 6. Counter reaches max_continuations → no block + hitl_queue escalation. + 7. timeout_s passed to runner. + 8. Missing artifact (sidecar present, artifact deleted) → no block. + 9. Runner raises exception → treated as failure, block returned. +""" + +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from bach.services.loop_gate import StopDecision, evaluate_stop +from bach.storage.artifacts import TaskArtifactStore + +# --------------------------------------------------------------------------- +# Helpers: build minimal artifacts on disk +# --------------------------------------------------------------------------- + + +def _write_artifact( + tmp_path: Path, + *, + status: str = "executing", + loop_gate: dict[str, Any] | None = None, + post_grill_readiness: str = "ready", + post_grill_next_action: str = "execute_same_session", +) -> Path: + """Write a minimal valid artifact with optional loop_gate frontmatter block. + + Mode 1 execution approval is represented by: + post_grill.readiness == "ready" AND + post_grill.next_action in {"execute_same_session", "fresh_session"} + """ + project_dir = tmp_path / "proj" + date_dir = project_dir / ".bach" / "runs" / "2026-06-10" + date_dir.mkdir(parents=True) + (project_dir / ".bach" / "project.yaml").write_text( + yaml.safe_dump({"name": "proj", "path": str(project_dir)}) + ) + artifact = date_dir / "task-100000-test.md" + payload: dict[str, Any] = { + "id": "task-100000-test", + "date": "2026-06-10", + "status": status, + "project": {"name": "proj", "path": str(project_dir)}, + "agent": { + "runtime": "claude-code", + "bach_session_id": "bach-test", + "runtime_session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "runtime_session_name": "bach-test", + "launch_command": "claude", + "resume_command": "claude --resume", + "restart_command": "claude", + }, + "skill": {"name": "grill-me", "status": "completed"}, + "task": { + "title": "Test task", + "original_description": "Test task", + "objective": "test", + "non_goals": [], + "constraints": [], + "likely_files_or_areas": [], + "acceptance_checks": [], + "risks_or_unknowns": [], + }, + "post_grill": { + "readiness": post_grill_readiness, + "next_action": post_grill_next_action, + }, + "log": [{"timestamp": "2026-06-10T00:00:00+00:00", "event": "created"}], + } + if loop_gate is not None: + payload["loop_gate"] = loop_gate + body = "\n## Grill Transcript / Notes\n\n## Final Task Contract\n\n## Carry Forward\n" + artifact.write_text(f"---\n{yaml.safe_dump(payload, sort_keys=False)}---\n{body}") + return artifact + + +def _write_sidecar(sessions_dir: Path, session_id: str, artifact: Path) -> Path: + """Write a sidecar YAML file matching session_tracker.record_session_start format.""" + sessions_dir.mkdir(parents=True, exist_ok=True) + sidecar_path = sessions_dir / f"{session_id}.yaml" + payload = { + "session_id": session_id, + "artifact": str(artifact), + "status_at_start": "executing", + "started_at": "2026-06-10T00:00:00+00:00", + "runtime": "claude-code", + } + sidecar_path.write_text(yaml.safe_dump(payload, sort_keys=True)) + return sidecar_path + + +def _read_log(artifact: Path) -> list[dict[str, Any]]: + """Read the artifact log array.""" + log = yaml.safe_load(artifact.read_text().split("---")[1])["log"] + return list(log) + + +def _read_frontmatter(artifact: Path) -> dict[str, Any]: + """Parse artifact frontmatter as a dict.""" + data: dict[str, Any] = yaml.safe_load(artifact.read_text().split("---")[1]) + return data + + +def _fake_runner_pass(cmd: str | list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + """Fake runner that simulates a passing verify command (exit code 0).""" + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="ok", stderr="") + + +def _fake_runner_fail(cmd: str | list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + """Fake runner that simulates a failing verify command (exit code 1).""" + return subprocess.CompletedProcess( + args=cmd, returncode=1, stdout="", stderr="tests failed: 3 failures" + ) + + +# --------------------------------------------------------------------------- +# No sidecar — never gate +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_no_sidecar_returns_no_block(tmp_path: Path) -> None: + """No sidecar for this session → gate never fires, pass-through.""" + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + store = TaskArtifactStore.__new__(TaskArtifactStore) # not needed for this path + + result = evaluate_stop( + "unknown-session-id", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_pass, + ) + + assert isinstance(result, StopDecision) + assert result.block is False + + +# --------------------------------------------------------------------------- +# Gate disabled or absent +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_no_loop_gate_block_returns_no_block(tmp_path: Path) -> None: + """Artifact has no loop_gate key → absent means never gate.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact(tmp_path) # no loop_gate key + _write_sidecar(sessions_dir, "sess-1", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-1", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_pass, + ) + + assert result.block is False + + +def test_evaluate_stop_loop_gate_enabled_false_returns_no_block(tmp_path: Path) -> None: + """loop_gate.enabled=False → never gate, even with a verify_command.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": False, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-2", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-2", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_pass, + ) + + assert result.block is False + + +# --------------------------------------------------------------------------- +# Mode 1 — execution approval check +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_no_execution_approval_returns_no_block(tmp_path: Path) -> None: + """Mode 1: post_grill.readiness != 'ready' → no execution approved, never gate.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + post_grill_readiness="not_ready", + post_grill_next_action="undecided", + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-3", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-3", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, # even if verify fails, no block without approval + ) + + assert result.block is False + + +def test_evaluate_stop_undecided_next_action_returns_no_block(tmp_path: Path) -> None: + """readiness=ready but next_action=undecided → no execution approval.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + post_grill_readiness="ready", + post_grill_next_action="undecided", + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-4", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-4", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, + ) + + assert result.block is False + + +def test_evaluate_stop_fresh_session_next_action_is_approved(tmp_path: Path) -> None: + """next_action='fresh_session' also counts as execution approved for gating.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + post_grill_readiness="ready", + post_grill_next_action="fresh_session", + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-5", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + # pass → no block, but gate WAS evaluated (approval was present) + result = evaluate_stop( + "sess-5", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_pass, + ) + + assert result.block is False + + +# --------------------------------------------------------------------------- +# Verify command passes → no block +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_verify_pass_returns_no_block(tmp_path: Path) -> None: + """verify_command exits 0 → no block, counter NOT incremented.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-6", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-6", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_pass, + ) + + assert result.block is False + # Log should record the gate decision + log = _read_log(artifact) + assert any(entry.get("event") == "gate_pass" for entry in log) + + +def test_evaluate_stop_verify_pass_does_not_increment_counter(tmp_path: Path) -> None: + """Passing verify command leaves continuation counter unchanged (at 0).""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-7", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + evaluate_stop( + "sess-7", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_pass, + ) + + fm = _read_frontmatter(artifact) + # Counter must not appear or be 0 after a pass. + gate = fm.get("loop_gate", {}) + assert gate.get("continuation_count", 0) == 0 + + +# --------------------------------------------------------------------------- +# Verify command fails → block + increment counter +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_verify_fail_returns_block(tmp_path: Path) -> None: + """verify_command exits non-zero → block=True with a message.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-8", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-8", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, + ) + + assert result.block is True + # Message must be non-empty and contain some failure context. + assert result.message + + +def test_evaluate_stop_verify_fail_message_includes_output(tmp_path: Path) -> None: + """Block message must include trimmed failure output for agent context.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-9", artifact) + + def _failing_with_output( + cmd: str | list[str], **kwargs: Any + ) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=cmd, returncode=1, stdout="FAILED: 5 tests", stderr="" + ) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-9", + sessions_dir=sessions_dir, + store=store, + runner=_failing_with_output, + ) + + assert "FAILED: 5 tests" in result.message + + +def test_evaluate_stop_verify_fail_increments_counter(tmp_path: Path) -> None: + """Each verify failure increments the persisted continuation_count by 1.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-10", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + evaluate_stop( + "sess-10", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, + ) + + fm = _read_frontmatter(artifact) + assert fm["loop_gate"]["continuation_count"] == 1 + + +def test_evaluate_stop_verify_fail_appends_log_event(tmp_path: Path) -> None: + """Verify failure appends a gate_block log event to the artifact.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-11", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + evaluate_stop( + "sess-11", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, + ) + + log = _read_log(artifact) + assert any(entry.get("event") == "gate_block" for entry in log) + + +def test_evaluate_stop_counter_accumulates_across_calls(tmp_path: Path) -> None: + """Multiple consecutive failures accumulate the counter.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 5}, + ) + + store = TaskArtifactStore.from_artifact_path(artifact) + for i in range(3): + _write_sidecar(sessions_dir, f"sess-counter-{i}", artifact) + evaluate_stop( + f"sess-counter-{i}", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, + ) + + fm = _read_frontmatter(artifact) + assert fm["loop_gate"]["continuation_count"] == 3 + + +# --------------------------------------------------------------------------- +# Cap exhaustion → hitl_queue escalation +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_cap_exhausted_returns_no_block(tmp_path: Path) -> None: + """counter >= max_continuations → no block (stop looping) + hitl escalation.""" + sessions_dir = tmp_path / "sessions" + # Start with continuation_count already at the limit. + artifact = _write_artifact( + tmp_path, + loop_gate={ + "enabled": True, + "verify_command": "make test", + "max_continuations": 3, + "continuation_count": 3, # already exhausted + }, + ) + _write_sidecar(sessions_dir, "sess-cap", artifact) + + escalated_to: list[tuple[str, str]] = [] + + def _fake_set_status(path: Path, new_status: str, source: str) -> Any: + escalated_to.append((new_status, source)) + # Return a minimal StatusChange-like object so the caller doesn't crash. + from collections import namedtuple + + SC = namedtuple("SC", ["old", "new", "suggestions"]) + return SC(old="executing", new=new_status, suggestions=[]) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-cap", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, + set_status_fn=_fake_set_status, + ) + + # Cap exhausted → no block (don't keep looping) + assert result.block is False + # Must escalate to hitl_queue via observer source + assert ("hitl_queue", "observer") in escalated_to + + +def test_evaluate_stop_cap_exhausted_logs_gate_exhausted(tmp_path: Path) -> None: + """Counter exhaustion appends a gate_exhausted log event.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={ + "enabled": True, + "verify_command": "make test", + "max_continuations": 2, + "continuation_count": 2, + }, + ) + _write_sidecar(sessions_dir, "sess-exhausted-log", artifact) + + store = TaskArtifactStore.from_artifact_path(artifact) + + from collections import namedtuple + + SC = namedtuple("SC", ["old", "new", "suggestions"]) + + def _noop_set_status(path: Path, new_status: str, source: str) -> Any: + return SC(old="executing", new=new_status, suggestions=[]) + + evaluate_stop( + "sess-exhausted-log", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, + set_status_fn=_noop_set_status, + ) + + log = _read_log(artifact) + assert any(entry.get("event") == "gate_exhausted" for entry in log) + + +def test_evaluate_stop_default_max_continuations_is_three(tmp_path: Path) -> None: + """Default max_continuations (when absent from frontmatter) is 3.""" + sessions_dir = tmp_path / "sessions" + # No max_continuations in the block → should default to 3. + artifact = _write_artifact( + tmp_path, + loop_gate={ + "enabled": True, + "verify_command": "make test", + # max_continuations absent — must default to 3 + "continuation_count": 3, + }, + ) + _write_sidecar(sessions_dir, "sess-default-max", artifact) + + escalated_to: list[str] = [] + + def _fake_set_status(path: Path, new_status: str, source: str) -> Any: + escalated_to.append(new_status) + from collections import namedtuple + + SC = namedtuple("SC", ["old", "new", "suggestions"]) + return SC(old="executing", new=new_status, suggestions=[]) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-default-max", + sessions_dir=sessions_dir, + store=store, + runner=_fake_runner_fail, + set_status_fn=_fake_set_status, + ) + + # Should be treated as exhausted (3 >= default 3). + assert result.block is False + assert "hitl_queue" in escalated_to + + +# --------------------------------------------------------------------------- +# Timeout forwarded to runner +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_passes_timeout_to_runner(tmp_path: Path) -> None: + """timeout_s is forwarded to the subprocess runner via the timeout kwarg.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-timeout", artifact) + + received_kwargs: list[dict[str, Any]] = [] + + def _capture_runner(cmd: str | list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + received_kwargs.append(kwargs) + return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="ok", stderr="") + + store = TaskArtifactStore.from_artifact_path(artifact) + evaluate_stop( + "sess-timeout", + sessions_dir=sessions_dir, + store=store, + runner=_capture_runner, + timeout_s=120, + ) + + assert received_kwargs + assert received_kwargs[0].get("timeout") == 120 + + +# --------------------------------------------------------------------------- +# Missing artifact +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_missing_artifact_returns_no_block(tmp_path: Path) -> None: + """Sidecar points to a deleted artifact → no block (non-fatal).""" + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + sidecar_path = sessions_dir / "sess-missing.yaml" + sidecar_path.write_text( + yaml.safe_dump( + { + "session_id": "sess-missing", + "artifact": str(tmp_path / "does" / "not" / "exist.md"), + "status_at_start": "executing", + "started_at": "2026-06-10T00:00:00+00:00", + "runtime": "claude-code", + } + ) + ) + + store = object() # won't be called — artifact missing before store is used + result = evaluate_stop( + "sess-missing", + sessions_dir=sessions_dir, + store=store, # type: ignore[arg-type] + runner=_fake_runner_pass, + ) + + assert result.block is False + + +# --------------------------------------------------------------------------- +# Runner raises exception → treated as failure (block) +# --------------------------------------------------------------------------- + + +def test_evaluate_stop_runner_raises_returns_block(tmp_path: Path) -> None: + """If the runner raises any exception, treat verify as failed → block.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-raise", artifact) + + def _raising_runner(cmd: str | list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + raise subprocess.TimeoutExpired(cmd, 300) + + store = TaskArtifactStore.from_artifact_path(artifact) + result = evaluate_stop( + "sess-raise", + sessions_dir=sessions_dir, + store=store, + runner=_raising_runner, + ) + + assert result.block is True + + +def test_evaluate_stop_runner_raises_timeout_increments_counter(tmp_path: Path) -> None: + """Runner timeout is a failure — counter must increment.""" + sessions_dir = tmp_path / "sessions" + artifact = _write_artifact( + tmp_path, + loop_gate={"enabled": True, "verify_command": "make test", "max_continuations": 3}, + ) + _write_sidecar(sessions_dir, "sess-timeout-fail", artifact) + + def _raising_runner(cmd: str | list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + raise subprocess.TimeoutExpired(cmd, 300) + + store = TaskArtifactStore.from_artifact_path(artifact) + evaluate_stop( + "sess-timeout-fail", + sessions_dir=sessions_dir, + store=store, + runner=_raising_runner, + ) + + fm = _read_frontmatter(artifact) + assert fm["loop_gate"]["continuation_count"] == 1 + + +# --------------------------------------------------------------------------- +# StopDecision dataclass contract +# --------------------------------------------------------------------------- + + +def test_stop_decision_is_frozen() -> None: + """StopDecision must be an immutable frozen dataclass.""" + decision = StopDecision(block=False, message="ok") + with pytest.raises((AttributeError, TypeError)): + decision.block = True # type: ignore[misc] diff --git a/tests/unit/test_phase16_docs.py b/tests/unit/test_phase16_docs.py new file mode 100644 index 0000000..d056c03 --- /dev/null +++ b/tests/unit/test_phase16_docs.py @@ -0,0 +1,242 @@ +"""Tests for Phase 16 documentation artifacts. + +D1 owns: docs/adr/015-session-observability-codex-parity.md, + docs/adr/016-loop-control.md, and docs/ARCHITECTURE.md + (Phase 16 section added). + +These tests verify that the required documents exist, contain the +load-bearing sections mandated by the DAG spec, and that the +ARCHITECTURE.md additions are present and coherent. They do NOT +test content accuracy (that is a code review concern) — they guard +against accidental deletion or structural omission. +""" + +from pathlib import Path + +# Locate repo root relative to this test file so the tests are +# independent of the working directory. +_REPO_ROOT = Path(__file__).parent.parent.parent +_ADR_DIR = _REPO_ROOT / "docs" / "adr" +_ARCH_FILE = _REPO_ROOT / "docs" / "ARCHITECTURE.md" +_ADR_015 = _ADR_DIR / "015-session-observability-codex-parity.md" +_ADR_016 = _ADR_DIR / "016-loop-control.md" + + +# --------------------------------------------------------------------------- +# ADR-015: session observability + Codex parity +# --------------------------------------------------------------------------- + + +def test_adr_015_exists() -> None: + """ADR-015 file must exist at the expected path.""" + assert _ADR_015.exists(), f"Missing ADR-015: {_ADR_015}" + + +def test_adr_015_has_status_header() -> None: + """ADR-015 must declare its acceptance status.""" + text = _ADR_015.read_text() + assert "Status" in text + assert "Accepted" in text + + +def test_adr_015_supersedes_adr007() -> None: + """ADR-015 must explicitly document that it supersedes ADR-007's Codex exception.""" + text = _ADR_015.read_text() + # The Codex exception in ADR-007 is superseded — this must be noted. + assert "007" in text or "ADR-007" in text + + +def test_adr_015_records_event_spool() -> None: + """ADR-015 must document the event spool. + + Covers spool_path / append_hook_event / read_spool_events. + """ + text = _ADR_015.read_text() + assert "spool" in text.lower() + + +def test_adr_015_records_watcher_run_and_exit() -> None: + """ADR-015 must document the watcher-per-session run-and-exit design.""" + text = _ADR_015.read_text() + # "foreground" also satisfies the intent — run-and-exit is the foreground model. + assert ( + "run-and-exit" in text.lower() + or "run and exit" in text.lower() + or "foreground" in text.lower() + ) + + +def test_adr_015_records_artifact_as_ipc() -> None: + """ADR-015 must document artifact-as-IPC (the artifact is the inter-process state channel).""" + text = _ADR_015.read_text() + # Either the exact term or a clear description of artifact-based communication. + assert "ipc" in text.lower() or "artifact" in text.lower() + + +def test_adr_015_records_observer_authority() -> None: + """ADR-015 must document observer authority enforced at the status write path.""" + text = _ADR_015.read_text() + assert "observer" in text.lower() + # The write-path enforcement is the key architectural point. + assert "status" in text.lower() + + +def test_adr_015_has_context_section() -> None: + """ADR-015 must have a Context section.""" + text = _ADR_015.read_text() + assert "## Context" in text or "# Context" in text + + +def test_adr_015_has_decision_section() -> None: + """ADR-015 must have a Decision section.""" + text = _ADR_015.read_text() + assert "## Decision" in text or "# Decision" in text + + +def test_adr_015_has_consequences_section() -> None: + """ADR-015 must have a Consequences section.""" + text = _ADR_015.read_text() + assert "## Consequences" in text or "# Consequences" in text + + +def test_adr_015_mentions_codex_sidecar() -> None: + """ADR-015 must note that Codex now gets sidecars (removing the guard from ADR-007).""" + text = _ADR_015.read_text() + assert "codex" in text.lower() + assert "sidecar" in text.lower() + + +# --------------------------------------------------------------------------- +# ADR-016: loop control +# --------------------------------------------------------------------------- + + +def test_adr_016_exists() -> None: + """ADR-016 file must exist at the expected path.""" + assert _ADR_016.exists(), f"Missing ADR-016: {_ADR_016}" + + +def test_adr_016_has_status_header() -> None: + """ADR-016 must declare its acceptance status.""" + text = _ADR_016.read_text() + assert "Status" in text + assert "Accepted" in text + + +def test_adr_016_records_gate_opt_in() -> None: + """ADR-016 must document the opt-in loop_gate block (absent = never gate).""" + text = _ADR_016.read_text() + assert "loop_gate" in text or "opt-in" in text.lower() + + +def test_adr_016_records_authority_table() -> None: + """ADR-016 must document the authority table distinguishing observer from afk sources.""" + text = _ADR_016.read_text() + assert "observer" in text.lower() + assert "afk" in text.lower() + + +def test_adr_016_records_afk_bounds() -> None: + """ADR-016 must document AFK max_turns and time_budget enforcement.""" + text = _ADR_016.read_text() + assert "max_turn" in text.lower() or "turn" in text.lower() + assert "time" in text.lower() + + +def test_adr_016_records_mode1_preservation() -> None: + """ADR-016 must document Mode 1 preservation (gate skips non-approved sessions).""" + text = _ADR_016.read_text() + # "Mode 1" is the PRD term for the no-implementation-without-approval contract. + assert "mode 1" in text.lower() or "mode_one" in text.lower() or "post_grill" in text.lower() + + +def test_adr_016_has_context_section() -> None: + """ADR-016 must have a Context section.""" + text = _ADR_016.read_text() + assert "## Context" in text or "# Context" in text + + +def test_adr_016_has_decision_section() -> None: + """ADR-016 must have a Decision section.""" + text = _ADR_016.read_text() + assert "## Decision" in text or "# Decision" in text + + +def test_adr_016_has_consequences_section() -> None: + """ADR-016 must have a Consequences section.""" + text = _ADR_016.read_text() + assert "## Consequences" in text or "# Consequences" in text + + +def test_adr_016_mentions_verify_command() -> None: + """ADR-016 must describe the verify_command field in loop_gate.""" + text = _ADR_016.read_text() + assert "verify_command" in text or "verify command" in text.lower() + + +def test_adr_016_mentions_cap_exhaustion_semantics() -> None: + """ADR-016 must document what happens when the continuation cap is reached.""" + text = _ADR_016.read_text() + assert "cap" in text.lower() or "hitl_queue" in text.lower() or "exhaust" in text.lower() + + +# --------------------------------------------------------------------------- +# ARCHITECTURE.md — Phase 16 additions +# --------------------------------------------------------------------------- + + +def test_architecture_md_exists() -> None: + """ARCHITECTURE.md must exist (it predates Phase 16 — this guards against deletion).""" + assert _ARCH_FILE.exists(), f"Missing ARCHITECTURE.md: {_ARCH_FILE}" + + +def test_architecture_md_has_phase16_section() -> None: + """ARCHITECTURE.md must document Phase 16 session observability components.""" + text = _ARCH_FILE.read_text() + # Phase 16 is the session observability & loop control phase. + assert "Phase 16" in text or "phase 16" in text.lower() or "session observab" in text.lower() + + +def test_architecture_md_mentions_session_watcher() -> None: + """ARCHITECTURE.md must reference the session_watcher component.""" + text = _ARCH_FILE.read_text() + assert "session_watcher" in text or "session watcher" in text.lower() + + +def test_architecture_md_mentions_loop_gate() -> None: + """ARCHITECTURE.md must reference the loop_gate component.""" + text = _ARCH_FILE.read_text() + assert "loop_gate" in text or "loop gate" in text.lower() + + +def test_architecture_md_mentions_afk_runner() -> None: + """ARCHITECTURE.md must reference the afk_runner component.""" + text = _ARCH_FILE.read_text() + assert "afk_runner" in text or "afk runner" in text.lower() or "afk" in text.lower() + + +def test_architecture_md_mentions_judge_service() -> None: + """ARCHITECTURE.md must reference the judge_service component.""" + text = _ARCH_FILE.read_text() + assert "judge_service" in text or "judge service" in text.lower() or "judge" in text.lower() + + +def test_architecture_md_mentions_session_discovery() -> None: + """ARCHITECTURE.md must reference the session_discovery module.""" + text = _ARCH_FILE.read_text() + assert "session_discovery" in text or "session discovery" in text.lower() + + +def test_architecture_md_mentions_control_plane_observation_plane() -> None: + """ARCHITECTURE.md must document the control/observation plane boundary. + + This is the PRD's load-bearing architectural rule: hook payloads may drive + state; parsed transcript content may only feed previews/judge — never gate. + """ + text = _ARCH_FILE.read_text() + # Either the exact term or equivalent description. + assert ( + "control plane" in text.lower() + or "observation plane" in text.lower() + or "observer" in text.lower() + ) diff --git a/tests/unit/test_session_discovery.py b/tests/unit/test_session_discovery.py new file mode 100644 index 0000000..7aa46ef --- /dev/null +++ b/tests/unit/test_session_discovery.py @@ -0,0 +1,925 @@ +"""Tests for runtimes/session_discovery.py. + +Strategy: + - Use tmp_path to build realistic fake session stores for both runtimes. + - Inject process_probe and clock (`now`) so tests run without real + OS process inspection or wall-clock dependencies. + - Liveness thresholds are small integers fed directly to discover_sessions + to avoid any dependency on BachConfig in these unit tests. + +All file-system writes stay inside tmp_path — never ~/.claude or ~/.codex. +""" + +import json +import os +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from bach.domain.models import AgentRuntime, SessionLiveness +from bach.runtimes.session_discovery import ( + default_process_probe, + discover_sessions, +) + +# --------------------------------------------------------------------------- +# Shared fixtures & helpers +# --------------------------------------------------------------------------- + +NOW = datetime(2026, 1, 15, 12, 0, 0, tzinfo=UTC) +# Seconds-since-epoch equivalent of NOW — used to fake mtimes. +NOW_TS = NOW.timestamp() + +# Thresholds used in every call to discover_sessions unless overridden. +RUNNING_S = 30 # mtime within 30 s → running +IDLE_S = 300 # stale > 300 s, process alive → idle + + +def _probe_empty() -> list[tuple[int, str]]: + """Fake process table with no running agents.""" + return [] + + +def _probe_claude(pid: int = 1001) -> list[tuple[int, str]]: + """Fake process table: one claude process.""" + return [(pid, "/usr/local/bin/claude --session-id abc")] + + +def _probe_codex(pid: int = 2001) -> list[tuple[int, str]]: + """Fake process table: one codex process.""" + return [(pid, "/usr/local/bin/codex resume --last")] + + +def _write_jsonl(path: Path, lines: list[dict[str, Any]]) -> None: + """Write a JSONL file (one JSON object per line).""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(line) for line in lines) + "\n") + + +def _set_mtime(path: Path, ts: float) -> None: + """Force a file's mtime without changing its content.""" + os.utime(path, (ts, ts)) + + +# --------------------------------------------------------------------------- +# Claude session store helpers +# --------------------------------------------------------------------------- + + +def _make_claude_session( + projects_dir: Path, + project_slug: str, + session_uuid: str, + *, + mtime_offset_s: float = 0, + lines: list[dict[str, Any]] | None = None, +) -> Path: + """Create a fake Claude session JSONL under projects_dir//.jsonl. + + mtime_offset_s < 0 → file older than NOW by that many seconds. + mtime_offset_s > 0 → newer than NOW (unusual but test-valid). + """ + transcript = projects_dir / project_slug / f"{session_uuid}.jsonl" + content = lines or [{"type": "user", "content": "hello"}] + _write_jsonl(transcript, content) + _set_mtime(transcript, NOW_TS + mtime_offset_s) + return transcript + + +def _make_codex_session( + codex_home: Path, + session_uuid: str, + date_parts: tuple[str, str, str], + ts_str: str, + *, + mtime_offset_s: float = 0, + lines: list[dict[str, Any]] | None = None, +) -> Path: + """Create a fake Codex rollout file at the right path.""" + yyyy, mm, dd = date_parts + filename = f"rollout-{ts_str}-{session_uuid}.jsonl" + transcript = codex_home / "sessions" / yyyy / mm / dd / filename + content = lines or [{"type": "session_meta", "payload": {"id": session_uuid}}] + _write_jsonl(transcript, content) + _set_mtime(transcript, NOW_TS + mtime_offset_s) + return transcript + + +# --------------------------------------------------------------------------- +# Basic smoke tests — discover_sessions returns a list +# --------------------------------------------------------------------------- + + +def test_empty_claude_dir_returns_no_sessions(tmp_path: Path) -> None: + """An empty claude_projects_dir produces an empty result.""" + sessions = discover_sessions( + claude_projects_dir=tmp_path / "claude", + codex_home=tmp_path / "codex", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + assert sessions == [] + + +def test_missing_dirs_returns_no_sessions(tmp_path: Path) -> None: + """Non-existent directories are silently handled — no FileNotFoundError.""" + sessions = discover_sessions( + claude_projects_dir=tmp_path / "nonexistent" / "claude", + codex_home=tmp_path / "nonexistent" / "codex", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + assert sessions == [] + + +# --------------------------------------------------------------------------- +# Claude sessions — slug → project_dir decoding +# --------------------------------------------------------------------------- + + +def test_claude_single_session_discovered(tmp_path: Path) -> None: + """One JSONL file in a slug directory is found and returned.""" + slug = "-home-user-myproject" # represents /home/user/myproject + uuid = "aaaa-bbbb-cccc" + projects_dir = tmp_path / "claude_projects" + _make_claude_session(projects_dir, slug, uuid) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "codex", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + s = sessions[0] + assert s.runtime is AgentRuntime.claude_code + assert s.session_id == uuid + assert s.transcript_path == projects_dir / slug / f"{uuid}.jsonl" + + +def test_claude_slug_decodes_to_project_dir(tmp_path: Path) -> None: + """A slug like '-home-user-proj' decodes to Path('/home/user/proj').""" + slug = "-home-user-proj" + projects_dir = tmp_path / "cp" + _make_claude_session(projects_dir, slug, "sess-1") + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + # Slug '-home-user-proj' → '/home/user/proj' + assert sessions[0].project_dir == Path("/home/user/proj") + + +def test_claude_slug_ambiguous_gives_none_project_dir(tmp_path: Path) -> None: + """A slug that does NOT start with '-' (no leading separator) is ambiguous → None. + + Claude stores slugs by replacing '/' with '-', so an absolute path always + starts with '-'. A slug without a leading '-' has no unambiguous decode. + """ + slug = "somerelativepath" # no leading '-' → ambiguous + projects_dir = tmp_path / "cp" + _make_claude_session(projects_dir, slug, "sess-2") + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + assert sessions[0].project_dir is None + + +def test_claude_multiple_sessions_in_one_slug(tmp_path: Path) -> None: + """Multiple JSONL files inside one slug directory are all discovered.""" + slug = "-home-user-project" + projects_dir = tmp_path / "cp" + _make_claude_session(projects_dir, slug, "uuid-1") + _make_claude_session(projects_dir, slug, "uuid-2") + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + uuids = {s.session_id for s in sessions} + assert uuids == {"uuid-1", "uuid-2"} + + +def test_claude_multiple_slugs(tmp_path: Path) -> None: + """Sessions across multiple slug directories are all discovered.""" + projects_dir = tmp_path / "cp" + _make_claude_session(projects_dir, "-proj-alpha", "alpha-sess") + _make_claude_session(projects_dir, "-proj-beta", "beta-sess") + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 2 + + +def test_claude_non_jsonl_files_ignored(tmp_path: Path) -> None: + """Only .jsonl files should be treated as session transcripts.""" + slug = "-home-user-proj" + projects_dir = tmp_path / "cp" + slug_dir = projects_dir / slug + slug_dir.mkdir(parents=True) + # A .json file and a .txt file — should be ignored. + (slug_dir / "some-config.json").write_text("{}") + (slug_dir / "readme.txt").write_text("hi") + # One real session. + _make_claude_session(projects_dir, slug, "real-session") + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + assert sessions[0].session_id == "real-session" + + +# --------------------------------------------------------------------------- +# Codex sessions — rollout file scan +# --------------------------------------------------------------------------- + + +def test_codex_single_session_discovered(tmp_path: Path) -> None: + """One Codex rollout file is found and returned with runtime=codex.""" + codex_home = tmp_path / "codex" + uuid = "codex-uuid-1" + _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "20260115T120000Z") + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + s = sessions[0] + assert s.runtime is AgentRuntime.codex + assert s.session_id == uuid + + +def test_codex_session_id_extracted_from_filename(tmp_path: Path) -> None: + """Session ID is the UUID part of 'rollout--.jsonl'.""" + codex_home = tmp_path / "codex" + uuid = "my-test-uuid-9999" + _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "20260115T120000") + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + assert sessions[0].session_id == uuid + + +def test_codex_multiple_days(tmp_path: Path) -> None: + """Rollout files across multiple date directories are all discovered.""" + codex_home = tmp_path / "codex" + _make_codex_session(codex_home, "uuid-day1", ("2026", "01", "14"), "20260114T120000Z") + _make_codex_session(codex_home, "uuid-day2", ("2026", "01", "15"), "20260115T120000Z") + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 2 + + +def test_codex_project_dir_from_index(tmp_path: Path) -> None: + """session_index.jsonl entries with a project_dir field are decoded.""" + codex_home = tmp_path / "codex" + uuid = "indexed-uuid" + project_path = "/home/user/myproject" + + # Write index entry with a project_dir field. + index_path = codex_home / "session_index.jsonl" + index_path.parent.mkdir(parents=True, exist_ok=True) + index_path.write_text( + json.dumps( + { + "id": uuid, + "thread_name": "test session", + "updated_at": "2026-01-15T12:00:00Z", + "project_dir": project_path, + } + ) + + "\n" + ) + + # Also create the rollout file so discovery finds the session. + _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "20260115T120000Z") + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + assert sessions[0].project_dir == Path(project_path) + + +def test_codex_project_dir_none_when_not_in_index(tmp_path: Path) -> None: + """A Codex session with no index entry or missing project_dir → project_dir=None.""" + codex_home = tmp_path / "codex" + _make_codex_session(codex_home, "no-index-uuid", ("2026", "01", "15"), "20260115T120002Z") + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + assert sessions[0].project_dir is None + + +def test_codex_index_lags_dir_scan_union(tmp_path: Path) -> None: + """Sessions on disk but NOT in the index are still discovered (index may lag). + + The index is informational. The rollout file scan is always authoritative + for session existence — the index only enriches it with project_dir. + """ + codex_home = tmp_path / "codex" + # File on disk only — no index entry. + _make_codex_session(codex_home, "disk-only-uuid", ("2026", "01", "15"), "20260115T120001Z") + # Index entry for a session with no file (can happen if rollout was deleted). + index_path = codex_home / "session_index.jsonl" + index_path.parent.mkdir(parents=True, exist_ok=True) + index_path.write_text( + json.dumps( + { + "id": "index-only-uuid", + "thread_name": "x", + "updated_at": "2026-01-15T00:00:00Z", + } + ) + + "\n" + ) + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + # Only the disk session is returned — no transcript, no session. + session_ids = {s.session_id for s in sessions} + assert "disk-only-uuid" in session_ids + assert "index-only-uuid" not in session_ids + + +def test_codex_non_rollout_jsonl_ignored(tmp_path: Path) -> None: + """JSONL files not matching 'rollout-*-*.jsonl' pattern are ignored.""" + codex_home = tmp_path / "codex" + sessions_dir = codex_home / "sessions" / "2026" / "01" / "15" + sessions_dir.mkdir(parents=True) + # A JSONL file that doesn't follow rollout naming. + (sessions_dir / "some-other-file.jsonl").write_text("{}\n") + # A real rollout. + _make_codex_session(codex_home, "real-codex-uuid", ("2026", "01", "15"), "20260115T120000Z") + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + assert sessions[0].session_id == "real-codex-uuid" + + +# --------------------------------------------------------------------------- +# Liveness — running (mtime fresh) +# --------------------------------------------------------------------------- + + +def test_liveness_running_when_mtime_within_threshold(tmp_path: Path) -> None: + """Transcript written 10 s ago < 30 s threshold → running.""" + projects_dir = tmp_path / "cp" + _make_claude_session(projects_dir, "-home-user-proj", "fresh-sess", mtime_offset_s=-10) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.running + + +def test_liveness_running_when_mtime_at_threshold_boundary(tmp_path: Path) -> None: + """Transcript exactly at threshold boundary is treated as running. + + The condition is <=threshold, so mtime_offset_s=-RUNNING_S is still running. + """ + projects_dir = tmp_path / "cp" + _make_claude_session( + projects_dir, "-home-user-proj", "boundary-sess", mtime_offset_s=-RUNNING_S + ) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.running + + +# --------------------------------------------------------------------------- +# Liveness — idle (process alive, mtime stale) +# --------------------------------------------------------------------------- + + +def test_liveness_idle_when_process_alive_and_mtime_stale(tmp_path: Path) -> None: + """Stale transcript + matching claude process → idle.""" + projects_dir = tmp_path / "cp" + _make_claude_session( + projects_dir, "-home-user-proj", "stale-sess", mtime_offset_s=-(IDLE_S + 60) + ) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_claude, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.idle + + +def test_liveness_idle_for_codex_session(tmp_path: Path) -> None: + """Stale transcript + matching codex process → idle.""" + codex_home = tmp_path / "codex" + _make_codex_session( + codex_home, + "stale-codex", + ("2026", "01", "15"), + "20260115T120000Z", + mtime_offset_s=-(IDLE_S + 60), + ) + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_codex, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.idle + + +# --------------------------------------------------------------------------- +# Liveness — ended (no matching process) +# --------------------------------------------------------------------------- + + +def test_liveness_ended_when_no_process_and_stale(tmp_path: Path) -> None: + """Stale transcript + empty process table → ended.""" + projects_dir = tmp_path / "cp" + _make_claude_session( + projects_dir, "-home-user-proj", "ended-sess", mtime_offset_s=-(IDLE_S + 60) + ) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.ended + + +def test_liveness_ended_when_no_process_even_if_recent(tmp_path: Path) -> None: + """Recent transcript but no process → running (process table trumps nothing).""" + # If transcript is fresh, it's running regardless of process table. + # This tests the fresh-transcript case (running) to confirm the priority + # ordering: mtime freshness is checked first. + projects_dir = tmp_path / "cp" + _make_claude_session(projects_dir, "-home-user-proj", "fresh-no-proc", mtime_offset_s=-5) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + # Fresh mtime → running, even without a live process. + assert sessions[0].liveness is SessionLiveness.running + + +def test_liveness_ended_between_running_and_idle_no_process(tmp_path: Path) -> None: + """Stale beyond running threshold, no process at all → ended.""" + projects_dir = tmp_path / "cp" + _make_claude_session( + projects_dir, + "-home-user-proj", + "ended-2", + mtime_offset_s=-(RUNNING_S + 10), # stale past running but not past idle + ) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.ended + + +# --------------------------------------------------------------------------- +# last_activity — transcript mtime +# --------------------------------------------------------------------------- + + +def test_last_activity_reflects_transcript_mtime(tmp_path: Path) -> None: + """last_activity is set from the transcript file's mtime.""" + projects_dir = tmp_path / "cp" + offset = -15.0 + _make_claude_session(projects_dir, "-home-user-proj", "activity-sess", mtime_offset_s=offset) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + s = sessions[0] + assert s.last_activity is not None + expected_ts = NOW_TS + offset + assert abs(s.last_activity.timestamp() - expected_ts) < 1.0 + + +def test_last_activity_timezone_aware(tmp_path: Path) -> None: + """last_activity is a timezone-aware datetime (UTC).""" + projects_dir = tmp_path / "cp" + _make_claude_session(projects_dir, "-proj", "tz-sess", mtime_offset_s=-5) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].last_activity is not None + assert sessions[0].last_activity.tzinfo is not None + + +# --------------------------------------------------------------------------- +# Mixed runtime — both Claude and Codex sessions in one call +# --------------------------------------------------------------------------- + + +def test_both_runtimes_discovered_together(tmp_path: Path) -> None: + """discover_sessions returns sessions from both runtimes in one call.""" + projects_dir = tmp_path / "cp" + codex_home = tmp_path / "codex" + + _make_claude_session(projects_dir, "-home-user-proj", "claude-uuid") + _make_codex_session(codex_home, "codex-uuid", ("2026", "01", "15"), "20260115T120000Z") + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + runtimes = {s.runtime for s in sessions} + assert AgentRuntime.claude_code in runtimes + assert AgentRuntime.codex in runtimes + + +# --------------------------------------------------------------------------- +# DiscoveredSession shape +# --------------------------------------------------------------------------- + + +def test_discovered_session_has_correct_runtime(tmp_path: Path) -> None: + """The runtime field always matches the store that was scanned.""" + projects_dir = tmp_path / "cp" + _make_claude_session(projects_dir, "-proj", "r-sess") + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].runtime is AgentRuntime.claude_code + + +def test_discovered_session_transcript_path_exists(tmp_path: Path) -> None: + """transcript_path on the returned record points to the actual file.""" + projects_dir = tmp_path / "cp" + uuid = "path-check-uuid" + expected = projects_dir / "-proj" / f"{uuid}.jsonl" + _make_claude_session(projects_dir, "-proj", uuid) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].transcript_path == expected + + +# --------------------------------------------------------------------------- +# default_process_probe — smoke test (does not crash on the real OS) +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Liveness — IDLE_MAX_AGE_S cap: stale sessions classified as ended +# --------------------------------------------------------------------------- + + +def test_liveness_ended_when_age_exceeds_idle_max_age(tmp_path: Path) -> None: + """A session older than IDLE_MAX_AGE_S is classified as ended even with a live process. + + The process probe is machine-global (any claude process, any session). A + 2-day-old transcript almost certainly belongs to a prior session, not the + currently running one. Beyond IDLE_MAX_AGE_S we presume ended. + """ + from bach.runtimes.session_discovery import IDLE_MAX_AGE_S + + projects_dir = tmp_path / "cp" + # Session is older than IDLE_MAX_AGE_S (e.g. 25 hours old). + _make_claude_session( + projects_dir, + "-home-user-proj", + "ancient-sess", + mtime_offset_s=-(IDLE_MAX_AGE_S + 3600), # 25 hours + ) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + # Even with a claude process alive, the age cap must force ended. + process_probe=_probe_claude, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.ended, ( + "Session older than IDLE_MAX_AGE_S must be classified as ended " + "even if a matching runtime process is alive machine-wide" + ) + + +def test_liveness_ended_one_second_past_idle_max_age_boundary(tmp_path: Path) -> None: + """A session one second past IDLE_MAX_AGE_S is classified as ended. + + The condition is age_s > IDLE_MAX_AGE_S (strict greater-than), so the + boundary (age_s == IDLE_MAX_AGE_S) is still within the cap and classifies + as idle when a process is alive. One second past the cap → ended. + """ + from bach.runtimes.session_discovery import IDLE_MAX_AGE_S + + projects_dir = tmp_path / "cp" + _make_claude_session( + projects_dir, + "-home-user-proj", + "just-past-boundary-sess", + mtime_offset_s=-(IDLE_MAX_AGE_S + 1), + ) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_claude, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.ended + + +def test_liveness_idle_within_idle_max_age(tmp_path: Path) -> None: + """A session younger than IDLE_MAX_AGE_S still gets normal idle classification.""" + from bach.runtimes.session_discovery import IDLE_MAX_AGE_S + + projects_dir = tmp_path / "cp" + # Older than idle_threshold but well within IDLE_MAX_AGE_S. + age = IDLE_S + 60 # stale beyond idle_threshold but << IDLE_MAX_AGE_S + assert age < IDLE_MAX_AGE_S, "Test precondition: age must be < IDLE_MAX_AGE_S" + _make_claude_session( + projects_dir, + "-home-user-proj", + "normal-idle-sess", + mtime_offset_s=-age, + ) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_claude, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions[0].liveness is SessionLiveness.idle + + +def test_default_process_probe_returns_list() -> None: + """default_process_probe() runs ps and returns a list of (pid, cmdline) tuples. + + We only assert the structural contract — not specific processes — so the + test stays hermetic across machines and CI environments. + """ + result = default_process_probe() + assert isinstance(result, list) + for item in result: + assert isinstance(item, tuple) + assert len(item) == 2 + pid, cmd = item + assert isinstance(pid, int) + assert isinstance(cmd, str) + + +# --------------------------------------------------------------------------- +# Resilience — malformed filenames, empty directories, I/O errors +# --------------------------------------------------------------------------- + + +def test_empty_slug_directory_produces_no_sessions(tmp_path: Path) -> None: + """A slug directory with no JSONL files is silently skipped.""" + projects_dir = tmp_path / "cp" + (projects_dir / "-empty-slug").mkdir(parents=True) + + sessions = discover_sessions( + claude_projects_dir=projects_dir, + codex_home=tmp_path / "c", + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions == [] + + +def test_codex_sessions_dir_missing_returns_no_codex_sessions(tmp_path: Path) -> None: + """If /sessions/ doesn't exist, no Codex sessions are returned.""" + codex_home = tmp_path / "codex" + codex_home.mkdir(parents=True) # home exists, but no sessions subdir + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert sessions == [] + + +def test_codex_malformed_index_line_skipped(tmp_path: Path) -> None: + """A non-JSON line in session_index.jsonl is skipped, not raised.""" + codex_home = tmp_path / "codex" + index_path = codex_home / "session_index.jsonl" + index_path.parent.mkdir(parents=True) + # Write a mix of bad and good lines. + index_path.write_text("this is not json\n") + # Also create a rollout file. + _make_codex_session(codex_home, "good-uuid", ("2026", "01", "15"), "20260115T120003Z") + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + # The rollout file is still discovered. + assert len(sessions) == 1 + assert sessions[0].session_id == "good-uuid" + + +def test_codex_rollout_filename_with_multiple_hyphens_in_uuid(tmp_path: Path) -> None: + """UUID containing multiple hyphens is extracted correctly from the filename. + + Filename format: rollout--.jsonl + The UUID itself contains hyphens, so parsing must split on the FIRST two + hyphens after 'rollout-' prefix: rollout--. + + Actually: the format is rollout-- where ts has no hyphens. + We test a realistic UUID like '550e8400-e29b-41d4-a716-446655440000'. + """ + codex_home = tmp_path / "codex" + uuid = "550e8400-e29b-41d4-a716-446655440000" + _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "20260115T120000Z") + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + assert sessions[0].session_id == uuid diff --git a/tests/unit/test_session_models.py b/tests/unit/test_session_models.py new file mode 100644 index 0000000..a0464fe --- /dev/null +++ b/tests/unit/test_session_models.py @@ -0,0 +1,270 @@ +"""Tests for Phase 16 session observability domain types. + +Covers: + - SessionLiveness, SessionEventKind as StrEnums (all values round-trip, + membership, and string equality). + - SessionEvent: frozen, correct field types, optional fields accept None. + - DiscoveredSession: frozen, optional fields, correct defaults. + - JudgeVerdict: frozen, confidence bounds are the caller's concern (the + dataclass itself imposes no constraint — judge_service does that). + +These are pure data types — no I/O, no services. Tests are fast by design. +""" + +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from bach.domain.models import ( + AgentRuntime, + DiscoveredSession, + JudgeVerdict, + SessionEvent, + SessionEventKind, + SessionLiveness, +) + +# --------------------------------------------------------------------------- +# SessionLiveness +# --------------------------------------------------------------------------- + + +def test_session_liveness_values_are_strings() -> None: + """StrEnum values must equal their string forms — storage round-trip.""" + assert SessionLiveness.running == "running" + assert SessionLiveness.idle == "idle" + assert SessionLiveness.ended == "ended" + + +def test_session_liveness_all_members() -> None: + """All three liveness states exist — nothing more, nothing less.""" + members = {m.value for m in SessionLiveness} + assert members == {"running", "idle", "ended"} + + +def test_session_liveness_from_string() -> None: + """SessionLiveness can be constructed from its string value.""" + assert SessionLiveness("running") is SessionLiveness.running + assert SessionLiveness("idle") is SessionLiveness.idle + assert SessionLiveness("ended") is SessionLiveness.ended + + +def test_session_liveness_invalid_raises() -> None: + """Unknown string raises ValueError — no silent coercion at the enum level.""" + with pytest.raises(ValueError): + SessionLiveness("stale") + + +# --------------------------------------------------------------------------- +# SessionEventKind +# --------------------------------------------------------------------------- + + +def test_session_event_kind_all_members() -> None: + """All seven event kinds exist per the spec.""" + expected = { + "session_meta", + "user_turn", + "assistant_turn", + "tool_call", + "tool_result", + "end", + "unknown", + } + assert {m.value for m in SessionEventKind} == expected + + +def test_session_event_kind_string_equality() -> None: + """StrEnum identity — each value equals its string form for easy serialization.""" + assert SessionEventKind.session_meta == "session_meta" + assert SessionEventKind.user_turn == "user_turn" + assert SessionEventKind.assistant_turn == "assistant_turn" + assert SessionEventKind.tool_call == "tool_call" + assert SessionEventKind.tool_result == "tool_result" + assert SessionEventKind.end == "end" + assert SessionEventKind.unknown == "unknown" + + +def test_session_event_kind_from_string() -> None: + """Round-trip from string value works for every member.""" + for kind in SessionEventKind: + assert SessionEventKind(kind.value) is kind + + +# --------------------------------------------------------------------------- +# SessionEvent +# --------------------------------------------------------------------------- + + +def test_session_event_is_frozen() -> None: + """SessionEvent is a frozen dataclass — mutation must raise.""" + event = SessionEvent( + kind=SessionEventKind.user_turn, + timestamp=None, + text=None, + raw_kind="user", + ) + with pytest.raises((AttributeError, TypeError)): + event.kind = SessionEventKind.end # type: ignore[misc] + + +def test_session_event_all_fields_set() -> None: + """Happy-path construction with all fields provided.""" + ts = datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC) + event = SessionEvent( + kind=SessionEventKind.assistant_turn, + timestamp=ts, + text="Hello from the assistant", + raw_kind="assistant", + ) + assert event.kind is SessionEventKind.assistant_turn + assert event.timestamp == ts + assert event.text == "Hello from the assistant" + assert event.raw_kind == "assistant" + + +def test_session_event_optional_fields_accept_none() -> None: + """timestamp and text are both typed as `X | None` — None must be accepted.""" + event = SessionEvent( + kind=SessionEventKind.unknown, + timestamp=None, + text=None, + raw_kind="some-unknown-type", + ) + assert event.timestamp is None + assert event.text is None + + +def test_session_event_raw_kind_preserved_for_unknown() -> None: + """raw_kind keeps the runtime's own string even when kind=unknown. + + This is the diagnostic hook — transcript.py populates it so downstream + code can log or surface the unrecognized type without parsing again. + """ + event = SessionEvent( + kind=SessionEventKind.unknown, + timestamp=None, + text=None, + raw_kind="queue-operation", + ) + assert event.raw_kind == "queue-operation" + + +# --------------------------------------------------------------------------- +# DiscoveredSession +# --------------------------------------------------------------------------- + + +def test_discovered_session_is_frozen() -> None: + """DiscoveredSession is a frozen dataclass — must raise on assignment.""" + session = DiscoveredSession( + runtime=AgentRuntime.claude_code, + session_id="abc-123", + transcript_path=Path("/tmp/fake.jsonl"), + project_dir=None, + liveness=SessionLiveness.running, + last_activity=None, + ) + with pytest.raises((AttributeError, TypeError)): + session.session_id = "other" # type: ignore[misc] + + +def test_discovered_session_optional_fields_none() -> None: + """project_dir and last_activity are both optional — None accepted.""" + session = DiscoveredSession( + runtime=AgentRuntime.codex, + session_id="sess-42", + transcript_path=Path("/tmp/t.jsonl"), + project_dir=None, + liveness=SessionLiveness.ended, + last_activity=None, + ) + assert session.project_dir is None + assert session.last_activity is None + + +def test_discovered_session_with_all_fields() -> None: + """Full construction with project_dir and last_activity set.""" + ts = datetime(2025, 6, 1, 12, 0, 0, tzinfo=UTC) + session = DiscoveredSession( + runtime=AgentRuntime.claude_code, + session_id="uuid-abc", + transcript_path=Path("/home/user/.claude/projects/proj/uuid-abc.jsonl"), + project_dir=Path("/home/user/myproject"), + liveness=SessionLiveness.idle, + last_activity=ts, + ) + assert session.runtime is AgentRuntime.claude_code + assert session.session_id == "uuid-abc" + assert session.project_dir == Path("/home/user/myproject") + assert session.liveness is SessionLiveness.idle + assert session.last_activity == ts + + +def test_discovered_session_runtime_codex() -> None: + """Codex runtime is a valid value for DiscoveredSession.runtime.""" + session = DiscoveredSession( + runtime=AgentRuntime.codex, + session_id="codex-sess-1", + transcript_path=Path("/tmp/codex.jsonl"), + project_dir=None, + liveness=SessionLiveness.running, + last_activity=None, + ) + assert session.runtime is AgentRuntime.codex + + +# --------------------------------------------------------------------------- +# JudgeVerdict +# --------------------------------------------------------------------------- + + +def test_judge_verdict_is_frozen() -> None: + """JudgeVerdict is a frozen dataclass — mutation must raise.""" + verdict = JudgeVerdict( + summary="task looks complete", + suggested_status="qa", + needs_human=False, + confidence=0.9, + ) + with pytest.raises((AttributeError, TypeError)): + verdict.summary = "changed" # type: ignore[misc] + + +def test_judge_verdict_fields() -> None: + """All four fields are stored and accessible.""" + verdict = JudgeVerdict( + summary="Compilation errors found", + suggested_status="hitl_queue", + needs_human=True, + confidence=0.85, + ) + assert verdict.summary == "Compilation errors found" + assert verdict.suggested_status == "hitl_queue" + assert verdict.needs_human is True + assert verdict.confidence == 0.85 + + +def test_judge_verdict_confidence_boundary_values() -> None: + """The dataclass itself does not enforce 0.0–1.0 bounds — that is judge_service's job. + + We verify that extreme values are stored as-is (the constraint lives in + services/judge_service.py, not in the dataclass, to keep domain types free of + service-layer policy). + """ + low = JudgeVerdict(summary="x", suggested_status="qa", needs_human=False, confidence=0.0) + high = JudgeVerdict(summary="x", suggested_status="qa", needs_human=False, confidence=1.0) + assert low.confidence == 0.0 + assert high.confidence == 1.0 + + +def test_judge_verdict_needs_human_false() -> None: + """needs_human=False is a valid and common case.""" + verdict = JudgeVerdict( + summary="All tests pass", + suggested_status="qa", + needs_human=False, + confidence=0.95, + ) + assert verdict.needs_human is False diff --git a/tests/unit/test_session_spool.py b/tests/unit/test_session_spool.py new file mode 100644 index 0000000..8875ddd --- /dev/null +++ b/tests/unit/test_session_spool.py @@ -0,0 +1,145 @@ +"""Tests for the spool helpers in services/session_tracker.py. + +Strategy: + - All functions take injected sessions_dir (tmp_path) — no real ~/.bach. + - Tests assert ONE behaviour per test. + - spool_path, append_hook_event, read_spool_events are the public API + being verified here; sidecar functions are covered in test_session_tracker.py. +""" + +import json +from pathlib import Path + +from bach.services.session_tracker import ( + append_hook_event, + read_spool_events, + spool_path, +) + +# --------------------------------------------------------------------------- +# spool_path +# --------------------------------------------------------------------------- + + +def test_spool_path_returns_expected_filename(tmp_path: Path) -> None: + """Spool file is /.events.jsonl.""" + p = spool_path("abc-123", sessions_dir=tmp_path) + assert p == tmp_path / "abc-123.events.jsonl" + + +def test_spool_path_does_not_create_file(tmp_path: Path) -> None: + """spool_path is a pure path computation — it must not touch disk.""" + p = spool_path("x", sessions_dir=tmp_path) + assert not p.exists() + + +# --------------------------------------------------------------------------- +# append_hook_event +# --------------------------------------------------------------------------- + + +def test_append_hook_event_creates_file_on_first_write(tmp_path: Path) -> None: + """First append creates the spool file (parents must exist, i.e. sessions_dir).""" + append_hook_event("s1", {"type": "stop"}, sessions_dir=tmp_path) + assert spool_path("s1", sessions_dir=tmp_path).exists() + + +def test_append_hook_event_writes_valid_json_line(tmp_path: Path) -> None: + """Each event is a complete JSON object on one line.""" + payload = {"type": "hook_event", "data": "hello"} + append_hook_event("s1", payload, sessions_dir=tmp_path) + lines = spool_path("s1", sessions_dir=tmp_path).read_text().splitlines() + assert len(lines) == 1 + parsed = json.loads(lines[0]) + assert parsed["type"] == "hook_event" + assert parsed["data"] == "hello" + + +def test_append_hook_event_appends_multiple_events(tmp_path: Path) -> None: + """Multiple appends produce multiple lines (append, not overwrite).""" + append_hook_event("s2", {"n": 1}, sessions_dir=tmp_path) + append_hook_event("s2", {"n": 2}, sessions_dir=tmp_path) + append_hook_event("s2", {"n": 3}, sessions_dir=tmp_path) + lines = spool_path("s2", sessions_dir=tmp_path).read_text().splitlines() + assert len(lines) == 3 + assert json.loads(lines[2])["n"] == 3 + + +def test_append_hook_event_separate_sessions_have_separate_files(tmp_path: Path) -> None: + """Each session_id gets its own spool file.""" + append_hook_event("a", {"x": 1}, sessions_dir=tmp_path) + append_hook_event("b", {"x": 2}, sessions_dir=tmp_path) + assert spool_path("a", sessions_dir=tmp_path).exists() + assert spool_path("b", sessions_dir=tmp_path).exists() + # 'a' spool only has the 'a' event. + lines_a = spool_path("a", sessions_dir=tmp_path).read_text().splitlines() + assert len(lines_a) == 1 + + +def test_append_hook_event_creates_sessions_dir_on_demand(tmp_path: Path) -> None: + """sessions_dir need not pre-exist — append creates it.""" + nested = tmp_path / "deep" / "sessions" + append_hook_event("s", {"y": 1}, sessions_dir=nested) + assert spool_path("s", sessions_dir=nested).exists() + + +# --------------------------------------------------------------------------- +# read_spool_events +# --------------------------------------------------------------------------- + + +def test_read_spool_events_returns_empty_for_missing_file(tmp_path: Path) -> None: + """Missing spool → ([], 0). Not an error — most sessions have no spool.""" + events, offset = read_spool_events("nonexistent", sessions_dir=tmp_path) + assert events == [] + assert offset == 0 + + +def test_read_spool_events_returns_all_events_from_zero(tmp_path: Path) -> None: + """from_offset=0 reads everything in the spool.""" + append_hook_event("s3", {"i": 1}, sessions_dir=tmp_path) + append_hook_event("s3", {"i": 2}, sessions_dir=tmp_path) + events, offset = read_spool_events("s3", sessions_dir=tmp_path, from_offset=0) + assert len(events) == 2 + assert events[0]["i"] == 1 + assert events[1]["i"] == 2 + + +def test_read_spool_events_returns_new_offset(tmp_path: Path) -> None: + """Returned offset equals the number of lines consumed (for resumption).""" + append_hook_event("s4", {"x": 1}, sessions_dir=tmp_path) + append_hook_event("s4", {"x": 2}, sessions_dir=tmp_path) + _, offset = read_spool_events("s4", sessions_dir=tmp_path, from_offset=0) + assert offset == 2 + + +def test_read_spool_events_respects_from_offset(tmp_path: Path) -> None: + """from_offset skips the first N lines — allows incremental polling.""" + for i in range(5): + append_hook_event("s5", {"i": i}, sessions_dir=tmp_path) + events, offset = read_spool_events("s5", sessions_dir=tmp_path, from_offset=3) + assert len(events) == 2 + assert events[0]["i"] == 3 + assert events[1]["i"] == 4 + assert offset == 5 + + +def test_read_spool_events_skips_blank_lines(tmp_path: Path) -> None: + """Blank lines in the spool file are silently ignored.""" + sp = spool_path("s6", sessions_dir=tmp_path) + tmp_path.mkdir(parents=True, exist_ok=True) + sp.write_text('{"k": 1}\n\n{"k": 2}\n') + events, _ = read_spool_events("s6", sessions_dir=tmp_path) + assert len(events) == 2 + + +def test_read_spool_events_skips_malformed_json_lines(tmp_path: Path) -> None: + """A malformed JSON line is silently skipped — never raises.""" + sp = spool_path("s7", sessions_dir=tmp_path) + tmp_path.mkdir(parents=True, exist_ok=True) + sp.write_text('{"k": 1}\nnot-json\n{"k": 2}\n') + events, _ = read_spool_events("s7", sessions_dir=tmp_path) + # Only the two valid lines survive. + assert len(events) == 2 + assert events[0]["k"] == 1 + assert events[1]["k"] == 2 diff --git a/tests/unit/test_session_tracker.py b/tests/unit/test_session_tracker.py index 1a6beea..7151455 100644 --- a/tests/unit/test_session_tracker.py +++ b/tests/unit/test_session_tracker.py @@ -24,6 +24,13 @@ record_session_start, ) +# Phase 16: runtime field added to record_session_start and sidecar. +# Imported separately so old tests still run if the field is missing. +try: + from bach.domain.models import AgentRuntime +except ImportError: + AgentRuntime = None # type: ignore[assignment,misc] + @pytest.fixture def fake_home(tmp_path: Path) -> Iterator[Path]: @@ -135,6 +142,7 @@ def test_gc_stale_sidecars_deletes_only_old_ones(fake_home: Path) -> None: stale = _sidecar_path("stale") old_ts = (datetime.now(tz=UTC) - timedelta(days=10)).timestamp() import os + os.utime(stale, (old_ts, old_ts)) deleted = gc_stale_sidecars(max_age_days=7) @@ -153,3 +161,51 @@ def test_gc_stale_sidecars_handles_zero_age(fake_home: Path) -> None: record_session_start("a", Path("/x"), "grilling") record_session_start("b", Path("/y"), "grilling") assert gc_stale_sidecars(max_age_days=0) == 2 + + +# --------------------------------------------------------------------------- +# Phase 16: runtime field in sidecar +# --------------------------------------------------------------------------- + + +def test_record_session_start_accepts_runtime_field(fake_home: Path) -> None: + """record_session_start accepts an optional runtime keyword without error.""" + artifact = Path("/tmp/t.md") + # Should not raise whether runtime is passed or not. + target = record_session_start( + session_id="rt-test", + artifact=artifact, + status_at_start="executing", + runtime="claude-code", + ) + assert target.exists() + + +def test_record_session_start_persists_runtime_in_sidecar(fake_home: Path) -> None: + """When runtime is supplied it appears in the YAML sidecar payload.""" + import yaml + + artifact = Path("/tmp/t.md") + target = record_session_start( + session_id="rt-persist", + artifact=artifact, + status_at_start="executing", + runtime="codex", + ) + payload = yaml.safe_load(target.read_text()) + assert payload.get("runtime") == "codex" + + +def test_record_session_start_runtime_defaults_to_none(fake_home: Path) -> None: + """Calling without runtime is backward-compatible — no KeyError.""" + import yaml + + artifact = Path("/tmp/t.md") + target = record_session_start( + session_id="rt-none", + artifact=artifact, + status_at_start="inbox", + ) + payload = yaml.safe_load(target.read_text()) + # runtime key is present (None) or absent — both are acceptable. + assert payload.get("runtime") is None or "runtime" not in payload diff --git a/tests/unit/test_session_watcher.py b/tests/unit/test_session_watcher.py new file mode 100644 index 0000000..a869b29 --- /dev/null +++ b/tests/unit/test_session_watcher.py @@ -0,0 +1,791 @@ +"""Tests for services/session_watcher.py — watch_session loop. + +Strategy: + - Inject all external seams (follow_events, read_spool_events, + judge_session, set_task_status, append_log) as callables. + - Use a real transcript file that grows mid-test (via tmp_path). + - Use a fake judge that returns controllable verdicts. + - Assert observer moves, log-only paths, and clean exit. + - Never touch real ~/.bach, ~/.claude, ~/.codex, or LLM/subprocesses. + +The watcher contract (from dag.md): + - follow_events + drain spool in lock-step poll cycles. + - On stop/end spool events → always judge (ignores interval). + - Otherwise judge at most once per judge_interval_seconds. + - Verdict → apply via set_task_status(source="observer") when + observer_moves=True AND confidence >= threshold. + - Verdict below threshold → append log suggestion only. + - Exits when session ends (SessionEventKind.end, or transcript + ends + process gone via should_stop). + - Every observer action logged event=observer_*. + - All failures non-fatal (log + continue) except missing artifact. +""" + +from collections.abc import Callable, Iterator +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +from bach.config.settings import BachConfig +from bach.domain.models import ( + AgentRuntime, + JudgeVerdict, + SessionEvent, + SessionEventKind, +) +from bach.services.session_watcher import _transcript_path_for, watch_session + +# --------------------------------------------------------------------------- +# Fixtures and helpers +# --------------------------------------------------------------------------- + + +def _write_artifact( + tmp_path: Path, + status: str = "executing", + title: str = "Test Task", +) -> Path: + """Write a minimal valid task artifact for testing.""" + project_dir = tmp_path / "proj" + date_dir = project_dir / ".bach" / "runs" / "2026-06-10" + date_dir.mkdir(parents=True) + + artifact = date_dir / "task-100000-test.md" + payload: dict[str, Any] = { + "id": "task-100000-test", + "date": "2026-06-10", + "status": status, + "project": {"name": "proj", "path": str(project_dir)}, + "agent": { + "runtime": "claude-code", + "bach_session_id": "bach-test-id", + "runtime_session_id": "runtime-uuid-123", + "runtime_session_name": "bach-test-proj", + "launch_command": None, + "resume_command": None, + "restart_command": None, + }, + "skill": {"name": "grill-me", "status": "not_started"}, + "task": { + "title": title, + "original_description": title, + "objective": None, + "non_goals": [], + "constraints": [], + "likely_files_or_areas": [], + "acceptance_checks": [], + "risks_or_unknowns": [], + }, + "post_grill": {"readiness": "not_ready", "next_action": "undecided"}, + "log": [{"timestamp": "2026-06-10T00:00:00+00:00", "event": "created"}], + } + body = "\n## Grill Transcript / Notes\n\n## Final Task Contract\n\n## Carry Forward\n" + artifact.write_text(f"---\n{yaml.safe_dump(payload, sort_keys=False)}---\n{body}") + return artifact + + +def _read_log(artifact: Path) -> list[dict[str, Any]]: + """Parse artifact and return the log array.""" + raw = yaml.safe_load(artifact.read_text().split("---")[1]) + return list(raw.get("log", [])) + + +def _read_status(artifact: Path) -> str: + """Parse artifact and return the current status.""" + return str(yaml.safe_load(artifact.read_text().split("---")[1])["status"]) + + +def _make_event(kind: SessionEventKind, text: str = "hello") -> SessionEvent: + """Construct a minimal SessionEvent for testing.""" + return SessionEvent( + kind=kind, + timestamp=datetime.now(UTC), + text=text, + raw_kind=kind.value, + ) + + +def _make_verdict( + suggested_status: str = "qa", + needs_human: bool = False, + confidence: float = 0.9, + summary: str = "Session looks good", +) -> JudgeVerdict: + """Construct a JudgeVerdict for testing.""" + return JudgeVerdict( + summary=summary, + suggested_status=suggested_status, + needs_human=needs_human, + confidence=confidence, + ) + + +def _null_judge(**_kwargs: Any) -> None: + """A judge that always returns None (no verdict).""" + return None + + +def _events_iterator( + event_batches: list[list[SessionEvent]], +) -> Callable[..., Iterator[SessionEvent]]: + """ + Return a follow_events replacement that yields each batch then stops. + + Each inner list is one 'poll cycle'. An end event in a batch will cause + the watcher's should_stop to see an end event, triggering clean exit. + """ + + def _follow( + path: Path, + runtime: AgentRuntime, + *, + poll_seconds: float, + should_stop: Callable[[], bool], + ) -> Iterator[SessionEvent]: + for batch in event_batches: + if should_stop(): + return + yield from batch + + return _follow + + +def _spool_reader_empty( + session_id: str, *, sessions_dir: Path, from_offset: int = 0 +) -> tuple[list[dict[str, Any]], int]: + """A read_spool_events replacement that always returns empty spool.""" + return [], 0 + + +def _spool_reader_with_stop( + session_id: str, *, sessions_dir: Path, from_offset: int = 0 +) -> tuple[list[dict[str, Any]], int]: + """A read_spool_events that returns a 'stop' spool event once.""" + if from_offset == 0: + event = {"type": "stop", "timestamp": datetime.now(UTC).isoformat()} + return [event], 1 + return [], 1 + + +def _spool_reader_with_session_end( + session_id: str, *, sessions_dir: Path, from_offset: int = 0 +) -> tuple[list[dict[str, Any]], int]: + """A read_spool_events that returns a 'session_end' event once.""" + if from_offset == 0: + event = {"type": "session_end", "timestamp": datetime.now(UTC).isoformat()} + return [event], 1 + return [], 1 + + +def _default_config( + observer_moves: bool = True, + judge_confidence_threshold: float = 0.7, + judge_interval_seconds: int = 300, +) -> BachConfig: + """Build a BachConfig with sensible defaults for testing.""" + return BachConfig( + observer_moves=observer_moves, + judge_confidence_threshold=judge_confidence_threshold, + judge_interval_seconds=judge_interval_seconds, + ) + + +# --------------------------------------------------------------------------- +# Missing artifact — the one fatal condition +# --------------------------------------------------------------------------- + + +def test_watch_session_missing_artifact_raises(tmp_path: Path) -> None: + """Missing artifact path → exits with non-zero code (the one fatal condition).""" + missing = tmp_path / "nonexistent.md" + code = watch_session( + missing, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([]), + read_spool_events_fn=_spool_reader_empty, + judge_fn=_null_judge, + ) + assert code != 0 + + +# --------------------------------------------------------------------------- +# Clean exit on transcript end event +# --------------------------------------------------------------------------- + + +def test_watch_session_exits_clean_on_end_event(tmp_path: Path) -> None: + """SessionEventKind.end in transcript → loop exits with code 0.""" + artifact = _write_artifact(tmp_path) + end_event = _make_event(SessionEventKind.end) + + code = watch_session( + artifact, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[end_event]]), + read_spool_events_fn=_spool_reader_empty, + judge_fn=_null_judge, + ) + assert code == 0 + + +# --------------------------------------------------------------------------- +# Clean exit on spool stop/end events +# --------------------------------------------------------------------------- + + +def test_watch_session_exits_on_spool_stop_event(tmp_path: Path) -> None: + """Spool 'stop' event → triggers final judge and exits with code 0.""" + artifact = _write_artifact(tmp_path) + judge_calls: list[dict[str, Any]] = [] + + def _recording_judge(**kwargs: Any) -> None: + judge_calls.append(kwargs) + return None + + code = watch_session( + artifact, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), # no transcript events + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_recording_judge, + ) + assert code == 0 + # The stop event must have triggered a judge call. + assert len(judge_calls) >= 1 + + +def test_watch_session_exits_on_spool_session_end_event(tmp_path: Path) -> None: + """Spool 'session_end' event → triggers final judge and exits with code 0.""" + artifact = _write_artifact(tmp_path) + + code = watch_session( + artifact, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_session_end, + judge_fn=_null_judge, + ) + assert code == 0 + + +# --------------------------------------------------------------------------- +# Observer moves: status write on high-confidence verdict +# --------------------------------------------------------------------------- + + +def test_watch_session_observer_writes_status_on_high_confidence( + tmp_path: Path, +) -> None: + """High-confidence verdict + observer_moves=True → status write via set_task_status.""" + artifact = _write_artifact(tmp_path, status="executing") + + def _high_conf_judge(**_kwargs: Any) -> JudgeVerdict: + return _make_verdict(suggested_status="qa", confidence=0.9) + + # Stop after one poll cycle via spool stop event. + code = watch_session( + artifact, + config=_default_config(observer_moves=True, judge_confidence_threshold=0.7), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_high_conf_judge, + ) + assert code == 0 + # Status must have been updated to qa. + assert _read_status(artifact) == "qa" + + +def test_watch_session_no_status_write_when_observer_moves_disabled( + tmp_path: Path, +) -> None: + """observer_moves=False → verdict is log-only, no status write.""" + artifact = _write_artifact(tmp_path, status="executing") + + def _high_conf_judge(**_kwargs: Any) -> JudgeVerdict: + return _make_verdict(suggested_status="qa", confidence=0.9) + + code = watch_session( + artifact, + config=_default_config(observer_moves=False, judge_confidence_threshold=0.7), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_high_conf_judge, + ) + assert code == 0 + # Status must NOT have changed — observer_moves is off. + assert _read_status(artifact) == "executing" + # But a log entry should be present for the suggestion. + log = _read_log(artifact) + assert any(e.get("event") == "observer_suggestion" for e in log), ( + "Expected observer_suggestion log entry when observer_moves=False" + ) + + +# --------------------------------------------------------------------------- +# Log-only path: confidence below threshold +# --------------------------------------------------------------------------- + + +def test_watch_session_low_confidence_logs_suggestion_not_status( + tmp_path: Path, +) -> None: + """Verdict below confidence threshold → log-only suggestion, no status write.""" + artifact = _write_artifact(tmp_path, status="executing") + + def _low_conf_judge(**_kwargs: Any) -> JudgeVerdict: + # Confidence 0.3 is below the 0.7 default threshold. + return _make_verdict(suggested_status="qa", confidence=0.3) + + code = watch_session( + artifact, + config=_default_config(observer_moves=True, judge_confidence_threshold=0.7), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_low_conf_judge, + ) + assert code == 0 + # Status must NOT have changed (too low confidence). + assert _read_status(artifact) == "executing" + # A suggestion log entry must have been appended. + log = _read_log(artifact) + assert any(e.get("event") == "observer_suggestion" for e in log), ( + "Expected observer_suggestion log entry for low-confidence verdict" + ) + + +# --------------------------------------------------------------------------- +# needs_human escalation path +# --------------------------------------------------------------------------- + + +def test_watch_session_needs_human_writes_hitl_queue( + tmp_path: Path, +) -> None: + """needs_human=True in verdict → set_task_status to hitl_queue (if allowed).""" + artifact = _write_artifact(tmp_path, status="executing") + + def _needs_human_judge(**_kwargs: Any) -> JudgeVerdict: + return _make_verdict( + suggested_status="hitl_queue", + needs_human=True, + confidence=0.9, + ) + + code = watch_session( + artifact, + config=_default_config(observer_moves=True, judge_confidence_threshold=0.7), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_needs_human_judge, + ) + assert code == 0 + assert _read_status(artifact) == "hitl_queue" + + +# --------------------------------------------------------------------------- +# Non-fatal failures: judge returns None, status write fails +# --------------------------------------------------------------------------- + + +def test_watch_session_none_verdict_is_nonfatal(tmp_path: Path) -> None: + """Judge returning None → loop continues, no crash, clean exit.""" + artifact = _write_artifact(tmp_path) + + code = watch_session( + artifact, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_null_judge, # always returns None + ) + assert code == 0 + + +def test_watch_session_judge_exception_is_nonfatal(tmp_path: Path) -> None: + """Judge raising an exception → non-fatal, loop exits cleanly.""" + artifact = _write_artifact(tmp_path) + + def _exploding_judge(**_kwargs: Any) -> JudgeVerdict: + raise RuntimeError("LLM timeout") + + code = watch_session( + artifact, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_exploding_judge, + ) + assert code == 0 + + +def test_watch_session_status_write_failure_is_nonfatal(tmp_path: Path) -> None: + """set_task_status raising → non-fatal, loop exits cleanly.""" + artifact = _write_artifact(tmp_path, status="executing") + + def _high_conf_judge(**_kwargs: Any) -> JudgeVerdict: + return _make_verdict(suggested_status="qa", confidence=0.9) + + status_write_calls: list[int] = [] + + def _exploding_status_writer(artifact_path: Path, new_status: str, source: str) -> None: + status_write_calls.append(1) + raise OSError("disk full") + + code = watch_session( + artifact, + config=_default_config(observer_moves=True), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_high_conf_judge, + set_task_status_fn=_exploding_status_writer, + ) + assert code == 0 + # The failed write should have been attempted. + assert len(status_write_calls) >= 1 + + +# --------------------------------------------------------------------------- +# Judge interval: mid-session calls are rate-limited +# --------------------------------------------------------------------------- + + +def test_watch_session_judge_called_at_most_once_without_stop_event( + tmp_path: Path, +) -> None: + """ + Without a stop/end spool event, the judge fires at most once per + judge_interval_seconds. With interval=9999 and multiple poll cycles + of regular events, the judge fires at most once. + """ + artifact = _write_artifact(tmp_path) + judge_calls: list[int] = [] + + def _counting_judge(**_kwargs: Any) -> None: + judge_calls.append(1) + return None + + # Three batches of regular assistant_turn events, then a stop event. + batches: list[list[SessionEvent]] = [ + [_make_event(SessionEventKind.assistant_turn, "turn 1")], + [_make_event(SessionEventKind.assistant_turn, "turn 2")], + [_make_event(SessionEventKind.assistant_turn, "turn 3")], + [], # final batch, paired with spool stop event + ] + + call_count = [0] + + def _spool_with_late_stop( + session_id: str, *, sessions_dir: Path, from_offset: int = 0 + ) -> tuple[list[dict[str, Any]], int]: + call_count[0] += 1 + # Return stop on the 4th drain call. + if call_count[0] >= 4: + return [{"type": "stop"}], 1 + return [], 0 + + code = watch_session( + artifact, + config=_default_config( + judge_interval_seconds=9999, # very long interval + ), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator(batches), + read_spool_events_fn=_spool_with_late_stop, + judge_fn=_counting_judge, + ) + assert code == 0 + # With interval=9999, the judge fires exactly once (on the stop event). + # Without the stop event it would fire 0 times mid-session. With the + # forced stop event, exactly 1 call is expected. + assert judge_calls == [1] + + +# --------------------------------------------------------------------------- +# Transcript grows mid-test (file I/O integration) +# --------------------------------------------------------------------------- + + +def test_watch_session_accumulates_events_from_growing_transcript( + tmp_path: Path, +) -> None: + """ + The watcher must accumulate events that appear in the transcript + after the watcher starts. Simulated by injecting a follow_events_fn + that grows its event list across multiple poll cycles. + """ + artifact = _write_artifact(tmp_path) + seen_events: list[SessionEvent] = [] + + def _accumulating_judge(**kwargs: Any) -> None: + # Capture the events passed to the judge. + seen_events.extend(kwargs.get("events", [])) + return None + + # Simulate transcript growing: first 2 events, then 2 more, then stop. + batches: list[list[SessionEvent]] = [ + [_make_event(SessionEventKind.user_turn, "first message")], + [_make_event(SessionEventKind.assistant_turn, "first reply")], + [], # triggers stop + ] + + call_count = [0] + + def _spool_stop_on_third( + session_id: str, *, sessions_dir: Path, from_offset: int = 0 + ) -> tuple[list[dict[str, Any]], int]: + call_count[0] += 1 + if call_count[0] >= 3: + return [{"type": "stop"}], 1 + return [], 0 + + code = watch_session( + artifact, + config=_default_config(judge_interval_seconds=0), # fire judge every cycle + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator(batches), + read_spool_events_fn=_spool_stop_on_third, + judge_fn=_accumulating_judge, + ) + assert code == 0 + # The judge must have seen at least one event from the growing transcript. + assert len(seen_events) >= 1 + + +# --------------------------------------------------------------------------- +# Session ID and task title are passed to the judge +# --------------------------------------------------------------------------- + + +def test_watch_session_passes_task_title_to_judge(tmp_path: Path) -> None: + """The task title from the artifact frontmatter is passed to judge_fn.""" + artifact = _write_artifact(tmp_path, title="My Important Task") + captured: list[dict[str, Any]] = [] + + def _capturing_judge(**kwargs: Any) -> None: + captured.append(dict(kwargs)) + return None + + code = watch_session( + artifact, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_capturing_judge, + ) + assert code == 0 + assert any("My Important Task" in str(c.get("task_title", "")) for c in captured) + + +def test_watch_session_passes_current_status_to_judge(tmp_path: Path) -> None: + """The current artifact status is passed to judge_fn.""" + artifact = _write_artifact(tmp_path, status="executing") + captured: list[dict[str, Any]] = [] + + def _capturing_judge(**kwargs: Any) -> None: + captured.append(dict(kwargs)) + return None + + code = watch_session( + artifact, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_capturing_judge, + ) + assert code == 0 + assert any(c.get("current_status") == "executing" for c in captured) + + +# --------------------------------------------------------------------------- +# Default injected dependencies are wired correctly (smoke test) +# --------------------------------------------------------------------------- + + +def test_watch_session_accepts_minimal_args(tmp_path: Path) -> None: + """watch_session can be called with only required args — defaults for optionals.""" + artifact = _write_artifact(tmp_path) + + # End the session immediately so the test doesn't hang. + code = watch_session( + artifact, + config=_default_config(), + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[_make_event(SessionEventKind.end)]]), + read_spool_events_fn=_spool_reader_empty, + judge_fn=_null_judge, + ) + assert code == 0 + + +# --------------------------------------------------------------------------- +# Transcript path resolution — _transcript_path_for +# --------------------------------------------------------------------------- + + +def test_transcript_path_for_claude_returns_jsonl_under_slug(tmp_path: Path) -> None: + """_transcript_path_for returns the expected JSONL path for claude-code. + + The slug is the project absolute path with '/' and '.' replaced by '-'. + The resolved path should be: + //.jsonl + The file is created in the tmp dir so the function returns a real path. + """ + project_path = tmp_path / "myproject" + session_id = "550e8400-e29b-41d4-a716-446655440000" + + # Replicate the slug encoding: replace '/' and '.' with '-'. + slug = str(project_path).replace("/", "-").replace(".", "-") + claude_projects_dir = tmp_path / "claude" / "projects" + transcript_dir = claude_projects_dir / slug + transcript_dir.mkdir(parents=True) + expected = transcript_dir / f"{session_id}.jsonl" + expected.touch() # create it so the function can find it + + result = _transcript_path_for( + AgentRuntime.claude_code, + session_id, + project_path, + claude_projects_dir=claude_projects_dir, + ) + + assert result is not None + assert result == expected + + +def test_transcript_path_for_claude_no_session_id_returns_none(tmp_path: Path) -> None: + """Empty session_id → None (Codex before writeback or missing field).""" + result = _transcript_path_for( + AgentRuntime.claude_code, + "", + tmp_path / "proj", + claude_projects_dir=tmp_path / "claude" / "projects", + ) + assert result is None + + +def test_transcript_path_for_codex_finds_rollout_file(tmp_path: Path) -> None: + """_transcript_path_for locates a codex rollout-*-.jsonl via glob.""" + session_id = "abc12345-0000-1111-2222-333344445555" + codex_sessions_dir = tmp_path / "codex" / "sessions" + date_dir = codex_sessions_dir / "2026" / "06" / "10" + date_dir.mkdir(parents=True) + rollout = date_dir / f"rollout-20260610T120000Z-{session_id}.jsonl" + rollout.touch() + + result = _transcript_path_for( + AgentRuntime.codex, + session_id, + tmp_path / "proj", + codex_sessions_dir=codex_sessions_dir, + ) + + assert result is not None + assert result == rollout + + +# --------------------------------------------------------------------------- +# Regression: artifact body containing "---" horizontal rule survives log append +# --------------------------------------------------------------------------- + + +def _write_artifact_with_hr_body(tmp_path: Path) -> Path: + """Write a task artifact whose markdown body contains a '---' horizontal rule. + + This exercises the DRIFT bug: a naive text.split("---") without maxsplit + would split on the horizontal rule and corrupt the artifact on rewrite. + """ + import yaml as _yaml + + project_dir = tmp_path / "proj" + date_dir = project_dir / ".bach" / "runs" / "2026-06-10" + date_dir.mkdir(parents=True) + artifact = date_dir / "task-120000-hr-test.md" + + payload: dict[str, Any] = { + "id": "task-120000-hr-test", + "date": "2026-06-10", + "status": "executing", + "project": {"name": "proj", "path": str(project_dir)}, + "agent": { + "runtime": "claude-code", + "bach_session_id": "bach-hr-test", + "runtime_session_id": "aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb", + "runtime_session_name": "bach-hr-proj", + "launch_command": None, + "resume_command": None, + "restart_command": None, + }, + "skill": {"name": "grill-me", "status": "not_started"}, + "task": { + "title": "HR body test", + "original_description": "test", + "objective": None, + "non_goals": [], + "constraints": [], + "likely_files_or_areas": [], + "acceptance_checks": [], + "risks_or_unknowns": [], + }, + "post_grill": {"readiness": "not_ready", "next_action": "undecided"}, + "log": [{"timestamp": "2026-06-10T00:00:00+00:00", "event": "created"}], + } + # Body deliberately contains "---" as a markdown horizontal rule. + body = ( + "\n## Grill Transcript / Notes\n\n" + "Some notes here.\n\n" + "---\n\n" # <--- the horizontal rule that must survive + "More notes below the rule.\n\n" + "## Final Task Contract\n\n" + "## Carry Forward\n" + ) + artifact.write_text(f"---\n{_yaml.safe_dump(payload, sort_keys=False)}---\n{body}") + return artifact + + +def test_log_append_preserves_body_with_hr_rule(tmp_path: Path) -> None: + """Appending a log event must not corrupt a body that contains a '---' rule. + + Regression for the DRIFT bug: split("---") without maxsplit=2 splits on the + horizontal rule in the body and produces a malformed artifact on rewrite. + """ + artifact = _write_artifact_with_hr_body(tmp_path) + body_before = artifact.read_text().split("---", 2)[2] + + def _high_conf_judge(**_kwargs: Any) -> JudgeVerdict: + return _make_verdict(suggested_status="qa", confidence=0.9) + + code = watch_session( + artifact, + config=_default_config(observer_moves=False), # log-only to trigger _append_log_event + sessions_dir=tmp_path / "sessions", + follow_events_fn=_events_iterator([[]]), + read_spool_events_fn=_spool_reader_with_stop, + judge_fn=_high_conf_judge, + ) + assert code == 0 + + # Body must be byte-identical after the log append. + body_after = artifact.read_text().split("---", 2)[2] + assert body_after == body_before, ( + "Artifact body was corrupted by _append_log_event — " + "the '---' horizontal rule was likely treated as a frontmatter delimiter." + ) + + # And the log entry must have been appended. + log = _read_log(artifact) + assert any(e.get("event") == "observer_suggestion" for e in log) diff --git a/tests/unit/test_sessions_service.py b/tests/unit/test_sessions_service.py new file mode 100644 index 0000000..569157a --- /dev/null +++ b/tests/unit/test_sessions_service.py @@ -0,0 +1,720 @@ +"""Tests for services/sessions_service.py — list_sessions, adopt_session, resume_session. + +Strategy: + - Build realistic fake DiscoveredSession lists using tmp_path artifacts. + - Inject all external deps (discover_sessions, read_tail_events, iterm) so + tests never touch real ~/.claude, ~/.codex, ~/.bach, or iTerm. + - Filesystem writes stay inside tmp_path. + +Covered cases: + list_sessions: + - empty discovery → empty list + - sessions linked to artifact by runtime_session_id → task_id populated + - sessions with no matching artifact → task_id/artifact_path None + - filter by project path (project arg) + - filter by runtime + - filter by state/liveness + - preview populated from read_tail_events (last assistant text) + - preview degrades gracefully when read_tail_events fails + + adopt_session: + - links session to artifact → writes runtime_session_id + - refuses when task already linked to non-ended session (without force) + - force=True overwrites existing link + - ambiguous prefix → ValueError listing candidates + - no match → ValueError + + resume_session: + - linked task → returns persisted resume_command + - orphan session → returns build_resume_command result + - orphan with no project_dir → returns runtime-built command +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from bach.domain.models import ( + AgentRuntime, + DiscoveredSession, + SessionEvent, + SessionEventKind, + SessionLiveness, +) +from bach.services.sessions_service import ( + SessionRow, + adopt_session, + find_artifact_for_ref, + list_sessions, + resume_session, + scan_artifact_paths, +) + +# --------------------------------------------------------------------------- +# Constants / test clock +# --------------------------------------------------------------------------- + +NOW = datetime(2026, 1, 15, 12, 0, 0, tzinfo=UTC) +RUNNING_S = 30 +IDLE_S = 300 + + +# --------------------------------------------------------------------------- +# Helpers — build fake artifacts and discovered sessions +# --------------------------------------------------------------------------- + + +def _make_artifact( + tmp_path: Path, + *, + runtime_session_id: str | None = None, + task_id: str = "t1", + runtime: str = "claude-code", + project_path: str | None = None, + resume_command: str | None = None, + status: str = "executing", +) -> Path: + """Write a minimal Bach task artifact to tmp_path and return its path.""" + artifact = tmp_path / f"{task_id}.md" + proj_path = project_path or str(tmp_path / "myproject") + payload: dict[str, Any] = { + "id": f"task-123456-{task_id}", + "task_id": task_id, + "date": "2026-01-15", + "status": status, + "project": {"name": "myproject", "path": proj_path}, + "agent": { + "runtime": runtime, + "bach_session_id": f"bach-20260115-120000-{task_id}", + "runtime_session_id": runtime_session_id, + "runtime_session_name": f"session-{task_id}", + "launch_command": "claude ...", + "resume_command": resume_command, + "restart_command": "claude ...", + }, + "skill": {"name": "grill-me", "status": "completed"}, + "task": { + "title": f"Task {task_id}", + "original_description": "some description", + "objective": None, + "non_goals": [], + "constraints": [], + "likely_files_or_areas": [], + "acceptance_checks": [], + "risks_or_unknowns": [], + }, + "post_grill": {"readiness": "ready", "next_action": "execute_same_session"}, + "log": [{"timestamp": "2026-01-15T12:00:00+00:00", "event": "created"}], + "parent_task": None, + "blocked_by": [], + } + body = "\n## Grill Transcript / Notes\n\n## Final Task Contract\n\n## Carry Forward\n" + artifact.write_text(f"---\n{yaml.safe_dump(payload, sort_keys=False)}---\n{body}") + return artifact + + +def _make_session( + *, + session_id: str | None = None, + runtime: AgentRuntime = AgentRuntime.claude_code, + liveness: SessionLiveness = SessionLiveness.running, + project_dir: Path | None = None, + transcript_path: Path | None = None, +) -> DiscoveredSession: + """Build a DiscoveredSession for testing (no real files needed).""" + sid = session_id or str(uuid.uuid4()) + tp = transcript_path or Path(f"/tmp/fake/{sid}.jsonl") + return DiscoveredSession( + runtime=runtime, + session_id=sid, + transcript_path=tp, + project_dir=project_dir, + liveness=liveness, + last_activity=NOW, + ) + + +def _make_assistant_event(text: str) -> SessionEvent: + return SessionEvent( + kind=SessionEventKind.assistant_turn, + timestamp=NOW, + text=text, + raw_kind="assistant", + ) + + +# --------------------------------------------------------------------------- +# list_sessions — empty / no artifacts +# --------------------------------------------------------------------------- + + +def test_list_sessions_empty_discovery_returns_empty(tmp_path: Path) -> None: + """No sessions discovered → empty list.""" + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [], + read_events_fn=lambda path, runtime: [], + ) + assert result == [] + + +def test_list_sessions_no_matching_artifact_gives_none_task_id(tmp_path: Path) -> None: + """Session not linked to any artifact → task_id and artifact_path are None.""" + session = _make_session() + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [session], + read_events_fn=lambda path, runtime: [], + ) + assert len(result) == 1 + row = result[0] + assert row.task_id is None + assert row.artifact_path is None + assert row.session.session_id == session.session_id + + +def test_list_sessions_matched_artifact_populates_task_id(tmp_path: Path) -> None: + """Session whose session_id matches artifact's runtime_session_id → task_id set.""" + sid = str(uuid.uuid4()) + artifact = _make_artifact(tmp_path, runtime_session_id=sid, task_id="t42") + session = _make_session(session_id=sid) + + result = list_sessions( + sessions_dir=tmp_path, + artifacts_dir=tmp_path, + discover_fn=lambda: [session], + read_events_fn=lambda path, runtime: [], + artifact_paths=[artifact], + ) + + assert len(result) == 1 + row = result[0] + assert row.task_id == "t42" + assert row.artifact_path == artifact + + +def test_list_sessions_preview_from_last_assistant_event(tmp_path: Path) -> None: + """Preview is the text from the last assistant_turn event.""" + session = _make_session() + events = [ + _make_assistant_event("first message"), + _make_assistant_event("last message"), + ] + + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [session], + read_events_fn=lambda path, runtime: events, + ) + + assert len(result) == 1 + assert result[0].preview == "last message" + + +def test_list_sessions_preview_none_on_no_events(tmp_path: Path) -> None: + """No events → preview is None.""" + session = _make_session() + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [session], + read_events_fn=lambda path, runtime: [], + ) + assert result[0].preview is None + + +def test_list_sessions_preview_degrades_on_exception(tmp_path: Path) -> None: + """read_events_fn raising → preview is None (no crash).""" + session = _make_session() + + def _bad_reader(path: Path, runtime: AgentRuntime) -> list[SessionEvent]: + raise OSError("file gone") + + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [session], + read_events_fn=_bad_reader, + ) + assert result[0].preview is None + + +def test_list_sessions_filter_by_runtime(tmp_path: Path) -> None: + """runtime filter keeps only matching sessions.""" + claude_session = _make_session(runtime=AgentRuntime.claude_code) + codex_session = _make_session(runtime=AgentRuntime.codex) + + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [claude_session, codex_session], + read_events_fn=lambda path, runtime: [], + runtime="claude-code", + ) + assert len(result) == 1 + assert result[0].session.runtime == AgentRuntime.claude_code + + +def test_list_sessions_filter_by_state(tmp_path: Path) -> None: + """state filter keeps only sessions with matching liveness.""" + running = _make_session(liveness=SessionLiveness.running) + ended = _make_session(liveness=SessionLiveness.ended) + + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [running, ended], + read_events_fn=lambda path, runtime: [], + state="running", + ) + assert len(result) == 1 + assert result[0].session.liveness == SessionLiveness.running + + +def test_list_sessions_filter_by_project(tmp_path: Path) -> None: + """project filter keeps only sessions whose project_dir matches.""" + proj_a = tmp_path / "proj_a" + proj_b = tmp_path / "proj_b" + session_a = _make_session(project_dir=proj_a) + session_b = _make_session(project_dir=proj_b) + + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [session_a, session_b], + read_events_fn=lambda path, runtime: [], + project=str(proj_a), + ) + assert len(result) == 1 + assert result[0].session.project_dir == proj_a + + +def test_list_sessions_multiple_sessions_all_included(tmp_path: Path) -> None: + """Multiple sessions with no filters → all returned.""" + sessions = [_make_session() for _ in range(3)] + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: sessions, + read_events_fn=lambda path, runtime: [], + ) + assert len(result) == 3 + + +def test_list_sessions_preview_picks_last_user_turn_when_no_assistant(tmp_path: Path) -> None: + """When there are only user_turn events, preview comes from last user event.""" + session = _make_session() + events = [ + SessionEvent( + kind=SessionEventKind.user_turn, timestamp=NOW, text="user msg", raw_kind="user" + ), + ] + result = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path / "arts", + discover_fn=lambda: [session], + read_events_fn=lambda path, runtime: events, + ) + # user_turn as fallback when no assistant events + assert result[0].preview == "user msg" + + +# --------------------------------------------------------------------------- +# adopt_session +# --------------------------------------------------------------------------- + + +def test_adopt_session_writes_runtime_session_id(tmp_path: Path) -> None: + """adopt_session writes session_id into artifact's agent.runtime_session_id.""" + sid = str(uuid.uuid4()) + artifact = _make_artifact(tmp_path, runtime_session_id=None, task_id="t5") + session = _make_session(session_id=sid, liveness=SessionLiveness.ended) + + result = adopt_session( + session_ref=sid, + task_ref="t5", + artifact_paths=[artifact], + discover_fn=lambda: [session], + ) + + assert result == artifact + payload_text = artifact.read_text() + assert sid in payload_text + + +def test_adopt_session_refuses_when_task_already_linked_to_running_session( + tmp_path: Path, +) -> None: + """Refuses to adopt if artifact is already linked to a different running session.""" + existing_sid = str(uuid.uuid4()) + new_sid = str(uuid.uuid4()) + artifact = _make_artifact(tmp_path, runtime_session_id=existing_sid, task_id="t6") + + # existing linked session is running (non-ended) + existing_session = _make_session(session_id=existing_sid, liveness=SessionLiveness.running) + new_session = _make_session(session_id=new_sid, liveness=SessionLiveness.ended) + + with pytest.raises(ValueError, match="already linked"): + adopt_session( + session_ref=new_sid, + task_ref="t6", + artifact_paths=[artifact], + discover_fn=lambda: [existing_session, new_session], + ) + + +def test_adopt_session_force_overwrites_existing_link(tmp_path: Path) -> None: + """force=True allows overwriting an existing link.""" + existing_sid = str(uuid.uuid4()) + new_sid = str(uuid.uuid4()) + artifact = _make_artifact(tmp_path, runtime_session_id=existing_sid, task_id="t7") + + existing_session = _make_session(session_id=existing_sid, liveness=SessionLiveness.running) + new_session = _make_session(session_id=new_sid, liveness=SessionLiveness.ended) + + result = adopt_session( + session_ref=new_sid, + task_ref="t7", + artifact_paths=[artifact], + discover_fn=lambda: [existing_session, new_session], + force=True, + ) + + assert result == artifact + payload_text = artifact.read_text() + assert new_sid in payload_text + + +def test_adopt_session_allows_when_existing_session_ended(tmp_path: Path) -> None: + """If the artifact's current linked session is ended, adopt is allowed without force.""" + existing_sid = str(uuid.uuid4()) + new_sid = str(uuid.uuid4()) + artifact = _make_artifact(tmp_path, runtime_session_id=existing_sid, task_id="t8") + + # existing session is ended → safe to re-link + existing_session = _make_session(session_id=existing_sid, liveness=SessionLiveness.ended) + new_session = _make_session(session_id=new_sid, liveness=SessionLiveness.ended) + + result = adopt_session( + session_ref=new_sid, + task_ref="t8", + artifact_paths=[artifact], + discover_fn=lambda: [existing_session, new_session], + ) + assert result == artifact + + +def test_adopt_session_task_not_found_raises_value_error(tmp_path: Path) -> None: + """No artifact matching task_ref → ValueError.""" + sid = str(uuid.uuid4()) + session = _make_session(session_id=sid) + + with pytest.raises(ValueError, match="No task"): + adopt_session( + session_ref=sid, + task_ref="t999", + artifact_paths=[], + discover_fn=lambda: [session], + ) + + +def test_adopt_session_session_not_found_raises_value_error(tmp_path: Path) -> None: + """session_ref not found in discovered sessions → ValueError.""" + artifact = _make_artifact(tmp_path, runtime_session_id=None, task_id="t9") + + with pytest.raises(ValueError, match="[Nn]o session"): + adopt_session( + session_ref="deadbeef-0000-0000-0000-000000000000", + task_ref="t9", + artifact_paths=[artifact], + discover_fn=lambda: [], + ) + + +def test_adopt_session_unique_prefix_matching(tmp_path: Path) -> None: + """Unique prefix of session_id is resolved to the full session.""" + sid = "aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb" + artifact = _make_artifact(tmp_path, runtime_session_id=None, task_id="t10") + session = _make_session(session_id=sid, liveness=SessionLiveness.ended) + + result = adopt_session( + session_ref="aaaabbbb", + task_ref="t10", + artifact_paths=[artifact], + discover_fn=lambda: [session], + ) + assert result == artifact + + +def test_adopt_session_ambiguous_prefix_raises_value_error(tmp_path: Path) -> None: + """Ambiguous prefix (matches multiple sessions) → ValueError listing candidates.""" + prefix = "aaaa" + sid1 = f"{prefix}1111-0000-0000-0000-000000000001" + sid2 = f"{prefix}2222-0000-0000-0000-000000000002" + artifact = _make_artifact(tmp_path, runtime_session_id=None, task_id="t11") + session1 = _make_session(session_id=sid1, liveness=SessionLiveness.ended) + session2 = _make_session(session_id=sid2, liveness=SessionLiveness.ended) + + with pytest.raises(ValueError, match="[Aa]mbiguous"): + adopt_session( + session_ref=prefix, + task_ref="t11", + artifact_paths=[artifact], + discover_fn=lambda: [session1, session2], + ) + + +# --------------------------------------------------------------------------- +# resume_session +# --------------------------------------------------------------------------- + + +def test_resume_session_linked_task_returns_persisted_command(tmp_path: Path) -> None: + """Linked session with resume_command → returns the stored command.""" + sid = str(uuid.uuid4()) + resume_cmd = f"cd '/my/project' && claude --resume {sid}" + artifact = _make_artifact( + tmp_path, + runtime_session_id=sid, + task_id="t20", + resume_command=resume_cmd, + ) + session = _make_session(session_id=sid) + + result = resume_session( + session_ref=sid, + artifact_paths=[artifact], + discover_fn=lambda: [session], + launch_fn=lambda cmd, path, title: True, + ) + assert result == resume_cmd + + +def test_resume_session_orphan_returns_runtime_command(tmp_path: Path) -> None: + """Orphan session (no linked artifact) → builds command from runtime.""" + sid = str(uuid.uuid4()) + proj_dir = tmp_path / "myproject" + session = _make_session( + session_id=sid, + runtime=AgentRuntime.claude_code, + project_dir=proj_dir, + ) + + result = resume_session( + session_ref=sid, + artifact_paths=[], + discover_fn=lambda: [session], + launch_fn=lambda cmd, path, title: True, + ) + # Claude Code resume command contains the session id + assert sid in result + + +def test_resume_session_codex_orphan_returns_command(tmp_path: Path) -> None: + """Orphan Codex session → returns a codex resume command.""" + sid = str(uuid.uuid4()) + proj_dir = tmp_path / "codexproject" + session = _make_session( + session_id=sid, + runtime=AgentRuntime.codex, + project_dir=proj_dir, + ) + + result = resume_session( + session_ref=sid, + artifact_paths=[], + discover_fn=lambda: [session], + launch_fn=lambda cmd, path, title: True, + ) + assert "codex" in result.lower() or sid in result + + +def test_resume_session_not_found_raises_value_error(tmp_path: Path) -> None: + """session_ref not found → ValueError.""" + with pytest.raises(ValueError, match="[Nn]o session"): + resume_session( + session_ref="nonexistent-id", + artifact_paths=[], + discover_fn=lambda: [], + launch_fn=lambda cmd, path, title: True, + ) + + +def test_resume_session_iterm_fallback_prints_command( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """When launch_fn returns False (iTerm failure), fallback command is printed.""" + sid = str(uuid.uuid4()) + resume_cmd = f"cd '/proj' && claude --resume {sid}" + artifact = _make_artifact( + tmp_path, + runtime_session_id=sid, + task_id="t21", + resume_command=resume_cmd, + ) + session = _make_session(session_id=sid) + + resume_session( + session_ref=sid, + artifact_paths=[artifact], + discover_fn=lambda: [session], + launch_fn=lambda cmd, path, title: False, # iTerm fails + ) + + captured = capsys.readouterr() + # Fallback command should appear in stdout (consistent with iterm launch pattern) + assert resume_cmd in captured.out + + +# --------------------------------------------------------------------------- +# SessionRow dataclass +# --------------------------------------------------------------------------- + + +def test_session_row_is_frozen(tmp_path: Path) -> None: + """SessionRow must be a frozen dataclass.""" + session = _make_session() + row = SessionRow(session=session, task_id="t1", artifact_path=None, preview=None) + with pytest.raises((AttributeError, TypeError)): + row.task_id = "t2" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Regression: adopt_session with "---" horizontal rule in body +# --------------------------------------------------------------------------- + + +def _make_artifact_with_hr_body(tmp_path: Path, *, task_id: str = "t50") -> Path: + """Write an artifact whose Markdown body contains a '---' horizontal rule. + + This is the regression case: a naive text.split("---") without maxsplit=2 + would produce 4 parts instead of 3, corrupting the read/write round-trip. + """ + artifact = tmp_path / f"{task_id}.md" + payload: dict[str, Any] = { + "id": f"task-hr-{task_id}", + "task_id": task_id, + "date": "2026-01-15", + "status": "executing", + "project": {"name": "hrproject", "path": str(tmp_path / "hrproject")}, + "agent": { + "runtime": "claude-code", + "bach_session_id": f"bach-20260115-120000-{task_id}", + "runtime_session_id": None, + "runtime_session_name": f"session-{task_id}", + "launch_command": "claude ...", + "resume_command": None, + "restart_command": "claude ...", + }, + "skill": {"name": "grill-me", "status": "completed"}, + "task": { + "title": f"Task with HR {task_id}", + "original_description": "task with horizontal rule in body", + "objective": None, + "non_goals": [], + "constraints": [], + "likely_files_or_areas": [], + "acceptance_checks": [], + "risks_or_unknowns": [], + }, + "post_grill": {"readiness": "ready", "next_action": "execute_same_session"}, + "log": [{"timestamp": "2026-01-15T12:00:00+00:00", "event": "created"}], + "parent_task": None, + "blocked_by": [], + } + # The body intentionally contains a "---" horizontal rule — this is the + # regression trigger. Without maxsplit=2, the split produces 4 chunks + # and the round-trip silently drops everything after the first "---". + body = ( + "\n## Grill Transcript / Notes\n\n" + "some notes here\n\n" + "---\n\n" # <-- horizontal rule that must survive the round-trip + "## Final Task Contract\n\n" + "## Carry Forward\n" + ) + artifact.write_text(f"---\n{yaml.safe_dump(payload, sort_keys=False)}---\n{body}") + return artifact + + +def test_adopt_session_body_with_horizontal_rule_is_preserved(tmp_path: Path) -> None: + """adopt_session on an artifact whose body contains '---' must leave the body intact. + + Regression for the old text.split('---') without maxsplit: that split + would produce 4 parts, causing the body after the first '---' rule to + be silently truncated on every write. The fix delegates to + TaskArtifactStore._split which always uses maxsplit=2. + """ + sid = str(uuid.uuid4()) + artifact = _make_artifact_with_hr_body(tmp_path, task_id="t50") + session = _make_session(session_id=sid, liveness=SessionLiveness.ended) + + # Record the body content before adopt. + original_text = artifact.read_text() + # Confirm the horizontal rule is in the original body. + assert "---\n\n## Final Task Contract" in original_text + + adopt_session( + session_ref=sid, + task_ref="t50", + artifact_paths=[artifact], + discover_fn=lambda: [session], + ) + + updated_text = artifact.read_text() + # The horizontal rule and everything after it must still be present. + assert "---\n\n## Final Task Contract" in updated_text + assert "## Carry Forward" in updated_text + # The session id must have been written into the frontmatter. + assert sid in updated_text + + +# --------------------------------------------------------------------------- +# Public API: scan_artifact_paths, find_artifact_for_ref +# --------------------------------------------------------------------------- + + +def test_scan_artifact_paths_returns_md_files(tmp_path: Path) -> None: + """scan_artifact_paths finds .md files recursively.""" + sub = tmp_path / "sub" + sub.mkdir() + (sub / "a.md").write_text("# a") + (sub / "b.txt").write_text("ignored") + result = scan_artifact_paths(tmp_path) + assert any(p.name == "a.md" for p in result) + assert all(p.suffix == ".md" for p in result) + + +def test_scan_artifact_paths_missing_dir_returns_empty(tmp_path: Path) -> None: + """scan_artifact_paths returns [] when the directory doesn't exist.""" + assert scan_artifact_paths(tmp_path / "nonexistent") == [] + + +def test_find_artifact_for_ref_by_task_id(tmp_path: Path) -> None: + """find_artifact_for_ref resolves a task_id to the correct artifact.""" + artifact = _make_artifact(tmp_path, task_id="t99") + result = find_artifact_for_ref("t99", [artifact]) + assert result == artifact + + +def test_find_artifact_for_ref_by_session_prefix(tmp_path: Path) -> None: + """find_artifact_for_ref resolves a session-id prefix to the linked artifact.""" + sid = "deadbeef-1234-5678-abcd-000000000000" + artifact = _make_artifact(tmp_path, task_id="t98", runtime_session_id=sid) + result = find_artifact_for_ref("deadbeef", [artifact]) + assert result == artifact + + +def test_find_artifact_for_ref_returns_none_when_not_found(tmp_path: Path) -> None: + """find_artifact_for_ref returns None when ref matches nothing.""" + artifact = _make_artifact(tmp_path, task_id="t97") + result = find_artifact_for_ref("t999", [artifact]) + assert result is None diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 995d114..e471a45 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -316,3 +316,221 @@ def test_load_config_malformed_yaml_uses_defaults(tmp_path: Path) -> None: config = load_config(path=config_path) assert config == BachConfig() + + +# --------------------------------------------------------------------------- +# Phase 16 — Session observability config fields +# --------------------------------------------------------------------------- + + +def test_load_config_watch_on_launch_defaults_false(tmp_path: Path) -> None: + """watch_on_launch defaults to False — no watcher spawned unless opted-in.""" + config = load_config(path=tmp_path / "missing.yaml") + assert config.watch_on_launch is False + + +def test_load_config_watch_on_launch_set_true(tmp_path: Path) -> None: + """watch_on_launch: true enables the watcher on launch.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("watch_on_launch: true\n") + config = load_config(path=config_path) + assert config.watch_on_launch is True + + +def test_load_config_watch_on_launch_invalid_falls_back(tmp_path: Path) -> None: + """A non-bool watch_on_launch must not crash — falls back to False.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("watch_on_launch: maybe\n") + config = load_config(path=config_path) + assert config.watch_on_launch is False + + +def test_load_config_observer_moves_defaults_true(tmp_path: Path) -> None: + """observer_moves defaults to True — observer applies status changes.""" + config = load_config(path=tmp_path / "missing.yaml") + assert config.observer_moves is True + + +def test_load_config_observer_moves_set_false(tmp_path: Path) -> None: + """observer_moves: false → judge is log-only, no status mutations.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("observer_moves: false\n") + config = load_config(path=config_path) + assert config.observer_moves is False + + +def test_load_config_observer_moves_invalid_falls_back(tmp_path: Path) -> None: + """A non-bool observer_moves falls back to default True.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("observer_moves: 1\n") + config = load_config(path=config_path) + assert config.observer_moves is True + + +def test_load_config_judge_confidence_threshold_default(tmp_path: Path) -> None: + """judge_confidence_threshold defaults to 0.7.""" + config = load_config(path=tmp_path / "missing.yaml") + assert config.judge_confidence_threshold == 0.7 + + +def test_load_config_judge_confidence_threshold_override(tmp_path: Path) -> None: + """A float override is accepted.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("judge_confidence_threshold: 0.9\n") + config = load_config(path=config_path) + assert config.judge_confidence_threshold == 0.9 + + +def test_load_config_judge_confidence_threshold_out_of_range_falls_back(tmp_path: Path) -> None: + """A threshold outside [0.0, 1.0] must fall back to 0.7 with a warning. + + Accepting out-of-range values would silently disable or over-trigger + the judge — the lenient fallback keeps the CLI running while logging + the misconfiguration. + """ + config_path = tmp_path / "config.yaml" + config_path.write_text("judge_confidence_threshold: 1.5\n") + config = load_config(path=config_path) + assert config.judge_confidence_threshold == 0.7 + + +def test_load_config_judge_confidence_threshold_invalid_type_falls_back(tmp_path: Path) -> None: + """A non-numeric threshold falls back to 0.7.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("judge_confidence_threshold: high\n") + config = load_config(path=config_path) + assert config.judge_confidence_threshold == 0.7 + + +def test_load_config_judge_interval_seconds_default(tmp_path: Path) -> None: + """judge_interval_seconds defaults to 300 (5 minutes between mid-session calls).""" + config = load_config(path=tmp_path / "missing.yaml") + assert config.judge_interval_seconds == 300 + + +def test_load_config_judge_interval_seconds_override(tmp_path: Path) -> None: + """A positive-int override is accepted.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("judge_interval_seconds: 120\n") + config = load_config(path=config_path) + assert config.judge_interval_seconds == 120 + + +def test_load_config_judge_interval_seconds_invalid_falls_back(tmp_path: Path) -> None: + """A non-positive or non-int value falls back to 300.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("judge_interval_seconds: -10\n") + config = load_config(path=config_path) + assert config.judge_interval_seconds == 300 + + +def test_load_config_liveness_running_seconds_default(tmp_path: Path) -> None: + """liveness_running_seconds defaults to 60.""" + config = load_config(path=tmp_path / "missing.yaml") + assert config.liveness_running_seconds == 60 + + +def test_load_config_liveness_running_seconds_override(tmp_path: Path) -> None: + """A positive-int override is accepted.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("liveness_running_seconds: 30\n") + config = load_config(path=config_path) + assert config.liveness_running_seconds == 30 + + +def test_load_config_liveness_running_seconds_invalid_falls_back(tmp_path: Path) -> None: + """A non-positive value falls back to 60.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("liveness_running_seconds: 0\n") + config = load_config(path=config_path) + assert config.liveness_running_seconds == 60 + + +def test_load_config_liveness_idle_minutes_default(tmp_path: Path) -> None: + """liveness_idle_minutes defaults to 5.""" + config = load_config(path=tmp_path / "missing.yaml") + assert config.liveness_idle_minutes == 5 + + +def test_load_config_liveness_idle_minutes_override(tmp_path: Path) -> None: + """A positive-int override is accepted.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("liveness_idle_minutes: 10\n") + config = load_config(path=config_path) + assert config.liveness_idle_minutes == 10 + + +def test_load_config_liveness_idle_minutes_invalid_falls_back(tmp_path: Path) -> None: + """A string value falls back to 5.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("liveness_idle_minutes: five\n") + config = load_config(path=config_path) + assert config.liveness_idle_minutes == 5 + + +def test_load_config_afk_max_turns_default(tmp_path: Path) -> None: + """afk_max_turns defaults to 10.""" + config = load_config(path=tmp_path / "missing.yaml") + assert config.afk_max_turns == 10 + + +def test_load_config_afk_max_turns_override(tmp_path: Path) -> None: + """A positive-int override is accepted.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("afk_max_turns: 5\n") + config = load_config(path=config_path) + assert config.afk_max_turns == 5 + + +def test_load_config_afk_max_turns_invalid_falls_back(tmp_path: Path) -> None: + """A non-positive value falls back to 10.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("afk_max_turns: 0\n") + config = load_config(path=config_path) + assert config.afk_max_turns == 10 + + +def test_load_config_afk_time_budget_minutes_default(tmp_path: Path) -> None: + """afk_time_budget_minutes defaults to 60.""" + config = load_config(path=tmp_path / "missing.yaml") + assert config.afk_time_budget_minutes == 60 + + +def test_load_config_afk_time_budget_minutes_override(tmp_path: Path) -> None: + """A positive-int override is accepted.""" + config_path = tmp_path / "config.yaml" + config_path.write_text("afk_time_budget_minutes: 90\n") + config = load_config(path=config_path) + assert config.afk_time_budget_minutes == 90 + + +def test_load_config_afk_time_budget_minutes_invalid_falls_back(tmp_path: Path) -> None: + """A bool value falls back to 60 (bool is a subclass of int — reject it).""" + config_path = tmp_path / "config.yaml" + config_path.write_text("afk_time_budget_minutes: true\n") + config = load_config(path=config_path) + assert config.afk_time_budget_minutes == 60 + + +def test_load_config_all_new_fields_in_one_file(tmp_path: Path) -> None: + """All 8 new Phase 16 fields can be read from a single config file.""" + config_path = tmp_path / "config.yaml" + config_path.write_text( + "watch_on_launch: true\n" + "observer_moves: false\n" + "judge_confidence_threshold: 0.8\n" + "judge_interval_seconds: 180\n" + "liveness_running_seconds: 45\n" + "liveness_idle_minutes: 8\n" + "afk_max_turns: 7\n" + "afk_time_budget_minutes: 120\n" + ) + config = load_config(path=config_path) + assert config.watch_on_launch is True + assert config.observer_moves is False + assert config.judge_confidence_threshold == 0.8 + assert config.judge_interval_seconds == 180 + assert config.liveness_running_seconds == 45 + assert config.liveness_idle_minutes == 8 + assert config.afk_max_turns == 7 + assert config.afk_time_budget_minutes == 120 diff --git a/tests/unit/test_status_service.py b/tests/unit/test_status_service.py index 4a81d45..83041b0 100644 --- a/tests/unit/test_status_service.py +++ b/tests/unit/test_status_service.py @@ -10,6 +10,7 @@ import yaml from bach.services.status_service import ( + OBSERVER_SOURCE, SUGGESTED_NEXT_STEPS, VALID_STATUSES, InvalidStatusError, @@ -48,18 +49,20 @@ def _write_minimal_artifact(tmp_path: Path, initial_status: str = "grilling") -> }, "skill": {"name": "grill-me", "status": "not_started"}, "task": { - "title": "T", "original_description": "T", - "objective": None, "non_goals": [], "constraints": [], - "likely_files_or_areas": [], "acceptance_checks": [], + "title": "T", + "original_description": "T", + "objective": None, + "non_goals": [], + "constraints": [], + "likely_files_or_areas": [], + "acceptance_checks": [], "risks_or_unknowns": [], }, "post_grill": {"readiness": "not_ready", "next_action": "undecided"}, "log": [{"timestamp": "2026-05-24T00:00:00+00:00", "event": "created"}], } body = "\n## Grill Transcript / Notes\n\n## Final Task Contract\n\n## Carry Forward\n" - artifact.write_text( - f"---\n{yaml.safe_dump(payload, sort_keys=False)}---\n{body}" - ) + artifact.write_text(f"---\n{yaml.safe_dump(payload, sort_keys=False)}---\n{body}") return artifact @@ -75,11 +78,21 @@ def test_valid_statuses_matches_canonical_set() -> None: migrated at read time (see storage/artifacts._STATUS_MIGRATION) and are NOT valid as writable values anymore. """ - assert VALID_STATUSES == frozenset({ - "inbox", "research", "prototype", "prd", - "kanban_issues", "afk_queue", "hitl_queue", - "executing", "qa", "done", "carried_forward", - }) + assert VALID_STATUSES == frozenset( + { + "inbox", + "research", + "prototype", + "prd", + "kanban_issues", + "afk_queue", + "hitl_queue", + "executing", + "qa", + "done", + "carried_forward", + } + ) # --------------------------------------------------------------------------- @@ -132,6 +145,7 @@ def test_set_task_status_noop_when_already_target(tmp_path: Path) -> None: artifact = _write_minimal_artifact(tmp_path, initial_status="hitl_queue") before_mtime = artifact.stat().st_mtime import time + time.sleep(0.01) # ensure mtime would differ if we wrote result = set_task_status(artifact, "hitl_queue", source="hook") assert result.old == "hitl_queue" @@ -194,8 +208,7 @@ def test_suggested_next_steps_has_entry_for_every_status() -> None: """ for status in VALID_STATUSES: assert status in SUGGESTED_NEXT_STEPS, ( - f"Status {status!r} has no SUGGESTED_NEXT_STEPS entry. " - f"Add one in status_service.py." + f"Status {status!r} has no SUGGESTED_NEXT_STEPS entry. Add one in status_service.py." ) @@ -237,3 +250,144 @@ def test_set_task_status_sees_migrated_status_from_legacy_artifact( # migration — so .old should be the MIGRATED value, not "grilling". result = set_task_status(artifact, "done", source="repl") assert result.old == "prd" # migrated, not the on-disk "grilling" + + +# --------------------------------------------------------------------------- +# Observer authority — Phase 16 (PRD bach#3) +# +# The observer source (automated watchers/judge) can only move tasks in two +# safe directions: executing→qa (hand-off signal) and any of +# {executing,qa,afk_queue}→hitl_queue (escalation to human). All other moves +# are forbidden because observers parse transcripts, which are observation +# plane only — they must never gate or advance the workflow unilaterally. +# --------------------------------------------------------------------------- + + +def test_observer_source_constant_value() -> None: + """OBSERVER_SOURCE is the sentinel string callers must pass literally. + + Pinned so a refactor that renames the constant breaks loudly here + rather than silently in production where observers could gain authority. + """ + assert OBSERVER_SOURCE == "observer" + + +def test_observer_allowed_executing_to_qa(tmp_path: Path) -> None: + """Observer MAY move executing→qa (session-complete hand-off).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + result = set_task_status(artifact, "qa", source=OBSERVER_SOURCE) + assert result.old == "executing" + assert result.new == "qa" + + +def test_observer_allowed_executing_to_hitl_queue(tmp_path: Path) -> None: + """Observer MAY escalate executing→hitl_queue (needs-human signal).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + result = set_task_status(artifact, "hitl_queue", source=OBSERVER_SOURCE) + assert result.new == "hitl_queue" + + +def test_observer_allowed_qa_to_hitl_queue(tmp_path: Path) -> None: + """Observer MAY escalate qa→hitl_queue (QA surfaced a human-judgment block).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="qa") + result = set_task_status(artifact, "hitl_queue", source=OBSERVER_SOURCE) + assert result.new == "hitl_queue" + + +def test_observer_allowed_afk_queue_to_hitl_queue(tmp_path: Path) -> None: + """Observer MAY escalate afk_queue→hitl_queue (AFK session stalled).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="afk_queue") + result = set_task_status(artifact, "hitl_queue", source=OBSERVER_SOURCE) + assert result.new == "hitl_queue" + + +def test_observer_forbidden_done(tmp_path: Path) -> None: + """Observer must NEVER set done — only a human may declare completion. + + This is the most important prohibition: automated observers cannot declare + work finished. done requires human approval. + """ + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + with pytest.raises(InvalidStatusError): + set_task_status(artifact, "done", source=OBSERVER_SOURCE) + # Artifact status must remain unchanged. + payload = yaml.safe_load(artifact.read_text().split("---")[1]) + assert payload["status"] == "executing" + + +def test_observer_forbidden_backward_qa_to_executing(tmp_path: Path) -> None: + """Observer must not move backward (qa→executing) — would re-open closed work.""" + artifact = _write_minimal_artifact(tmp_path, initial_status="qa") + with pytest.raises(InvalidStatusError): + set_task_status(artifact, "executing", source=OBSERVER_SOURCE) + + +def test_observer_forbidden_pre_execution_target_inbox(tmp_path: Path) -> None: + """Observer must not touch pre-execution stages (inbox).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + with pytest.raises(InvalidStatusError): + set_task_status(artifact, "inbox", source=OBSERVER_SOURCE) + + +def test_observer_forbidden_pre_execution_target_prd(tmp_path: Path) -> None: + """Observer must not touch pre-execution stages (prd).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + with pytest.raises(InvalidStatusError): + set_task_status(artifact, "prd", source=OBSERVER_SOURCE) + + +def test_observer_forbidden_pre_execution_target_research(tmp_path: Path) -> None: + """Observer must not touch pre-execution stages (research).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + with pytest.raises(InvalidStatusError): + set_task_status(artifact, "research", source=OBSERVER_SOURCE) + + +def test_observer_forbidden_pre_execution_target_prototype(tmp_path: Path) -> None: + """Observer must not touch pre-execution stages (prototype).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + with pytest.raises(InvalidStatusError): + set_task_status(artifact, "prototype", source=OBSERVER_SOURCE) + + +def test_observer_forbidden_pre_execution_target_kanban_issues(tmp_path: Path) -> None: + """Observer must not touch pre-execution stages (kanban_issues).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + with pytest.raises(InvalidStatusError): + set_task_status(artifact, "kanban_issues", source=OBSERVER_SOURCE) + + +def test_observer_forbidden_carried_forward(tmp_path: Path) -> None: + """Observer must not set carried_forward — manual triage decision only.""" + artifact = _write_minimal_artifact(tmp_path, initial_status="qa") + with pytest.raises(InvalidStatusError): + set_task_status(artifact, "carried_forward", source=OBSERVER_SOURCE) + + +def test_observer_error_message_is_informative(tmp_path: Path) -> None: + """InvalidStatusError from observer path names both the forbidden move and the source. + + Good error messages let operators quickly understand WHY a move was rejected + without having to read source code. + """ + artifact = _write_minimal_artifact(tmp_path, initial_status="executing") + with pytest.raises(InvalidStatusError, match="observer"): + set_task_status(artifact, "done", source=OBSERVER_SOURCE) + + +def test_human_source_unrestricted_to_done(tmp_path: Path) -> None: + """Non-observer sources retain full transition authority (including done). + + The observer restriction must be scoped only to source=OBSERVER_SOURCE. + Any other source string — repl, hook, cli, afk — must still reach done. + """ + artifact = _write_minimal_artifact(tmp_path, initial_status="qa") + result = set_task_status(artifact, "done", source="repl") + assert result.new == "done" + + +def test_human_source_unrestricted_backward(tmp_path: Path) -> None: + """Non-observer sources may move backward (manual correction scenario).""" + artifact = _write_minimal_artifact(tmp_path, initial_status="qa") + result = set_task_status(artifact, "inbox", source="repl") + assert result.new == "inbox" diff --git a/tests/unit/test_transcript.py b/tests/unit/test_transcript.py new file mode 100644 index 0000000..f972d1f --- /dev/null +++ b/tests/unit/test_transcript.py @@ -0,0 +1,587 @@ +"""Tests for runtimes/transcript.py — transcript line parsing. + +Strategy: + - Each parse_line test uses a single realistic fixture line (or a + malformed variant) to verify one mapping from raw record type to + SessionEventKind. + - read_tail_events and follow_events tests use tmp_path to write real + JSONL files so no patching is needed for the file path. + - follow_events is tested with a stop callable that fires after a + fixed number of polls, avoiding any real wall-clock sleep. + +Coverage goals per the spec (dag.md § C2 contract): + - Claude JSONL: user / assistant (with content blocks incl tool_use) / + system / attachment / queue-operation / ai-title / unknown types + - Codex rollout: session_meta / turn_context / response_item / + event_msg sub-types (task_started, agent_message, agent_reasoning, + task_complete, token_count) + - Malformed JSON lines → kind=unknown, raw_kind preserved, no raise + - Truncated lines (valid JSON up to a mid-string) → same safety + - Unknown record types → kind=unknown, raw_kind preserved + - Text truncation at ~200 chars + - Timestamp extraction where present, None where absent +""" + +import json +import time +from collections.abc import Iterator +from datetime import datetime +from pathlib import Path +from typing import Any + +from bach.domain.models import AgentRuntime, SessionEvent, SessionEventKind +from bach.runtimes.transcript import follow_events, parse_line, read_tail_events + +# --------------------------------------------------------------------------- +# Helpers: realistic fixture line builders +# --------------------------------------------------------------------------- + +_CLAUDE_TS = "2026-01-15T10:30:00.000Z" +_CODEX_TS = "2026-01-15T10:30:00.000Z" + + +def _claude_line(record_type: str, content: dict[str, Any] | None = None) -> str: + """Build a realistic Claude JSONL line for the given record type.""" + base: dict[str, Any] = {"type": record_type, "timestamp": _CLAUDE_TS} + if content: + base.update(content) + return json.dumps(base) + + +def _codex_line(record_type: str, payload: dict[str, Any] | None = None) -> str: + """Build a realistic Codex rollout JSONL line for the given record type. + + Codex rollout format wraps the payload inside a 'payload' key at the + top level: {"type": "...", "timestamp": "...", "payload": {...}}. + The 'type' discriminator maps to SessionEventKind. + """ + base: dict[str, Any] = {"type": record_type, "timestamp": _CODEX_TS, "payload": payload or {}} + return json.dumps(base) + + +# --------------------------------------------------------------------------- +# parse_line — Claude JSONL record types +# --------------------------------------------------------------------------- + + +def test_claude_user_maps_to_user_turn() -> None: + """Claude 'user' record type → kind=user_turn.""" + line = _claude_line("user", {"message": {"role": "user", "content": "hello"}}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.user_turn + assert event.raw_kind == "user" + + +def test_claude_assistant_maps_to_assistant_turn() -> None: + """Claude 'assistant' record type → kind=assistant_turn.""" + line = _claude_line( + "assistant", + {"message": {"role": "assistant", "content": [{"type": "text", "text": "I will help."}]}}, + ) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.assistant_turn + assert event.raw_kind == "assistant" + + +def test_claude_assistant_with_tool_use_maps_to_tool_call() -> None: + """Claude 'assistant' record that contains tool_use content block → kind=tool_call. + + Claude embeds tool calls as content blocks inside the 'assistant' + record type — we detect the presence of tool_use blocks to upgrade + the kind from assistant_turn to tool_call. + """ + line = _claude_line( + "assistant", + { + "message": { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "abc", "name": "Read", "input": {"file": "/x"}} + ], + } + }, + ) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.tool_call + assert event.raw_kind == "assistant" + + +def test_claude_tool_result_maps_to_tool_result() -> None: + """Claude 'tool_result' record type → kind=tool_result.""" + line = _claude_line( + "tool_result", + {"content": [{"type": "tool_result", "tool_use_id": "abc", "content": "ok"}]}, + ) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.tool_result + assert event.raw_kind == "tool_result" + + +def test_claude_system_maps_to_session_meta() -> None: + """Claude 'system' record type → kind=session_meta (initial context).""" + line = _claude_line("system", {"system": "You are Claude."}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.session_meta + assert event.raw_kind == "system" + + +def test_claude_ai_title_maps_to_session_meta() -> None: + """Claude 'ai-title' record type → kind=session_meta (title annotation).""" + line = _claude_line("ai-title", {"title": "My coding session"}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.session_meta + assert event.raw_kind == "ai-title" + + +def test_claude_attachment_maps_to_unknown() -> None: + """Claude 'attachment' record type → kind=unknown (not mapped to a standard kind).""" + line = _claude_line("attachment", {"file": "/path/to/file.py"}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.unknown + assert event.raw_kind == "attachment" + + +def test_claude_queue_operation_maps_to_unknown() -> None: + """Claude 'queue-operation' record type → kind=unknown.""" + line = _claude_line("queue-operation", {"op": "enqueue", "task_id": "t99"}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.unknown + assert event.raw_kind == "queue-operation" + + +def test_claude_unknown_record_type_maps_to_unknown() -> None: + """Any unrecognized Claude record type → kind=unknown, raw_kind preserved.""" + line = _claude_line("some-future-type", {"extra": "data"}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.unknown + assert event.raw_kind == "some-future-type" + + +# --------------------------------------------------------------------------- +# parse_line — Codex rollout record types +# --------------------------------------------------------------------------- + + +def test_codex_session_meta_maps_to_session_meta() -> None: + """Codex 'session_meta' → kind=session_meta.""" + line = _codex_line("session_meta", {"id": "uuid-abc", "model": "gpt-5.4-mini"}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.session_meta + assert event.raw_kind == "session_meta" + + +def test_codex_turn_context_maps_to_user_turn() -> None: + """Codex 'turn_context' → kind=user_turn (the user's input turn).""" + line = _codex_line("turn_context", {"input": "Fix the failing tests"}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.user_turn + assert event.raw_kind == "turn_context" + + +def test_codex_response_item_maps_to_assistant_turn() -> None: + """Codex 'response_item' → kind=assistant_turn.""" + line = _codex_line("response_item", {"output": "I will investigate."}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.assistant_turn + assert event.raw_kind == "response_item" + + +def test_codex_event_msg_task_started_maps_to_session_meta() -> None: + """Codex event_msg with type=task_started → kind=session_meta.""" + line = _codex_line("event_msg", {"type": "task_started", "task_id": "t1"}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.session_meta + assert event.raw_kind == "event_msg" + + +def test_codex_event_msg_agent_message_maps_to_assistant_turn() -> None: + """Codex event_msg with type=agent_message → kind=assistant_turn.""" + line = _codex_line("event_msg", {"type": "agent_message", "message": "Working on it."}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.assistant_turn + assert event.raw_kind == "event_msg" + + +def test_codex_event_msg_agent_reasoning_maps_to_assistant_turn() -> None: + """Codex event_msg with type=agent_reasoning → kind=assistant_turn (chain-of-thought).""" + line = _codex_line("event_msg", {"type": "agent_reasoning", "reasoning": "let me think..."}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.assistant_turn + assert event.raw_kind == "event_msg" + + +def test_codex_event_msg_task_complete_maps_to_end() -> None: + """Codex event_msg with type=task_complete → kind=end.""" + line = _codex_line("event_msg", {"type": "task_complete", "success": True}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.end + assert event.raw_kind == "event_msg" + + +def test_codex_event_msg_token_count_maps_to_unknown() -> None: + """Codex event_msg with type=token_count → kind=unknown (metadata, not a turn).""" + line = _codex_line("event_msg", {"type": "token_count", "input": 512, "output": 128}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.unknown + assert event.raw_kind == "event_msg" + + +def test_codex_unknown_record_type_maps_to_unknown() -> None: + """Any unrecognized Codex record type → kind=unknown, raw_kind preserved.""" + line = _codex_line("some_future_type", {"extra": "value"}) + event = parse_line(line, AgentRuntime.codex) + assert event.kind is SessionEventKind.unknown + assert event.raw_kind == "some_future_type" + + +# --------------------------------------------------------------------------- +# parse_line — malformed and edge cases (NEVER raise) +# --------------------------------------------------------------------------- + + +def test_malformed_json_returns_unknown_never_raises() -> None: + """Completely invalid JSON → kind=unknown, no exception raised. + + The 'never raises' contract is the most important safety property of + this module — a bad transcript line must not kill a watcher or judge. + """ + event = parse_line("not valid json {{{", AgentRuntime.claude_code) + assert event.kind is SessionEventKind.unknown + assert isinstance(event.raw_kind, str) + assert event.timestamp is None + assert event.text is None + + +def test_truncated_json_line_returns_unknown_never_raises() -> None: + """Truncated JSON (valid up to a mid-string cut) → kind=unknown, no raise.""" + truncated = '{"type": "assistant", "timestamp": "2026-01' # cut mid-string + event = parse_line(truncated, AgentRuntime.claude_code) + assert event.kind is SessionEventKind.unknown + assert event.timestamp is None + + +def test_empty_string_returns_unknown_never_raises() -> None: + """Empty input line → kind=unknown, no exception raised.""" + event = parse_line("", AgentRuntime.claude_code) + assert event.kind is SessionEventKind.unknown + + +def test_whitespace_only_line_returns_unknown_never_raises() -> None: + """Whitespace-only line → kind=unknown, no exception raised.""" + event = parse_line(" \t ", AgentRuntime.codex) + assert event.kind is SessionEventKind.unknown + + +def test_json_without_type_field_returns_unknown() -> None: + """Valid JSON but missing 'type' key → kind=unknown, raw_kind=''.""" + event = parse_line(json.dumps({"timestamp": _CLAUDE_TS, "data": "x"}), AgentRuntime.claude_code) + assert event.kind is SessionEventKind.unknown + + +def test_json_array_instead_of_object_returns_unknown() -> None: + """JSON array at top level (not an object) → kind=unknown, no raise.""" + event = parse_line(json.dumps(["a", "b"]), AgentRuntime.claude_code) + assert event.kind is SessionEventKind.unknown + + +def test_malformed_json_for_codex_runtime_returns_unknown() -> None: + """Malformed JSON with codex runtime also → kind=unknown, no raise.""" + event = parse_line("{corrupt", AgentRuntime.codex) + assert event.kind is SessionEventKind.unknown + assert event.timestamp is None + + +# --------------------------------------------------------------------------- +# parse_line — timestamp extraction +# --------------------------------------------------------------------------- + + +def test_timestamp_extracted_from_claude_line() -> None: + """Timestamp field in Claude record → parsed datetime, UTC-aware.""" + line = _claude_line("user", {"message": {"role": "user", "content": "hi"}}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.timestamp is not None + assert isinstance(event.timestamp, datetime) + + +def test_timestamp_absent_yields_none() -> None: + """Record without a 'timestamp' field → event.timestamp is None.""" + line = json.dumps({"type": "user", "message": {"role": "user", "content": "hi"}}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.timestamp is None + + +def test_malformed_timestamp_yields_none_not_raise() -> None: + """Unparseable timestamp string → timestamp=None, no exception raised.""" + line = json.dumps({"type": "user", "timestamp": "not-a-timestamp"}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.timestamp is None + assert event.kind is SessionEventKind.user_turn + + +# --------------------------------------------------------------------------- +# parse_line — text extraction and truncation +# --------------------------------------------------------------------------- + + +def test_text_truncated_to_200_chars() -> None: + """Long text content is truncated to ~200 chars by the parser.""" + long_text = "A" * 500 + line = _claude_line( + "assistant", + {"message": {"role": "assistant", "content": [{"type": "text", "text": long_text}]}}, + ) + event = parse_line(line, AgentRuntime.claude_code) + assert event.text is not None + # Allow a small margin for truncation markers / formatting. + assert len(event.text) <= 220 + + +def test_short_text_not_truncated() -> None: + """Text under 200 chars is preserved as-is.""" + short_text = "Short response." + line = _claude_line( + "assistant", + {"message": {"role": "assistant", "content": [{"type": "text", "text": short_text}]}}, + ) + event = parse_line(line, AgentRuntime.claude_code) + assert event.text is not None + assert short_text in event.text + + +def test_codex_agent_message_text_extracted() -> None: + """Codex agent_message event_msg → text extracted from payload.message.""" + msg = "I have analyzed the failing tests." + line = _codex_line("event_msg", {"type": "agent_message", "message": msg}) + event = parse_line(line, AgentRuntime.codex) + assert event.text is not None + assert msg in event.text + + +def test_user_turn_text_extracted_from_claude() -> None: + """Claude user turn → text extracted from message content.""" + line = _claude_line("user", {"message": {"role": "user", "content": "What is the plan?"}}) + event = parse_line(line, AgentRuntime.claude_code) + assert event.text is not None + assert "What is the plan?" in event.text + + +# --------------------------------------------------------------------------- +# read_tail_events — file-based tests using tmp_path +# --------------------------------------------------------------------------- + + +def test_read_tail_events_returns_last_n_events(tmp_path: Path) -> None: + """read_tail_events with max_events=3 returns at most 3 events from the tail.""" + transcript = tmp_path / "session.jsonl" + a_block = [{"type": "text", "text": "a1"}] + a_block2 = [{"type": "text", "text": "a2"}] + lines = [ + _claude_line("system"), + _claude_line("user", {"message": {"role": "user", "content": "q1"}}), + _claude_line("assistant", {"message": {"role": "assistant", "content": a_block}}), + _claude_line("user", {"message": {"role": "user", "content": "q2"}}), + _claude_line("assistant", {"message": {"role": "assistant", "content": a_block2}}), + ] + transcript.write_text("\n".join(lines) + "\n") + + events = read_tail_events(transcript, AgentRuntime.claude_code, max_events=3) + assert len(events) == 3 + # Tail = last 3 lines (two assistant turns and one user turn). + assert events[-1].kind is SessionEventKind.assistant_turn + + +def test_read_tail_events_skips_blank_lines(tmp_path: Path) -> None: + """Blank lines in a JSONL file are silently skipped.""" + transcript = tmp_path / "session.jsonl" + asst_block = [{"type": "text", "text": "yo"}] + transcript.write_text( + _claude_line("user", {"message": {"role": "user", "content": "hi"}}) + + "\n\n" + + _claude_line("assistant", {"message": {"role": "assistant", "content": asst_block}}) + + "\n" + ) + + events = read_tail_events(transcript, AgentRuntime.claude_code, max_events=50) + # 2 real lines + 1 blank → 2 events only + assert len(events) == 2 + + +def test_read_tail_events_handles_malformed_lines(tmp_path: Path) -> None: + """Malformed lines in a transcript produce kind=unknown events, not raises.""" + transcript = tmp_path / "session.jsonl" + resp_block = [{"type": "text", "text": "resp"}] + transcript.write_text( + _claude_line("user", {"message": {"role": "user", "content": "ok"}}) + + "\n" + + "this is not json\n" + + _claude_line("assistant", {"message": {"role": "assistant", "content": resp_block}}) + + "\n" + ) + + events = read_tail_events(transcript, AgentRuntime.claude_code, max_events=50) + assert len(events) == 3 + kinds = [e.kind for e in events] + assert SessionEventKind.unknown in kinds + + +def test_read_tail_events_empty_file(tmp_path: Path) -> None: + """Empty transcript file → returns empty list, no raise.""" + transcript = tmp_path / "empty.jsonl" + transcript.write_text("") + events = read_tail_events(transcript, AgentRuntime.claude_code) + assert events == [] + + +def test_read_tail_events_missing_file(tmp_path: Path) -> None: + """Missing transcript file → returns empty list, no raise.""" + events = read_tail_events(tmp_path / "nonexistent.jsonl", AgentRuntime.codex) + assert events == [] + + +def test_read_tail_events_default_max_events(tmp_path: Path) -> None: + """Default max_events=50 limits the result to at most 50 events.""" + transcript = tmp_path / "big.jsonl" + lines = [ + _claude_line("user", {"message": {"role": "user", "content": f"msg {i}"}}) + for i in range(100) + ] + transcript.write_text("\n".join(lines) + "\n") + + events = read_tail_events(transcript, AgentRuntime.claude_code) + assert len(events) == 50 + + +def test_read_tail_events_codex_format(tmp_path: Path) -> None: + """read_tail_events correctly dispatches to Codex parsing for codex transcripts.""" + transcript = tmp_path / "codex.jsonl" + lines = [ + _codex_line("session_meta", {"id": "x"}), + _codex_line("turn_context", {"input": "do something"}), + _codex_line("event_msg", {"type": "agent_message", "message": "done"}), + ] + transcript.write_text("\n".join(lines) + "\n") + + events = read_tail_events(transcript, AgentRuntime.codex, max_events=50) + assert len(events) == 3 + assert events[0].kind is SessionEventKind.session_meta + assert events[1].kind is SessionEventKind.user_turn + assert events[2].kind is SessionEventKind.assistant_turn + + +# --------------------------------------------------------------------------- +# follow_events — file tail-follow tests using tmp_path +# --------------------------------------------------------------------------- + + +def test_follow_events_yields_existing_lines_then_stops(tmp_path: Path) -> None: + """follow_events yields events for lines already in the file and respects should_stop.""" + transcript = tmp_path / "session.jsonl" + transcript.write_text( + _claude_line("user", {"message": {"role": "user", "content": "start"}}) + "\n" + ) + + calls: list[int] = [0] + + def should_stop() -> bool: + calls[0] += 1 + # Stop after we've been called a few times (allows initial read to finish) + return calls[0] > 5 + + events = list( + follow_events( + transcript, AgentRuntime.claude_code, poll_seconds=0.01, should_stop=should_stop + ) + ) + # At least the initial line should have been yielded + assert any(e.kind is SessionEventKind.user_turn for e in events) + + +def test_follow_events_stops_when_should_stop_true(tmp_path: Path) -> None: + """follow_events terminates when should_stop() returns True from the start.""" + transcript = tmp_path / "session.jsonl" + transcript.write_text( + _claude_line("user", {"message": {"role": "user", "content": "hi"}}) + "\n" + ) + + # Always stop immediately + events = list( + follow_events( + transcript, AgentRuntime.claude_code, poll_seconds=0.01, should_stop=lambda: True + ) + ) + # Nothing yielded (or very few) because should_stop is immediately True + # The contract is that we check should_stop; exact yield count is impl detail. + # Main thing: it does NOT block forever. + assert isinstance(events, list) + + +def test_follow_events_picks_up_new_lines_appended(tmp_path: Path) -> None: + """follow_events detects lines appended to the file mid-follow. + + We append a new line from a separate write after a short delay and + verify the iterator yields the new event. Uses a counter to avoid + infinite follow. + """ + transcript = tmp_path / "session.jsonl" + transcript.write_text("") # start empty + + emitted: list[SessionEvent] = [] + poll_count: list[int] = [0] + + def should_stop() -> bool: + poll_count[0] += 1 + # Stop once we have at least one event or after many polls + return len(emitted) >= 1 or poll_count[0] > 20 + + def _write_line() -> None: + """Append a line to the transcript after a tiny delay.""" + time.sleep(0.05) + new_block = [{"type": "text", "text": "new event"}] + with transcript.open("a") as f: + f.write( + _claude_line("assistant", {"message": {"role": "assistant", "content": new_block}}) + + "\n" + ) + + import threading + + writer = threading.Thread(target=_write_line, daemon=True) + writer.start() + + for event in follow_events( + transcript, AgentRuntime.claude_code, poll_seconds=0.02, should_stop=should_stop + ): + emitted.append(event) + + writer.join(timeout=2.0) + assert any(e.kind is SessionEventKind.assistant_turn for e in emitted) + + +def test_follow_events_missing_file_stops_gracefully(tmp_path: Path) -> None: + """follow_events on a missing file stops via should_stop, does not raise.""" + calls: list[int] = [0] + + def should_stop() -> bool: + calls[0] += 1 + return calls[0] > 3 + + events = list( + follow_events( + tmp_path / "gone.jsonl", AgentRuntime.codex, poll_seconds=0.01, should_stop=should_stop + ) + ) + assert isinstance(events, list) + + +def test_follow_events_returns_iterator() -> None: + """follow_events returns an Iterator (generator) type, not a list.""" + from pathlib import Path as _Path + + result = follow_events( + _Path("/nonexistent.jsonl"), + AgentRuntime.claude_code, + poll_seconds=0.01, + should_stop=lambda: True, + ) + assert isinstance(result, Iterator)