* refactor(observe): extract shared observer skeleton Move the observer-pattern-general pieces of the SCM observer into a new backend/internal/observe package so the tracker observer (issue #35) can build on the same primitives: - StartPollLoop: goroutine supervisor with immediate-first-poll + ticker + ctx-done exit. SCM Observer.Start now delegates to it. - CheckCredentialsOnce: lazy first-poll credential gate driven by a CredentialProbe closure. SCM observer keeps credentialsChecked/disabled as Observer fields; the shared helper mutates them via pointer so state ownership stays single-source. - CacheSet[V any] / CacheDelete[V any]: one generic bounded-FIFO helper replaces the three near-identical cacheSet{String,Time,Bool} bodies and the standalone evictStrings. The SCM-side methods are now one-line wrappers that thread o.Cache.max into the shared helper, so existing call sites and tests are untouched. SCM behavior is unchanged. The full 21-test SCM suite (including the end-to-end test added in PR #115) plus 577 backend tests stay green under `go test -race`. Part of #112. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(tracker): ports.TrackerObservation DTO + ApplyTrackerFacts reducer Land the contract that the future Tracker observer (issue #35) and its provider adapters must satisfy. No observer is wired in this PR — the DTO + reducer are the deliverable, and locking the shape now lets the observer + adapter work happen in small follow-up PRs. DTO (backend/internal/ports/tracker_observations.go): - TrackerObservation mirrors ports.SCMObservation: Fetched bool, ObservedAt time.Time, Provider/Host/Repo, normalized Issue facts, Comments, and a Changed{State, Assignee, Comments} discriminator. - TrackerIssueObservation carries the minimal facts lifecycle needs today (state, assignee, title, body, timestamps); richer per-provider metadata stays inside each adapter. - TrackerCommentObservation carries the comment fields needed for the bot-mention nudge (Author, Body, IsBot, ID for dedup). Reducer (backend/internal/lifecycle/reactions.go): - ApplyTrackerFacts(ctx, sessionID, ports.TrackerObservation) error, mirroring ApplySCMObservation's "Fetched gate → terminal-state → per-bucket reactions" shape. - Three initial reactions: * Issue state == done | cancelled → MarkTerminated (idempotent). * Changed.Assignee → log only via slog.Default(). The "assignee changed away from AO" policy is reserved for #40. * Changed.Comments with bot comments → one-time nudge with strings.Join'd bot bodies, deduped by comment IDs. - The nudge path reuses sendOnce with an empty prURL so the in-memory dedup applies but the PR-row persistence path is skipped. Tracker signature persistence will land with #35 alongside issue-row storage. Tests in backend/internal/lifecycle/manager_test.go cover each branch: terminate (done + cancelled), log-only assignee, nudge fires on new bot comment, nudge suppressed on repeat, new bot comment id refires, not-fetched is no-op, terminated session ignores observations. Part of #112. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(observe): rename CacheSet param to avoid shadowing built-in max golangci-lint revive flagged the CacheSet generic helper's max parameter as shadowing the built-in max() function. Rename to maxEntries; signature change is internal to the observe package and the SCM observer's one-line wrappers pass the value positionally, so no call sites need updating. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: honour disabled state in CheckCredentialsOnce + tighten bot-comment filter Two P1 review findings on #116: 1. observe.CheckCredentialsOnce was returning (true, nil) on every call after the gate ran, even when the probe had marked the observer disabled, because the *checked short-circuit ignored *disabled. The SCM observer didn't surface this in practice — its Poll method has an independent `if o.disabled { return nil }` guard that runs first — but a future Tracker observer that relies on the helper's documented contract ("Observer stays disabled") would silently flip back to "credentials available" after the first poll. Change the short-circuit to `return !*disabled, nil` and lock the behavior with a regression test that issues repeat calls after the probe reported unavailable. 2. lifecycle.newBotCommentContent's "skip uninteresting comments" filter used && where it needed ||. A bot comment with an empty ID but a non-empty body slipped through and appended "" to the ids slice. If every bot comment in the observation had an empty ID, strings.Join produced "" — which matches the zero value of the in-memory dedup map, so sendOnce treated the nudge as already-sent and silently suppressed it forever. Switch to || so any comment missing either an ID or a body is dropped, and add a regression test that an empty-ID bot comment never nudges (and does not pollute the dedup state for a follow-up comment that has a real ID). 586 tests pass with -race. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(observe): capture deadline once in poll-error spin-wait The `TestStartPollLoop_LogsPollErrorWithoutPanic` spin-wait was computing the loop bound as `time.Now().Before(time.Now().Add(200ms))` on every iteration, which is permanently true — the loop could only exit via the `break`. Under a scheduler delay (heavy CI load or `GOMAXPROCS=1`) where two polls never land in time, the test would hang until the wall-clock kill rather than failing fast. Capture the deadline once before the loop, and tighten the assertion to actually require two polls + done-channel closure within a bounded window, matching `TestStartPollLoop_FirstPollImmediateThenTicks`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|---|---|---|
| .github/workflows | ||
| backend | ||
| docs | ||
| frontend | ||
| test/cli | ||
| .envrc | ||
| .gitignore | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| README.md | ||
| flake.lock | ||
| flake.nix | ||
| package-lock.json | ||
| package.json | ||
README.md
ReverbCode
A Go-backed agent orchestration daemon for supervising parallel coding-agent
sessions, with an ao CLI today and an Electron supervisor planned. The Go
module and packages remain agent-orchestrator; "ReverbCode" is the public name.
See docs/architecture.md for the backend mental model
and AGENTS.md for the contributor / worker contract.
What's shipped today
- Loopback-only HTTP daemon (
backend/internal/httpd) controlling projects,
sessions, orchestrators, and hook callbacks over127.0.0.1. aoCobra CLI (backend/cmd/ao) — a thin client over the daemon for daemon
control, project/session/orchestrator management, and worker spawning.- Worker/orchestrator spawn into isolated
git worktreeworkspaces, launched
inside azellijruntime adapter. - Live per-PR observation via the provider-neutral SCM observer
(backend/internal/observe/scm/): polling loop, ETag guards, semantic
diffing, CI/check/review-thread tracking, and lifecycle nudges for CI
failures, review feedback, and merge conflicts. - Agent adapters under
backend/internal/adapters/agent/:claude-code,
codex,opencode,grok,cursor,qwen,copilot,kimi, plus
shared activity-dispatch / hook utilities. - SQLite store (
backend/internal/storage/sqlite/) with sqlc-generated
queries, DB-triggered change-data-capture intochange_log, and a CDC
poller/broadcaster (backend/internal/cdc/) feeding in-process subscribers
and an SSE replay endpoint. - Session lifecycle manager + reaper (
backend/internal/lifecycle/,
backend/internal/observe/reaper/): runtime/activity/PR facts reduced into
the small durable session state, display status derived at read time.
Quick start
Requirements: Go 1.25+, zellij on PATH for the
runtime adapter, and gh (or GITHUB_TOKEN) if you want the SCM observer to
authenticate against GitHub. The SQLite driver is the pure-Go
modernc.org/sqlite — no system SQLite library is required.
cd backend
go build -o /tmp/ao ./cmd/ao
# Start the daemon and wait for /readyz.
/tmp/ao start
# Register a local git repo as a project. The id defaults to the lowercased
# base of --path; pass --id explicitly when the directory name doesn't match.
/tmp/ao project add --path /path/to/your/repo --id your-repo --name your-repo
# Spawn a worker session running the default agent.
/tmp/ao spawn --project your-repo --prompt "Refactor the auth module"
# Inspect what's running.
/tmp/ao status
/tmp/ao session ls
CLI surface
The CLI is intentionally thin: every product command resolves to a daemon HTTP
route. Run ao <command> --help for the authoritative flag shape; the table
below groups what's on main today.
| Lane | Command | Purpose |
|---|---|---|
| Daemon | ao start |
Start the daemon in the background and wait for /readyz. |
| Daemon | ao stop |
Graceful shutdown via loopback POST /shutdown. |
| Daemon | ao status |
Report PID/port/health/readiness from running.json. |
| Daemon | ao daemon |
Hidden internal entrypoint used by ao start. |
| Project | ao project add |
Register a local git repo as a project. |
| Project | ao project ls |
List registered projects. |
| Project | ao project get <id> |
Fetch one project. |
| Project | ao project rm <id> |
Remove a project. |
| Session | ao spawn |
Spawn a worker session in a registered project. |
| Session | ao session ls |
List sessions (filter by project, include terminated). |
| Session | ao session get <id> |
Fetch one session. |
| Session | ao session kill <id> |
Terminate a session. |
| Session | ao session rename <id> <name> |
Rename a session. |
| Session | ao session restore <id> |
Relaunch a terminated session. |
| Session | ao session cleanup |
Reclaim eligible workspaces for terminated sessions. |
| Session | ao session claim-pr <session> <pr> |
Attach an existing PR to a session. |
| Orchestrator | ao orchestrator ls |
List orchestrator sessions. |
| Messaging | ao send |
Send a message to a running agent session. |
| Utility | ao doctor |
Local health checks (config, data dir, DB, git, zellij). |
| Utility | ao completion <shell> |
Generate bash/zsh/fish/powershell completions. |
| Utility | ao version |
Print build metadata. |
| Internal | ao hooks <agent> <event> |
Hidden adapter hook callback. |
See docs/cli/ for the daemon-control intent and command shape.
Configuration
All configuration is env-driven; the daemon takes no config file. The bind
host is hard-coded to 127.0.0.1 — the daemon has no auth, CORS, or TLS, and
exposing it beyond loopback would be a security regression.
| Var | Default | Purpose |
|---|---|---|
AO_PORT |
3001 |
Bind port; daemon fails fast if taken. |
AO_REQUEST_TIMEOUT |
60s |
Per-request timeout (Go duration). |
AO_SHUTDOWN_TIMEOUT |
10s |
Graceful-shutdown hard cap. |
AO_RUN_FILE |
<UserConfigDir>/agent-orchestrator/running.json |
PID + port handshake path. |
AO_DATA_DIR |
<UserConfigDir>/agent-orchestrator/data |
SQLite DB, WAL files, managed state. |
AO_AGENT |
claude-code |
Default agent adapter id used by ao spawn. |
AO_SESSION_ID |
(unset) | Set inside spawned sessions; read by ao send and ao hooks. |
GITHUB_TOKEN |
(unset) | Used by the GitHub SCM and tracker adapters. Falls back to gh auth token. |
Health check:
curl localhost:3001/healthz
curl localhost:3001/readyz
Architecture
The daemon is a long-running supervisor. Adapters observe external facts (PR
state, agent activity, runtime liveness); the lifecycle manager reduces those
into a small set of durable session facts (activity_state, is_terminated,
PR rows). Display status is derived from those facts at read time — it is
never stored. SQLite triggers append every user-visible change to change_log,
and the CDC poller broadcasts those events to in-process subscribers and an
SSE stream.
Full mental model and load-bearing rules: docs/architecture.md.
Package-by-package ownership: docs/backend-code-structure.md.
Testing
The local gate is the backend Go build and race-enabled test suite:
cd backend && go build ./... && go test -race ./...
GitHub Actions is the authoritative pre-merge gate; mirror its commands here
when in doubt. See AGENTS.md for the regen workflow when
touching the daemon API surface (npm run sqlc, npm run api).
Status and roadmap
Current main ships the CLI surface above, the SCM observer end-to-end
(issues #75,
#108,
#109), the agent
adapter platform, and the CDC pipeline with SSE replay. The Tracker observer
(#112) and live
pr_* event consumers (#110)
are in flight. The Electron supervisor under frontend/ is still a placeholder
shell — daemon logic stays in the Go backend.
Tracking milestone:
rewrite on GitHub.
Contributing
Repo layout and the worker contract live in AGENTS.md. Keep
changes surgical, follow the package boundaries documented in
docs/backend-code-structure.md, and prefer
adding daemon HTTP routes over leaking storage / runtime into the CLI.