219 lines
12 KiB
Markdown
219 lines
12 KiB
Markdown
# 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)
|
|
|
|
1. **All save/restore logic lives in the daemon**, not the frontend. The daemon
|
|
owns the store, the gitworktree adapter, and the `git` binary. The frontend's
|
|
only new responsibility: call the existing `POST /shutdown` endpoint before
|
|
it kills the daemon, so the save runs gracefully (SIGTERM remains the
|
|
fallback and triggers the same daemon-side save path).
|
|
2. **The "last-stop manifest" is the existing SQLite state, not a new file.**
|
|
`ListAllSessions` already records id, kind (worker/orchestrator), harness,
|
|
`is_terminated`, and `Metadata{branch, workspacePath, agentSessionId,
|
|
prompt}`. The `session_worktrees` table already has a `preserved_ref` column
|
|
(migration 0009) that nothing currently writes. No manifest.json, no new
|
|
migration, no new format. The manifest is a query.
|
|
3. **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.
|
|
4. **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
|
|
over `git stash create`, which silently drops all untracked files, and over
|
|
`git stash push -u`, which mutates the worktree and the global stash stack.)
|
|
5. **Do not weaken the existing dirty-worktree refusal** used by interactive
|
|
`ao session kill` / `ao cleanup`. Add a separate `ForceDestroy` that the
|
|
shutdown path calls only AFTER the work is captured. Adding `--force` to the
|
|
shared remove path would silently destroy work in the interactive flows.
|
|
|
|
## Global Constraints (binding — reviewers enforce verbatim)
|
|
|
|
- App state resolves under `~/.ao` only (`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
|
|
existing `POST /shutdown`).
|
|
- The existing single-session `POST /sessions/{id}/restore` endpoint 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 (`worktreeRemoveArgs` deliberately omits `--force`)
|
|
- `backend/internal/ports/outbound.go` — `Workspace` interface (~line 120)
|
|
- `backend/internal/session_manager/manager.go` — Kill (~411-446), Cleanup
|
|
(~556-588), Restore (~451), dirty-refusal translation
|
|
- `backend/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_ref` CRUD (`UpsertSessionWorktree`)
|
|
- `backend/internal/domain/session.go`, `domain/project.go` — record + worktree
|
|
domain types
|
|
- `frontend/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`),
|
|
point `refs/ao/preserved/<id>` at it via `git 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, or `git 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 `.gitignore` does NOT reappear.
|
|
|
|
### Task 3 — `SaveAndTeardownAll` + `RestoreAll` on the session manager
|
|
|
|
- `SaveAndTeardownAll(ctx)`: `ListAllSessions`; for each live (non-terminated)
|
|
session with a non-empty `Metadata.WorkspacePath`: `StashUncommitted` →
|
|
`UpsertSessionWorktree(preserved_ref=...)` (commit) → `MarkTerminated`
|
|
(reuse the LCM path Kill uses) → runtime teardown → `ForceDestroy`. Mirror
|
|
`Kill` but 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`, `ApplyPreserved` if a preserve ref is recorded, then
|
|
`manager.Restore(ctx, id)`. Reuse existing `Restore`; 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 writes `session_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 on `preserved_ref` being non-empty: a clean worktree at shutdown
|
|
writes a row with an empty `preserved_ref` and must still be restored.
|
|
No new column is needed (consistent with Task 6 leaving `state` alone).
|
|
**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.
|
|
|
|
### Task 4 — Wire into daemon boot/shutdown (`daemon.go`)
|
|
|
|
- After `startSession` returns and before `srv.Run(ctx)`: call `RestoreAll`
|
|
(best-effort; log failures; never block boot).
|
|
- After `srv.Run(ctx)` returns and before the store closes: call
|
|
`SaveAndTeardownAll` with a fresh bounded context (not the cancelled `ctx`).
|
|
- Expose the manager (or a minimal `LifecycleSaver`/`LifecycleRestorer` seam)
|
|
from the wiring up to `Run`.
|
|
**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. Plus `go 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
|
|
|
|
1. 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.
|
|
2. User manually deleted a worktree dir: `workspace.Restore` re-adds from the
|
|
branch; stray non-worktree dir → it refuses, restore loop logs and skips.
|
|
3. Base branch moved: worktree re-added on the session's own branch; restores
|
|
to the agent's last state regardless of base.
|
|
4. Orchestrator vs workers: no kind filter in either loop.
|
|
5. Preserved diff conflicts on apply: keep the ref, leave conflict markers,
|
|
still relaunch the agent. Never delete the ref on failed apply.
|
|
6. 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` — backend `go test ./...` + golangci-lint v2.12.2
|
|
- `cd 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, so `npm run sqlc` should not be needed; if a task
|
|
finds it does need a new query, change `queries/*` and run `npm run sqlc`.
|
|
- This plan adds NO new HTTP routes, so the OpenAPI/`npm run api` flow and the
|
|
`internal/httpd` spec-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 at `docs/plans/session-lifecycle-persistence.md`). Branch off `main`
|
|
as `feat/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-development` skill 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.
|