* feat(session): switch a session's agent mid-flight
Add the ability to change a session's agent (harness) without losing the
worktree. Previously a session's harness was fixed at spawn and could never
change; mixing agents on one task (e.g. codex to test, claude-code to write,
a cheaper model for cost) was impossible.
A switch keeps the same git worktree (all code + uncommitted work preserved)
and launches the new agent fresh — there is no native resume, since a
different harness cannot read the outgoing agent's session. An optional model
override rides the same path.
Two cases are handled:
- Live session: swap in place. The old agent is torn down only AFTER the new
launch command validates, so a bad/unknown harness never disrupts the
running session. A BeginSwitch/EndSwitch guard on the lifecycle manager makes
the reaper ignore the brief runtime gap so it is not mistaken for a crash.
- Terminated session (e.g. the agent exited): relaunch-as. The worktree is
restored and the new agent launched fresh under it — the way to bring a
finished task back under a different agent (plain restore keeps the harness).
lifecycle.MarkSwitched atomically changes the persisted harness, points at the
new runtime handle, and clears the harness-specific AgentSessionID (which
MarkSpawned's merge cannot), resetting activity/first-signal so the new agent
re-proves its hook pipeline.
Surfaces:
- Backend: sessionmanager.SwitchHarness + lifecycle guard/MarkSwitched.
- API: POST /sessions/{id}/switch {harness, model?} (400/404/409 mapped);
OpenAPI spec + frontend schema.ts regenerated.
- CLI: `ao session switch <id> --harness <agent> [--model ...]`.
- UI: the session inspector's Overview "Agent" row is now a dropdown that
switches the agent in place (or relaunches a terminated one); merged sessions
stay read-only.
Tests cover: live swap clears AgentSessionID, unknown harness leaves the agent
running, terminated relaunch-as, create-failure terminates cleanly, the reaper
guard suppresses termination during a switch, and MarkSwitched semantics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: format with prettier [skip ci]
* fix(session): address review — atomic switch guard + terminated resumability
P1: the switch-in-progress guard was a check-then-act (IsSwitching + later
BeginSwitch) and the terminated path never claimed it, so two concurrent
switches could both proceed and race two teardown/relaunch cycles over one
worktree. Replace with an atomic lifecycle.TryBeginSwitch (single-critical-
section compare-and-set) claimed once at the top of SwitchHarness for both the
live and terminated paths, released via defer.
P2: relaunchTerminatedWithHarness always fresh-launched with meta.Prompt,
bypassing restoreArgv's guard. Since the new harness cannot native-resume the
old agent's session, a terminated worker with no saved prompt would blank-
relaunch — which Restore deliberately refuses. Reject the same promptless-
worker case with ErrNotResumable (orchestrators stay promptless by design).
Tests: reject concurrent switch, terminated promptless worker rejected;
lifecycle TryBeginSwitch compare-and-set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: format with prettier [skip ci]
* fix(session): persist restored worktree on switch + resume previously-used agents
Two fixes on the agent-switch path.
Medium (review): terminated relaunch-as restored the worktree to ws.Path but
MarkSwitched only updated the runtime handle, leaving the old
Metadata.WorkspacePath/Branch. A changed session prefix or managed root could
restore to a different path while the stored session still pointed at the old
one, breaking later terminal/workspace/cleanup ops. MarkSwitched now takes full
SessionMetadata and persists WorkspacePath/Branch from the launch.
Bug: switching to a harness that had already run the session relaunched it
fresh, colliding with the agent's own prior native session — Claude Code pins a
deterministic --session-id, so a fresh relaunch failed with "Session ID <uuid>
is already in use". Sessions now track the set of harnesses they've launched
(SessionMetadata.LaunchedHarnesses); a previously-used harness RESUMES (via the
adapter's restore command) while a new one launches fresh. The promptless-worker
guard now applies only to fresh launches (a resume needs no saved prompt).
Tests: MarkSwitched persists workspace path/branch + launched set; resume for a
previously-used harness; fresh launch (and set update) for a new harness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(session): clear lingering runtime on terminated switch + gate dropdown to authed agents
A terminated agent's tmux session can outlive the agent process (the keep-alive
shell keeps it open), so its deterministic session name stays taken. The
terminated relaunch-as path skipped Destroy (it assumed no live runtime) and
went straight to Create, which failed with "duplicate session <id>" (surfaced
as a 500). It now tears down any leftover runtime handle before Create — Destroy
is idempotent, so an already-gone session is a no-op. The live path already did
this. Test: terminated relaunch with a lingering handle destroys it before Create.
UI: the Agent-row switch dropdown now lists only agents whose local auth probe
passed (the catalog's authorized set, same source the spawn dialogs use) instead
of every known harness, so users don't pick an agent that just fails at launch.
Empty/loading states render a disabled hint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(session): persist launched-harnesses set so switch resume actually triggers
The previous commit tracked LaunchedHarnesses on SessionMetadata to drive the
resume-vs-fresh switch decision, but the SQLite store maps metadata to explicit
columns and had no column for it — so it was silently dropped on write and read
back empty. The resume branch never fired, and switching back to a
previously-used deterministic-id agent (Claude Code) still fresh-launched and
collided ("Session ID <uuid> is already in use").
Add a durable launched_harnesses column (migration 0022), thread it through the
InsertSession/UpdateSession/Select queries (sqlc regenerated), and serialise the
harness set as a comma-separated string in the store. The set now round-trips,
so a previously-used harness resumes instead of colliding, surviving daemon
restarts (the agent's on-disk session does too).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(session): don't resume with a foreign session id; reject merged at service layer
Two correctness gaps from review.
1. The resume path keyed only on LaunchedHarnesses but still handed restoreArgv
the single meta.AgentSessionID — which MarkSwitched clears on each switch and
which may hold *another* harness's id (durable state tracks harness names, not
per-harness native ids). For adapters whose restore needs their own captured
id (Codex/OpenCode/Goose/Copilot), that meant resuming against a wrong/empty
id. Clear AgentSessionID on the resume path so restoreArgv resumes ONLY
adapters that deterministically derive their session id (e.g. Claude Code);
the rest cleanly fall through to a fresh launch, which never collides for them.
2. "merged" is a derived read-model status (from PR facts), so the internal
manager can't enforce the merged lock — only the inspector UI did, leaving
direct API/CLI callers able to switch a merged session. Service.SwitchHarness
now derives the session status and rejects StatusMerged (409 SESSION_MERGED)
before delegating.
Tests: resume does not leak a foreign session id (fresh-launches instead);
merged session is rejected at the service layer without reaching the manager.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(session): update prepareWorkspace calls for new signature + gofmt store
Merge with main changed prepareWorkspace to take systemPrompt + agentConfig;
update both switch call sites (live swap and terminated relaunch) to pass them,
restoring the build. Run gofmt on session_store.go so the added
LaunchedHarnesses struct field is realigned and the gofmt gate passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* 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>