* 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>
|
||
|---|---|---|
| .. | ||
| README.md | ||
README.md
Agent Adapter PRD
Goal
Agent adapters let AO run and observe different CLI coding agents without hardcoding agent-specific behavior into the spawn engine. Every CLI coding agent must implement the contract in backend/internal/ports/agent.go.
The important current slice is hook-derived session info. AO should know a running worker's native agent session id, title, and summary from agent hooks installed in the per-session worktree, not from scanning agent transcript/cache files.
Current Decisions
- AO only needs to derive session info for AO-managed sessions.
- Hook installation happens at worktree/session creation time.
SessionInforeads normalized metadata persisted in AO's session store.SessionInfomust not infer display info by reading agent transcript/cache files.SummaryIsFallbackis removed fromports.SessionInfo.TranscriptPathis removed fromports.SessionInfo.TitleandSummaryare both first-class fields.Titleis derived from the user prompt hook.Summaryis derived from the stop/final assistant hook.- Agent adapter
Metadatashould stay nil/empty unless an adapter has a real extra field that does not belong in the normalized contract.
Agent Contract
The shared contract lives in backend/internal/ports/agent.go.
Required adapter behavior:
GetConfigSpecdescribes user-facing agent config.GetLaunchCommandbuilds the native agent command.GetPromptDeliveryStrategysays whether the prompt is passed in argv or sent after launch.GetAgentHooksinstalls or merges AO hooks into the agent's workspace-local hook config.GetRestoreCommandbuilds a native resume command when restore is supported.SessionInforeturns normalized metadata:AgentSessionIDTitleSummary- optional adapter-specific
Metadata
Implementation layout:
- Agent-specific hook installation should live beside the agent adapter in
backend/internal/adapters/agent/<agent>/hooks.go; the hook commands are defined in code, not embedded template files. - Launch, restore, and session-info behavior can stay in the main agent implementation unless the file grows enough to justify another split.
- Every file an adapter writes into the session worktree must be covered by a sibling self-ignoring
.gitignorewritten viahookutil.EnsureWorkspaceGitignore. Hook files are untracked, andgit worktree remove(never run with--force) refuses on any untracked file — an uncovered hook file makes every session workspace permanently undeletable. The registry conformance test (registry.TestGetAgentHooksFootprintIsGitignored) enforces this for all adapters.
Metadata Keys
Hook callbacks persist these normalized keys in the session metadata JSON blob:
agentSessionId: native agent session id.title: display title, derived from the first user prompt hook for the session.summary: display summary, derived from the final assistant message exposed to the stop hook.
The original spawn prompt may remain in metadata as prompt for launch/debug fallback, but title is the preferred display title once hook metadata lands.
Hook Methodology
Agent adapters install hooks into the worktree-local config owned by the native agent.
Codex is the exception: Codex (0.136+) only loads project-local .codex/ hook config from trusted directories, and for linked git worktrees it sources hook declarations from the matching folder in the root checkout — never from AO's per-session worktree. The Codex adapter therefore passes its hooks on the launch command as -c 'hooks.<Event>=[...]' session-flag config (plus --dangerously-bypass-hook-trust, since session-flag hooks have no persisted trust hash), and marks the worktree as a trusted project for the invocation with -c 'projects={"<worktree>"={trust_level="trusted"}}' so spawns into never-before-trusted repos don't hang on Codex's interactive directory-trust prompt. Its GetAgentHooks writes nothing; it only strips entries older AO versions left in the worktree-local .codex/hooks.json.
Hook callbacks run through hidden AO CLI commands:
ao hooks <agent-adapter> <event>
The callback:
- Reads the native hook JSON payload from stdin.
- Reads the AO session id from
AO_SESSION_ID(exits 0 immediately for non-AO sessions). - Derives a normalized activity state from the agent + event (
activitydispatch.Derive); events with no activity meaning report nothing. - POSTs the state to the daemon at
POST /api/v1/sessions/{id}/activity; the daemon owns the store and fans outsession.updatedvia CDC. - Always exits 0 — a failed delivery must never break the user's agent. Failures are appended to
hooks.logunderAO_DATA_DIRand surfaced by thehooks-logcheck inao doctor.
The daemon also records the FIRST callback per spawn/restore (first_signal_at); a live session that has never signaled past a grace period derives the no_signal display status instead of a confident idle, so a broken hook pipeline is visible on the dashboard. The downgrade only applies to harnesses with a registered activity deriver (activitydispatch.SupportsHarness, injected into the session service at daemon wiring) — for a hook-less adapter, permanent silence is normal and stays idle. Known limitation: neither Codex nor Claude Code derives an activity state from SessionStart, so a restored session the user never prompts has nothing to signal and shows no_signal once the grace passes; a receipt-only session-start signal would close that gap.
Persisting hook-derived metadata (agentSessionId, title, summary) into the session row is not implemented yet — until it is, adapters whose restore needs the native session id (e.g. codex resume) fall back to a fresh launch.
The spawn engine inserts the AO session row before launching the durability provider so early startup hooks can update an existing row. If launch fails after insertion, spawn deletes the row during rollback.
The hook commands are a bare ao hooks ... on purpose: worktree-committed hook files stay machine-portable, and adapters recognize their own entries by command prefix for install/dedup/uninstall. To make the bare ao resolve to the daemon that installed the hooks (not a foreign or legacy ao earlier on the user's PATH), the session manager pins each spawned session's PATH with the daemon executable's directory first. When the pin cannot be applied (executable unresolvable or not named ao), the daemon logs a warning at spawn. Hook delivery failures are best-effort appended to hooks.log under AO_DATA_DIR (agents swallow hook stderr), and ao doctor warns when the ao on PATH is not the running binary.
Restore Boundary
Session display info and native restore are separate concerns.
Some agents may still need transcript-derived or deterministic native ids for GetRestoreCommand until restore is redesigned for that agent. Do not remove restore support just because SessionInfo stops reading transcripts.
For SessionInfo, transcript/cache files are not an acceptable source of title or summary.
UI And Events
The workspace adapter prefers:
metadata.titleas session title.metadata.summaryas session description.metadata.promptonly as fallback.
Hook metadata changes publish session.updated. The frontend listens to session.created, session.terminated, and session.updated and invalidates the workspace query.
Acceptance Criteria
Agent adapter behavior:
- Agent hook installation preserves user hooks and deduplicates AO hooks.
- Hook callbacks persist native session id, title, and summary.
SessionInforeturns normalized fields from persisted metadata.SessionInfodoes not read transcripts or caches for title/summary.- Adapter-specific metadata stays nil/empty unless a concrete feature requires it.
Engine and UI:
- Spawn installs hooks before launching the native agent.
- The session row exists before launch so hooks can merge metadata.
- Launch failure after row insertion deletes the row.
- Metadata updates publish
session.updated. - The dashboard refreshes title/summary without a manual reload.
Verification:
(cd backend && go test ./...)
(cd frontend && npm run typecheck)