12 KiB
Plan: Save-on-Close / Restore-on-Open Session Lifecycle
Goal
Make the intended lifecycle real and lean: on app close, save every running
session (worker AND orchestrator, no filtering) plus its uncommitted work, then
force-remove the worktrees. On app launch, recreate the worktrees, replay the
saved uncommitted work, and restore all sessions. The daemon already starts on
launch and shuts down + frees its port on quit; this plan fills the missing
save/restore middle.
Core architectural decisions (settled)
- All save/restore logic lives in the daemon, not the frontend. The daemon
owns the store, the gitworktree adapter, and thegitbinary. The frontend's
only new responsibility: call the existingPOST /shutdownendpoint before
it kills the daemon, so the save runs gracefully (SIGTERM remains the
fallback and triggers the same daemon-side save path). - The "last-stop manifest" is the existing SQLite state, not a new file.
ListAllSessionsalready records id, kind (worker/orchestrator), harness,
is_terminated, andMetadata{branch, workspacePath, agentSessionId, prompt}. Thesession_worktreestable already has apreserved_refcolumn
(migration 0009) that nothing currently writes. No manifest.json, no new
migration, no new format. The manifest is a query. - Uncommitted work is captured as a git commit object pointed to by a ref
refs/ao/preserved/<session-id>. Reject the user's original
refs/{worktree-path}/uncomit/naming (worktree paths contain/, are not
valid single ref components, and are not stable identity). The session id is
the stable key the rest of the system already uses. - Untracked files: respect
.gitignore. Build the preserve commit through
a temp index (GIT_INDEX_FILE=<tmp> git add -A; git write-tree; git commit-tree) so tracked + staged + new (non-ignored) files are captured,
side-effect-free, without mutating the working tree or the stash stack.
Ignored paths (node_modules/, build output, ignored.env) are skipped.
Log a one-line count of skipped ignored paths so it is never silent. (Chosen
overgit stash create, which silently drops all untracked files, and over
git stash push -u, which mutates the worktree and the global stash stack.) - Do not weaken the existing dirty-worktree refusal used by interactive
ao session kill/ao cleanup. Add a separateForceDestroythat the
shutdown path calls only AFTER the work is captured. Adding--forceto the
shared remove path would silently destroy work in the interactive flows.
Global Constraints (binding — reviewers enforce verbatim)
- App state resolves under
~/.aoonly (AO_DATA_DIR/AO_RUN_FILE
overridable). Never~/Library/Application Support. The manifest is the
existing SQLite DB at the configured data dir; preserve refs live in each
project repo's.git. - Preserve ref name is exactly
refs/ao/preserved/<session-id>. - Untracked capture respects
.gitignore(no-f, no force-include). Skipped
ignored paths are logged with a count. - No kind filtering anywhere in the save or restore loops: orchestrator and
worker sessions are both saved and both restored. - Save is strictly capture-then-destroy, per session, with the DB write
committed before the worktree is removed (crash-safety invariant). - Never delete a preserve ref except immediately after a successful clean
apply. A failed apply keeps the ref and leaves conflict markers for the agent. - No new manifest file, no new migration, no new HTTP endpoint (reuse the
existingPOST /shutdown). - The existing single-session
POST /sessions/{id}/restoreendpoint and the
interactive dirty-refusal removal path stay behaviorally unchanged. - No em dashes anywhere (prose, comments, commit messages).
Key files
backend/internal/adapters/workspace/gitworktree/workspace.go— Destroy,
Restore, isDirty, findWorktree (re-add logic lives here)backend/internal/adapters/workspace/gitworktree/commands.go— git arg
builders (worktreeRemoveArgsdeliberately omits--force)backend/internal/ports/outbound.go—Workspaceinterface (~line 120)backend/internal/session_manager/manager.go— Kill (~411-446), Cleanup
(~556-588), Restore (~451), dirty-refusal translationbackend/internal/daemon/daemon.go— boot/shutdown sequence (startSession
~112,srv.Run(ctx)~144)backend/internal/storage/sqlite/store/session_store.go—ListAllSessions
(~173)backend/internal/storage/sqlite/store/session_worktree_store.go—
preserved_refCRUD (UpsertSessionWorktree)backend/internal/domain/session.go,domain/project.go— record + worktree
domain typesfrontend/src/main.ts—before-quit(~694-700), running.json port read
(~338)
Tasks (smallest coherent diff first; each ends with ONE runnable check)
Task 1 — ForceDestroy on the workspace port + gitworktree adapter
Add ForceDestroy(ctx, info) error to the ports.Workspace interface and the
gitworktree adapter. It runs git worktree remove --force <path>, then prune,
then os.RemoveAll as a backstop. New arg builder in commands.go; leave the
existing safe Destroy/worktreeRemoveArgs untouched. Add the ponytail:
comment that ForceDestroy is only safe after the work is captured.
Check: Go test in gitworktree that creates a worktree, dirties it, calls
ForceDestroy, and asserts the path is gone and the worktree is deregistered.
Task 2 — StashUncommitted + ApplyPreserved on the gitworktree adapter
StashUncommitted(ctx, info) (ref string, err error): build the preserve
commit via a temp index that respects.gitignore
(GIT_INDEX_FILE=<tmp> git add -A→git write-tree→git commit-tree),
pointrefs/ao/preserved/<id>at it viagit update-ref, return the ref name
(empty if the worktree is clean — nothing to preserve). Log count of ignored
paths skipped.ApplyPreserved(ctx, info, ref) error: apply the preserve commit's tree onto
the worktree (git stash apply <sha>style, orgit read-tree/checkout from
the commit). On clean success delete the ref (git update-ref -d); on
conflict, keep the ref, leave conflict markers, return a sentinel the caller
logs.
Check: Go test that round-trips a tracked edit AND a new non-ignored file
through StashUncommitted → ForceDestroy → re-add → ApplyPreserved and asserts
both reappear; and that a path matched by.gitignoredoes NOT reappear.
Task 3 — SaveAndTeardownAll + RestoreAll on the session manager
SaveAndTeardownAll(ctx):ListAllSessions; for each live (non-terminated)
session with a non-emptyMetadata.WorkspacePath:StashUncommitted→
UpsertSessionWorktree(preserved_ref=...)(commit) →MarkTerminated
(reuse the LCM path Kill uses) → runtime teardown →ForceDestroy. Mirror
Killbut swap refuse-on-dirty for capture-then-force. No kind filter.RestoreAll(ctx):ListAllSessions; for each terminated session that the
shutdown save actually processed: ensure worktree via the existing
workspace.Restore,ApplyPreservedif a preserve ref is recorded, then
manager.Restore(ctx, id). Reuse existingRestore; do not duplicate its
argv/resume logic.- The "shutdown-saved" marker is the presence of a
session_worktrees
row for that session. Today nothing else writessession_worktrees
rows, so a row existing == "this session was saved by SaveAndTeardownAll".
A session the user killed earlier (already terminated when the save ran)
is skipped by the save and has no row, so RestoreAll skips it too. Do NOT
gate onpreserved_refbeing non-empty: a clean worktree at shutdown
writes a row with an emptypreserved_refand must still be restored.
No new column is needed (consistent with Task 6 leavingstatealone).
Check: Go test with fakes asserting (a) save calls capture-then-force in
order and writes preserved_ref before ForceDestroy, (b) RestoreAll restores BOTH
a worker and an orchestrator, (c) a session the user killed before shutdown is
not resurrected.
- The "shutdown-saved" marker is the presence of a
Task 4 — Wire into daemon boot/shutdown (daemon.go)
- After
startSessionreturns and beforesrv.Run(ctx): callRestoreAll
(best-effort; log failures; never block boot). - After
srv.Run(ctx)returns and before the store closes: call
SaveAndTeardownAllwith a fresh bounded context (not the cancelledctx). - Expose the manager (or a minimal
LifecycleSaver/LifecycleRestorerseam)
from the wiring up toRun.
Check: Manual run documented in report — spawn a session, edit a tracked
file + add a new file,POST /shutdown; assert worktree removed and
refs/ao/preserved/<id>exists; restart daemon; assert worktree re-created and
both edits reapplied. Plusgo build ./backend/...green.
Task 5 — Frontend: call /shutdown before kill (main.ts)
In before-quit: event.preventDefault() once, await fetch( http://127.0.0.1:<port>/shutdown, {method:'POST'}) with an ~8s bounded timeout
(port from the running.json the app already reads), then killDaemon +
app.exit(). Keep the process.on('exit') SIGTERM fallback intact.
Check: cd frontend && <typecheck cmd> green; manual: quit the app, daemon
log shows the save ran and exited cleanly (not just SIGTERM-killed).
Task 6 — Trim the over-built session_worktrees.state enum usage
No schema change. Ensure the save/restore code reads/writes only preserved_ref
and leaves state at its default; add ponytail: comments noting the enum is
unused multi-repo scaffolding.
Check: go test ./backend/internal/storage/... still green.
Edge cases the lean version must still handle
- Crash mid-shutdown: per-session capture-then-destroy with DB commit as the
commit point. Processed sessions recover via ref; unprocessed keep live
worktrees. No third lossy state. - User manually deleted a worktree dir:
workspace.Restorere-adds from the
branch; stray non-worktree dir → it refuses, restore loop logs and skips. - Base branch moved: worktree re-added on the session's own branch; restores
to the agent's last state regardless of base. - Orchestrator vs workers: no kind filter in either loop.
- Preserved diff conflicts on apply: keep the ref, leave conflict markers,
still relaunch the agent. Never delete the ref on failed apply. - Incomplete session (no branch/path): skipped on both save and restore.
Net change
Added: 2 adapter methods (ForceDestroy, StashUncommitted/ApplyPreserved),
2 manager methods (SaveAndTeardownAll, RestoreAll), 2 daemon call sites,
1 frontend fetch. Reuses ListAllSessions, session_worktrees.preserved_ref,
manager.Restore, the LCM terminate path, and the existing /shutdown
endpoint. No new file, migration, format, or endpoint.
Build & verify commands (from repo root; see AGENTS.md for the full list)
npm run lint— backendgo test ./...+ golangci-lint v2.12.2cd backend && go build ./.../go test ./.../go test -race ./.../
go vet ./...npm run frontend:typecheck— frontend TypeScript check (Task 5)- Do NOT hand-edit
backend/internal/storage/sqlite/gen/*. This plan adds no
new queries/migrations, sonpm run sqlcshould not be needed; if a task
finds it does need a new query, changequeries/*and runnpm run sqlc. - This plan adds NO new HTTP routes, so the OpenAPI/
npm run apiflow and the
internal/httpdspec-drift tests should stay green untouched. If a reviewer
sees spec drift, a task wrongly added a route.
Starting point for the implementing session
- Baseline: this plan and the cleanup are committed on
main(the plan file
lives atdocs/plans/session-lifecycle-persistence.md). Branch offmain
asfeat/session-lifecycle-persistence. - The file:line references above are approximate (prefixed
~). Verify each
with codegraph or grep before editing; the daemon is loopback-only and the
store is sqlc-generated, so confirm signatures rather than assuming. - Use the
superpowers:subagent-driven-developmentskill to execute: fresh
implementer subagent per task, task review (spec + quality) per task, then a
final whole-branch review. Subagents follow TDD.
Execution order
Tasks are sequential where coupled: Task 2 shares the gitworktree adapter with
Task 1 (do 1 then 2, same package); Task 3 depends on 1 + 2; Task 4 depends on 3. Task 5 (frontend) and Task 6 (storage cleanup) are independent and can run
anytime. Suggested order: 1 → 2 → 3 → 4, then 5 and 6.