* fix(sessions): stop AO hook files from making every worktree permanently dirty
Agent adapters write hook files (.codex/hooks.json, .opencode/plugins/
ao-activity.ts, .claude/settings.local.json, ...) into fresh session
worktrees as untracked files. `git worktree remove` (deliberately run
without --force) refuses on any untracked file, so Workspace.Destroy
failed for every session of the 12 workspace-writing harnesses:
POST /sessions/{id}/kill returned an unlogged 500 INTERNAL_ERROR and
`ao session cleanup` reported 'Would clean N' then '0 sessions cleaned'
with no reason, leaking workspaces forever.
Three coordinated fixes, none of which force-deletes user/agent work:
- Root cause: every adapter now writes a sentinel-guarded, self-ignoring
.gitignore next to its hook files (hookutil.EnsureWorkspaceGitignore),
so AO's own files no longer count as dirt while anything an agent
drops — even in the same directory — still blocks teardown. A
registry-wide conformance test enforces the contract for all current
and future adapters. (Per-worktree .git/worktrees/<name>/info/exclude
was evaluated first but git does not honor it.)
- Typed refusal: gitworktree.Destroy classifies a still-dirty refusal as
ports.ErrWorkspaceDirty (git status probe). Kill maps it to success
with freed=false (session terminated, worktree preserved); Cleanup
reports it per-session as skipped-with-reason through the API
(CleanupSessionsResponse.skipped), and the CLI prints
'Skipped: <id> (workspace has uncommitted changes)' plus a summary.
- Observability: envelope.WriteError records the raw service error into
a request-scoped slot and the access log attaches it to 5xx lines, so
any remaining internal error is diagnosable server-side.
Worktrees created before this fix gain the .gitignore on restore (hook
install re-runs); their cleanup is otherwise reported as skipped instead
of erroring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cleanup): address Greptile P2s — surface dirty-probe failures, stop leaking raw errors
Two review findings on this PR:
- gitworktree.Destroy: when the isDirty probe itself failed, the error was
silently discarded and the refusal looked identical to "registered but not
dirty". The probe failure now rides the returned error (dirty probe: ...),
so it reaches the access log via the 5xx error capture.
- Cleanup skip reasons: a non-dirty teardown failure put the raw error —
including internal filesystem paths — into the public skipped[].reason
field. The public reason is now the fixed string "workspace teardown
failed"; the full cause goes to the daemon log (warn, with sessionID and
path). The dirty-refusal reason is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gitworktree): wrap the dirty-probe error with %w per errorlint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(backend): HTTP daemon skeleton — config, health, runfile, graceful shutdown (#10)
Phase 1a of the Go HTTP daemon lane (#10). Stands up the loopback-only
sidecar skeleton the later REST/SSE/WS/static surfaces build on:
- config: env-driven (AO_HOST/PORT/ENV/timeouts/run-file) with zero-config
defaults; binds 127.0.0.1:3001; validates and fails fast on bad input.
- httpd: chi router with the recoverer → request-id → logger → real-ip
middleware stack and /healthz + /readyz probes. Per-request timeout is
carried in config but intentionally not global — it scopes to /api/v1 in
Phase 1b so it never throttles SSE/WS/health.
- runfile: atomic PID + port handshake (running.json) for the Electron
supervisor, with a dead-PID stale check so a crashed predecessor doesn't
block startup while a live one fails fast.
- server: bind-before-publish (port conflict fails fast), graceful shutdown
on SIGINT/SIGTERM via signal.NotifyContext with a 10s hard timeout, and
run-file cleanup on exit.
Why: the daemon must be safely supervisable as a child process — the
supervisor needs a discoverable PID/port and the daemon must not leave a
half-started process or stale handshake behind. Locking the lifecycle down
now keeps the future port split a small change rather than a rewrite.
Tests cover config defaults/overrides/validation, run-file round-trip and
live/dead PID detection, health probes, full Run lifecycle, and port-conflict
fail-fast.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(backend): drop Env config field — not needed yet (#10)
Per review on #14: AO_ENV / Config.Env / IsProduction() weren't load-bearing
for Phase 1a — they only switched the slog handler. Removing them now keeps
the surface minimal; the env knob can come back later when a real consumer
needs it.
- config: remove Env field, AO_ENV parsing, and IsProduction helper.
- main: collapse newLogger to a single text-handler path.
- httpd: drop the env field from the listening log line.
- tests: drop the env assertions and AO_ENV fixture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: add backend run + config quick-start to README (#10)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend): address Phase 1a review comments (#10)
- config: drop AO_HOST entirely — the daemon is loopback-only by design,
so making the bind host env-configurable was a security footgun
- config: use net.JoinHostPort in Addr() so IPv6 literals stay valid
- config: reject zero/negative AO_REQUEST_TIMEOUT and AO_SHUTDOWN_TIMEOUT
(time.ParseDuration accepts both; either would silently break the
daemon — instant request expiry / no graceful drain)
- runfile: split processAlive into unix/windows build-tagged files so
liveness detection is reliable on both platforms (Windows uses
OpenProcess; POSIX keeps signal 0)
- runfile: document os.Rename overwrite semantics (atomic on POSIX,
REPLACE_EXISTING on Windows) so the temp-then-rename pattern's
cross-platform behaviour is explicit
- httpd tests: give probe/waitForHealth clients an explicit per-request
timeout so a stalled connect can't hang the test on the outer deadline
* fix(backend): strip trailing blank line from runfile.go (#10)
gofmt CI was failing because removing the orphan processAlive doc
comment left an extra newline at EOF.
* fix(backend): cross-platform run-file replace + AO_HOST rationale (#10)
- runfile: introduce build-tagged atomicReplace — POSIX rename(2) on
Unix, MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows. The Go
runtime happens to do the Windows call internally already, but
invoking it directly makes the cross-platform contract explicit
instead of a runtime implementation detail
- runfile: tighten process_unix.go build tag from `!windows` to `unix`
so plan9/js/wasm fail to build rather than silently using a broken
signal-0 probe
- runfile: add TestWriteOverwritesExisting covering the stale run-file
replace path that none of the previous tests exercised
- config: anchor the loopback-only decision in the LoopbackHost doc so
the next contributor doesn't reintroduce AO_HOST without the security
rationale
* fix(backend): route chi access logs through slog/stderr (#10)
chi's middleware.Logger writes via stdlib log to stdout, but the
daemon's slog logger writes to stderr — so REST traffic and daemon
logs landed on different streams in different formats. Replace it
with a small slog-backed requestLogger that:
- Wraps the response writer via middleware.NewWrapResponseWriter so
status/bytes are accurate even when handlers return without an
explicit WriteHeader.
- Reads the request id off the context set by middleware.RequestID
(kept mounted just before this middleware so the id is available).
- Emits one structured Info line per request with method, path,
status, bytes, duration, and remote — same key=value shape as the
rest of the daemon, one stream for the Electron supervisor to
capture.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>