* fix(cdc): emit pr_review_thread_resolved on replace polls (#152 bug 5)
writePRRows was DELETE-then-UPSERT on the Replace path, so every poll's
upserts hit the INSERT branch and the AFTER UPDATE trigger that emits
pr_review_thread_resolved never fired in production. Replaces the
blanket delete with a set-diff: upsert observed threads first (so
unchanged thread_ids go through ON CONFLICT DO UPDATE and fire the
UPDATE trigger when resolved flips), then delete orphans whose
thread_id is not in the observed set, all inside the existing tx.
Adds DeletePRReviewThread query (sqlc-generated form hand-edited; no
sqlc binary available locally — sqlc generate from backend/ produces an
identical file).
Tests: TestPRReviewThreadsCDC_EmitsResolvedOnReplacePoll (regression —
fails without fix) and TestPRReviewThreadsReplace_PrunesOrphansWithoutReinserting.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(observe): emit scm-disabled log on startup with no subjects (#152 bug 7)
checkCredentials lived only inside Poll, which short-circuits when
discoverSubjects is empty. On a fresh daemon with no tracked PRs the
documented "scm observer disabled: provider credentials unavailable"
warn was unreachable, leaving users with no signal that the SCM
observer was a no-op.
Calls checkCredentials once in Observer.loop before the first Poll.
The existing credentialsChecked guard preserves once-per-process
semantics; provider construction still uses SkipTokenPreflight so
daemon readiness doesn't block on gh.
Test: TestStart_LogsDisabledWarningWhenNoTokenAndNoSubjects with a
race-safe syncBuffer for capturing slog from the observer goroutine.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(api,spawn): typed errors + project/branch/binary preflight (#152 bugs 1-4,6)
Closes the long tail of opaque-500-and-orphan-row failures that
discussion #149's smoke walk surfaced. The common shape: spawn created
the session row before validating preconditions, and the underlying
errors weren't typed, so toAPIError defaulted to INTERNAL_ERROR.
Bug 1 (orphan row + opaque 500 on unknown projectId):
Service.Spawn / SpawnOrchestrator now call store.GetProject first and
return apierr.NotFound("PROJECT_NOT_FOUND", ...) before manager.Spawn,
eliminating the create-row-then-fail-workspace ordering.
Bug 2 (Restore opaque 500 on half-spawned/terminated session):
Manager.Restore gained the ErrIncompleteHandle guard that Kill has at
manager.go:189-193. toAPIError now maps both restore and kill to the
same SESSION_INCOMPLETE_HANDLE 409 envelope.
Bug 3 (--branch unfetched / checked-out-elsewhere → opaque 500):
gitworktree pre-checks listRecords for branch-in-other-worktree, falls
back to refs/tags on missing local/remote head, and emits two new port
sentinels (ErrWorkspaceBranchCheckedOutElsewhere,
ErrWorkspaceBranchNotFetched) mapped to BRANCH_CHECKED_OUT_ELSEWHERE
(409) and BRANCH_NOT_FETCHED (400).
Bug 4 (orphan terminated row on claim-pr rollback):
Adds Store.DeleteSession gated to seed-state rows only (preserves the
no-resurrection guarantee for live sessions), transactional change_log
cleanup, Manager.RollbackSpawn (delete-then-fallback-to-kill), a new
POST /sessions/{id}/rollback endpoint, and rewires
cli/spawn.rollbackSpawnedSession to use it. The exit-0 sub-symptom was
unreproducible from current source and is left unaddressed.
Bug 6 (agent binary not on PATH → silent idle session):
Drops the "return name, nil" anti-pattern from all 21 agent adapters
and returns the new ports.ErrAgentBinaryNotFound on exec.LookPath miss.
Manager.Spawn gained a validateAgentBinary pre-flight (with injectable
LookPath so tests don't need real binaries on PATH) that aborts before
runtime.Create. Mapped to AGENT_BINARY_NOT_FOUND (400). Integration
tests in internal/integration/ stub LookPath to /usr/bin/true.
Tests cover each bug end-to-end. OpenAPI regenerated for /rollback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: gofmt + regen frontend schema.ts for /rollback
CI fixes for #153:
- gofmt/goimports on kilocode and kiro adapters that the bug 6 audit
left mis-grouped.
- openapi-typescript regen against the new /rollback endpoint added in
the Lane A commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(store): guard change_log delete behind seed probe + regen sqlc (#152, PR #153 review)
Addresses @greptile-apps P1 and P2 review feedback on PR #153.
P1 (CDC events deleted for live sessions in rollback fallback):
DeleteChangeLogForSession ran unconditionally inside the transaction
before DeleteSeedSession's seed-state predicates filtered the session
delete to a no-op. For a live session reaching DeleteSession (the
delete-then-kill fallback path inside RollbackSpawn), the seed delete
returned 0 rows but the session_created/session_updated CDC events
had already been purged. Now probes via a new SessionIsSeed query
first and short-circuits the whole tx — including the change_log
cleanup — when the row is not in seed state.
P2 (regen sqlc): installed sqlc 1.31.1 and ran `sqlc generate` from
backend/, replacing the hand-edited pr_review_threads.sql.go (and
producing minor format-only churn in models.go, pr.sql.go,
sessions.sql.go, changelog.sql.go).
The regen surfaced two issues:
1. GetPR / ListPRsBySession had their return types hand-changed to
gen.PR by the previous PR; sqlc actually emits GetPRRow /
ListPRsBySessionRow when queries enumerate columns. Fixed by
collapsing those two queries to `SELECT * FROM pr` so sqlc returns
gen.PR (which is what the store's prRowFromGen converter expects),
and pr.last_nudge_signature now lands in the result alongside the
existing 37 columns.
2. sqlc 1.31.1's SQLite parser silently strips trailing `?`
placeholders and string literals from DELETE statements (reproduced
with sqlc.arg, IFNULL, rowid subquery, and second-predicate
workarounds — all eaten). DeleteSeedSession and
DeleteChangeLogForSession both tripped it. They are now run as
plain tx.ExecContext calls inside Store.DeleteSession, inside the
same write transaction as SessionIsSeed; both queries are removed
from the queries/ directory and the workaround context is
documented inline in queries/sessions.sql and queries/changelog.sql
to keep future contributors from re-adding them.
Verified: go build ./... clean, go test -race ./... 1097/1097 pass.
* feat(plugin): add agents plugin (first iteration)
Faithful copy of the agents plugin implementation from yyovil/better-ao
(internal/plugin/ -> backend/internal/plugin/) plus its PRD
(prds/plugins/agents/PRD.md), as a first-iteration proposal for review.
Imports are left at their original github.com/yyovil/better-ao/... paths and
are NOT yet reconciled to this repo's module; see PR description for the
integration deltas (module path, missing internal/utils dependency).
Co-authored-by: Claude <noreply@anthropic.com>
* Move agent adapters under backend adapters
* Keep daemon ports and session out of adapter move
* Remove Better-AO naming from flake
* Keep flake as dev shell only
* Use goimports for local formatting
* Wire session manager to per-session agent adapters
Move the Agent port into internal/ports and have the claude-code and
codex adapters implement it directly, alongside their workspace-local
activity hooks and a manifest-keyed adapter registry. Rename
RuntimeConfig.LaunchCommand to Argv and update the tmux and zellij
runtimes to match.
The session Manager now resolves a real agent adapter per session via a
new ports.AgentResolver: from cfg.Harness on Spawn and the stored harness
on Restore, so one daemon runs claude-code and codex sessions side by
side. The daemon backs the resolver with the registry; AO_AGENT selects
the default harness (default claude-code), validated at startup. Removes
the temporary noopAgent stub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(agent): point the agent contract at internal/ports/agent.go
The Agent interface moved from internal/adapters/agent to internal/ports;
update the PRD's Goal and Agent Contract sections (and the SessionInfo
references) to match the code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Wire the session service into the daemon
daemon.Run now builds the controller-facing session service — a session
manager over the zellij runtime, a gitworktree workspace, the shared
store + LCM, and the per-session agent resolver (AO_AGENT default,
validated at startup) — and mounts it at httpd APIDeps.Sessions, so the
session REST routes are backed by a real service. startLifecycle moves
ahead of the HTTP server so both share one LCM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address Greptile review: complete the live spawn path
- Spawn and Restore now install workspace-local activity hooks
(GetAgentHooks) and run the adapter's optional PreLaunch step before
launch, via a shared prepareWorkspace helper. PreLaunch is how Claude
Code records workspace trust, so its interactive "trust this folder?"
dialog can't hang the headless pane; the spawned env now also carries
AO_DATA_DIR so the installed hook commands find the store.
- claudecode and codex hook/config writes are now atomic (temp + rename)
instead of os.WriteFile, so a crash mid-write can't leave a partial
file the agent fails to parse.
- ensureWorkspaceTrusted serializes its read-modify-write under a package
mutex, so concurrent spawns to different workspaces don't drop each
other's ~/.claude.json trust entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(ports): pin MetadataKeyAgentSessionID to domain.SessionMetadata json tag
The equality between ports.MetadataKeyAgentSessionID and the json tag on
domain.SessionMetadata.AgentSessionID is a hand-maintained invariant; this
test fails loudly if either side drifts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(adapters): use ports.MetadataKeyAgentSessionID in claudecode + codex
The native session id metadata key is defined in ports for cross-package
consumption; drop the duplicated literals in each adapter so the constant
has one home.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(codex): cover ensureCodexHooksFeatureEnabled TOML edge cases
The helper is a string editor over config.toml; pin its content
transformation for missing/empty files, existing [features] blocks,
the no-op case, and the legacy codex_hooks=true migration paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(adapters): document Registry concurrency contract
Registry registration runs at daemon boot before any goroutine calls Get,
so the underlying map needs no lock; pin that contract in the doc comment
so a future change doesn't quietly introduce a race.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style(codex): gofmt codex_test.go after constant rename
The previous commit (7c5b2a9) replaced codexAgentSessionIDMetadataKey with
ports.MetadataKeyAgentSessionID inside a map literal; the longer key threw
off gofmt's column alignment on the adjacent codexTitleMetadataKey /
codexSummaryMetadataKey lines. Caught by agent-ci's Check formatting step.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: harshitsinghbhandari <dev@theharshitsingh.com>
* refactor(project): manager talks to the sqlite store; drop the in-memory store
The project Manager now runs only against the durable backend store: remove the
process-local MemoryStore (and NewMemoryManager), and require a real Store. The
daemon already wires the sqlite store; tests now build a real temp-dir sqlite
store instead of the mock.
- Move Row + the Store port to project/store.go. The Store interface stays
because it is the dependency-inversion port that lets the manager reach the
backend without an import cycle (storage imports project.Row), not an extra
mock layer — there is no longer any in-memory implementation.
- NewManager requires a non-nil Store (no in-memory fallback).
- Add project/manager_test.go: List/Add/Get/Remove happy paths +
PATH_REQUIRED/NOT_A_GIT_REPO/PATH_ALREADY_REGISTERED/ID_ALREADY_REGISTERED,
PROJECT_NOT_FOUND/INVALID_PROJECT_ID, and UpdateConfig — all against a real
sqlite store (the service-logic tests #47 lacked).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(project): trim routes, consolidate package, add code-first OpenAPI
- Remove POST /reload, PATCH /{id}, POST /{id}/repair routes and their
Manager methods (Reload, UpdateConfig, Repair) and DTOs (ReloadResult,
UpdateConfigInput) — not needed at this stage
- Merge Manager interface into manager.go; delete project.go (single-impl
split served no purpose)
- Remove dead notImplemented helper from errors.go
- Port PR #59 code-first OpenAPI generation: controllers/dto.go named
response types, specgen/build.go (4 routes), parity + drift tests,
cmd/genspec, go generate wiring; regenerate openapi.yaml
- Add swaggest deps; add YAML() method to apispec.Spec
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(project): address PR review comments
- t.Skipf → t.Fatalf in gitRepo helper: git failures now hard-fail
instead of silently skipping manager tests on a misconfigured runner
- FindProjectByPath: add AND archived_at IS NULL so archived paths don't
permanently block re-registration (update queries/projects.sql and
generated gen/projects.sql.go)
- Add TestManager_ReaddAfterRemove to lock the fix
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fixed lint and fmt
* addressed greptile comments
* Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* project tests fix
* project_tests fix
* fix: Linting and formatting fix
* refactor: move project manager into service layer (#68)
* refactor: split service package by resource (#68)
* fix: ignore archived project id conflicts (#68)
* refactor: move pr manager into service layer (#68)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: itrytoohard <ayetrytoohard@gmail.com>
Introduces backend/.golangci.yml (27 linters across correctness, dead-code/
boilerplate, style, and security), wires it into CI as a blocking job, and
fixes every finding so the tree starts at zero.
Config:
- 27 linters: errcheck, govet, staticcheck, errorlint, bodyclose,
sqlclosecheck, rowserrcheck, nilerr, makezero, unused, unparam, unconvert,
wastedassign, copyloopvar, prealloc, dupl, revive (incl. exported-symbol doc
comments), gocritic, misspell, usestdlibvars, predeclared, nakedret, gosec, …
- Tuned for signal over noise: govet/shadow and gocritic hugeParam/rangeValCopy/
unnamedResult disabled (idiomatic-Go false positives); sqlc-generated code and
tests get scoped exclusions; gosec G304 excluded (paths are config/run-file/
worktree-derived, not user input); nilerr excluded in cli/status.go (probe
failures are the reported status, not a command error).
CI:
- New blocking lint job (golangci-lint-action, latest binary for Go-version
compatibility).
- go-version now read from go.mod (was pinned 1.22 while go.mod declares 1.25).
Cleanup to reach zero (no behavior change):
- errcheck: wrap deferred/inline Close()/Remove()/Rollback() with `_ =`.
- gosec: tighten dir/file perms (0755->0750, 0644->0600).
- unparam: drop always-nil error return from startLifecycle; drop unused
shellPath param (zellij PowerShell) and always-500 fallbackStatus param
(writeProjectError).
- gocritic: regexp \d, s != "", switch->if, combined appends.
- revive: doc comments on all exported symbols; rename project.ProjectRow ->
project.Row (stutter); rename `max` locals shadowing the builtin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each PR-child table (pr / pr_checks / pr_comment) had three near-identical
structs — gen.* (generated), sqlite.*Row, and ports.* — with wiring.Adapter
copying field-by-field between them. Collapse to one shared definition per
table in domain (PRRow / PRCheckRow / PRComment), used by both the PRWriter
port and the sqlite store; gen.* stays sealed inside the storage layer.
- *sqlite.Store now satisfies ports.SessionStore + ports.PRWriter directly,
so the entire wiring.Adapter package is deleted (lifecycle.New(store, store)).
- The bool PR state <-> single state column, int<->int64, and enum-default
translation now lives only at the gen<->domain boundary in pr_store.go.
- WritePRObservation renamed WritePR to match the port; the integration test
and composition root drop their adapter copies.
Net -280 lines, behaviour unchanged. go test -race ./... green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile P1: the patch-then-Update path in TestRestoreRoundTrip was
discarding GetSession's ok/err. A missed row would have handed UpdateSession
a zero-value SessionRecord (ID==""), which matches zero rows and returns
nil — Phase B then fails with the misleading "agent session id lost across
restart" instead of the real cause.
Fixed at the patch site (the only write path that could swallow the error)
and at the two read-then-assert sites in TestDetectingPersistsAcrossRestart
for consistency. Downstream assertions there would already fail loudly, but
the explicit ok/err check makes the failure mode unambiguous.
All 219 tests still pass under -race.
- Idempotent close + t.Cleanup so a mid-test t.Fatalf can't leak the
SQLite handle for the rest of the binary run. Restart-style tests still
call close() explicitly between phases; the cleanup hook becomes a no-op
once that runs.
- Drop the hardcoded "mer-1" assertion in TestHappyPath; assert the
structural invariant (project-scoped, non-empty id) instead, so the
test does not couple to the {project}-{counter} generation detail.
- Realign the test storeAdapter's PRFactsForSession + WritePR bodies to
be line-for-line identical to backend/lifecycle_wiring.go's production
adapter (extracted prState helper, separated err vs empty-rows checks),
so a future divergence shows up as a diff at review time. The proper
fix (extract to internal/storeutil) remains out of scope per the
brief's "do NOT redesign anything".
All 219 tests still pass under -race.
The Check formatting step failed on the stub method alignment in
stubAgent's three method declarations. Re-run of gofmt aligns the column
gutter on those signatures; no behavioural change.
Adds backend/internal/integration with five end-to-end tests that hydrate the
real lifecycle.Manager + session.Manager against a tmp SQLite store and
exercise the full pipeline through the DB triggers and the CDC poller:
- TestHappyPath_Spawn_PR_Kill — spawn -> SCM PR observation (open + CI
passing) -> kill; asserts canonical row, pr row, and change_log event
types (session_created/_updated, pr_created, pr_check_recorded).
- TestRestoreRoundTrip_PreservesMetadata — spawn, kill, close store, reopen
same DB path, hydrate fresh LCM/SM, Restore(); asserts AgentSessionID and
the rest of SessionMetadata survive across the daemon restart.
- TestCIFailureAndRecovery_NudgeThenClears — failing CI observation drives
the CI-failed reaction nudge with the log tail injected; passing CI
observation switches to approved-and-green human notify; pr_checks history
reads back the failure (the brake's source of truth).
- TestDetectingPersistsAcrossRestart — failed probe parks the session in
detecting with detecting_* columns populated, round-trips across a
close/reopen, alive probe clears the quarantine memory.
- TestCDCPollerReceivesAllStages — drives the real cdc.Poller; asserts the
trigger pipeline emits each expected event_type and seq is monotonic.
Wiring gap fixed (minimal): goose v3 keeps baseFS/logger/dialect as
package-level globals, so two concurrent sqlite.Open() calls — uncommon in
production but normal under -race with t.Parallel() — race on
goose.SetBaseFS/SetLogger/SetDialect inside migrate(). Added a process-level
sync.Mutex around the migrate() call. ~11 lines, no signature changes.
Scope notes (the task brief assumed a fancier architecture than what
actually shipped in PR #37):
- No outbox / consumer_offsets / janitor exist on main — the change_log
table IS the durable, ordered source of truth (see cdc/event.go), so the
brief's janitor-watermark step is skipped.
- No reaction_trackers table / ReactionStore port — trackers are in-memory
per lifecycle/reactions.go; persistence-round-trip there is N/A.
- No revision column / Upsert(rec, eventType) — write-mutex serialises and
change_log.seq orders, so the assertions land on event_type + seq, not on
a per-row revision counter.
All 219 tests pass under -race across 18 packages. lifecycle/fakes_test.go
is untouched; existing unit tests still drive the in-memory fake.