feat: project-less scratch sessions#2808
Conversation
Add a built-in Scratch pseudo-project so users can spawn agent sessions without registering a repo first. Backend: - Add ProjectKindScratch and ScratchProjectID domain constants. - Session service defaults an empty projectId to scratch and falls back to the daemon's default harness for scratch spawns. - Session manager creates a disposable bare repo under ~/.ao/scratch/<session> per scratch session and passes RepoPath to the gitworktree adapter. - Project service pins a Scratch summary first in List results. - Spawn controller converts empty projectId to scratch. - gitworktree Create now honors cfg.RepoPath for scratch sessions. CLI: - ao spawn falls back to the Scratch pseudo-project when no project context is available. - Scratch spawns with no explicit agent skip local agent preflight, deferring harness resolution to the daemon. - CWD project resolution skips the Scratch pseudo-project. Tests: - Add coverage for scratch spawn in service, session manager, CLI, and project service. - Update existing project list/delete assertions to account for the pinned Scratch entry. - Isolate spawn tests from inherited AO_PROJECT_ID/AO_SESSION_ID. Closes #2803
AgentWrapper
left a comment
There was a problem hiding this comment.
Review: changes requested
The feature shape is good — reusing the existing gitworktree machinery by threading cfg.RepoPath through is the right call, and the spawn-path test coverage is solid. But there is one blocking lifecycle bug: the per-session repo path is never persisted, so every workspace operation after spawn (kill, shutdown save, restore, even in-spawn rollback) fails to resolve the repo for scratch sessions. Details in the inline comments; summary below.
Blocker
-
Scratch sessions cannot be killed, saved on shutdown, or restored. Spawn passes the bare-repo path to
Workspace.Createviacfg.RepoPath, but nothing persists it.workspaceInfo(rec)(manager.go:2569) buildsports.WorkspaceInfowithoutRepoPath, so the adapter falls back torepoPath(info.ProjectID)and the daemon-wiredprojectRepoResolvererrors withno project registered with id "scratch". Consequences:Killdestroys the runtime, thenworkspace.Destroyerrors, so Kill returns beforeMarkTerminated— a zombie session (dead runtime, non-terminated, un-removable from the dashboard).SaveAndTeardownAll/boot reconcile:StashUncommittederrors, so scratch sessions are never shutdown-saved.RestoreAllbuilds itsWorkspaceConfigwithoutRepoPath, so restore fails.- In-spawn rollback (
destroySpawnWorkspace) fails too, sinceWorkspace.Createreturns info withoutRepoPath(workspace.go:145).
The new manager test only exercises Spawn, which is why this went unnoticed. Persist the repo path (e.g. in
SessionMetadataor the session_worktrees row) and thread it throughworkspaceInfo/restore config — or derive<dataDir>/scratch/<sessionID>for scratch sessions in those paths.
Should fix
- Reserved id not enforced:
validateProjectIDstill acceptsscratch, soao project add --project-id scratchsucceeds; the list then has twoscratchentries and the registered project is unreachable (spawns silently land in the throwaway repo). - List/Get inconsistency:
GET /api/v1/projectsincludesscratchbutGET /api/v1/projects/scratchreturns 404. The CLI already special-cases around this; other consumers (frontend picker) will hit the 404. - Disk leak: nothing ever removes
<dataDir>/scratch/<sessionID>; every throwaway session permanently leaks a bare repo under~/.ao/scratch.
Minor
- The CLI now silently falls back to Scratch where it previously errored with guidance — a user spawning from inside an unregistered repo may expect the agent to work on that repo. A one-line notice would help.
- The hard-coded SHA-1 empty-tree oid breaks under
init.defaultObjectFormat = sha256.
Verified locally: backend builds and the new tests in cli, service/session, service/project, and session_manager all pass.
| } | ||
|
|
||
| func (m *Manager) createSessionWorkspace(ctx context.Context, project domain.ProjectRecord, cfg ports.SpawnConfig, id domain.SessionID, branch string) (ports.WorkspaceInfo, *ports.WorkspaceProjectInfo, error) { | ||
| if project.Kind.WithDefault() == domain.ProjectKindScratch { |
There was a problem hiding this comment.
Blocker: the scratch repo path is passed to Create here but never persisted, and workspaceInfo(rec) (line 2569) rebuilds WorkspaceInfo without RepoPath. Every post-spawn workspace op — Kill → Destroy, SaveAndTeardownAll → StashUncommitted/ForceDestroy, RestoreAll → Restore — then falls back to repoPath("scratch"), and the daemon-wired projectRepoResolver errors with no project registered with id "scratch". Net effect: a scratch session can be spawned but never killed (the runtime is destroyed, then Kill errors before MarkTerminated, leaving a zombie), never shutdown-saved, and never restored. Even the in-spawn rollback fails because Workspace.Create returns info without RepoPath (workspace.go:145). Persist the repo path (SessionMetadata or the session_worktrees row) and thread it through, or derive <dataDir>/scratch/<id> for scratch in those paths — and add a Kill/restore test for scratch, since the current test only covers Spawn.
| // ensureScratchRepo creates a disposable bare git repository for a scratch | ||
| // session under the data dir. The existing gitworktree adapter materialises the | ||
| // session worktree from this repo, so no workspace machinery changes are needed. | ||
| func (m *Manager) ensureScratchRepo(ctx context.Context, id domain.SessionID) (string, error) { |
There was a problem hiding this comment.
Nothing removes this directory when the session dies: Destroy only removes the worktree, so each scratch session permanently leaks a bare repo (plus any preserved refs) under ~/.ao/scratch. For sessions billed as throwaway, Kill (once fixed to work — see the blocker) should also delete the backing repo dir.
| // initEmptyCommit creates an initial empty commit on the default branch in a | ||
| // bare repository so the gitworktree adapter has a base ref to create worktrees. | ||
| func initEmptyCommit(ctx context.Context, repoPath string) error { | ||
| emptyTree := "4b825dc642cb6eb9a060e54bf8d69288fbee4904" |
There was a problem hiding this comment.
Minor: this is the SHA-1 empty-tree oid; commit-tree fails in repos created under init.defaultObjectFormat = sha256. git -C <repo> hash-object -t tree /dev/null (or mktree with empty input) is format-agnostic.
| // ProjectKindScratch is the built-in project-less pseudo-project. | ||
| ProjectKindScratch ProjectKind = "scratch" | ||
| // ScratchProjectID is the reserved project id for the built-in Scratch space. | ||
| ScratchProjectID ProjectID = "scratch" |
There was a problem hiding this comment.
This id is reserved here but not enforced: validateProjectID in service/project still accepts scratch, so ao project add --project-id scratch --path <repo> succeeds. List then returns two entries with id scratch, and the registered project is unreachable — requireProject/loadProject short-circuit on the id, so spawns silently land in the throwaway repo instead of the registered one. Reject the reserved id in Add.
| } | ||
| out := make([]Summary, 0, len(projects)) | ||
| out := make([]Summary, 0, len(projects)+1) | ||
| out = append(out, Summary{ |
There was a problem hiding this comment.
List now includes scratch, but Get(ctx, "scratch") still returns PROJECT_NOT_FOUND — the CLI had to special-case skip it in resolveProjectFromCWD for exactly this reason, and any consumer that enumerates the list then fetches details (frontend picker) will hit a 404. Consider having Get serve the same synthesized summary.
| return projectDetails{}, usageError{fmt.Errorf("project could not be resolved; pass --project or run `ao project add --path <repo-path> --worker-agent <agent>`")} | ||
| // No registered project context: fall back to the built-in Scratch pseudo-project | ||
| // so a freeform `ao spawn --prompt "..."` works without project registration. | ||
| return scratchProjectDetails(), nil |
There was a problem hiding this comment.
Behavior change worth surfacing: ao spawn run from inside an unregistered repo used to fail with setup guidance; now it silently spawns into an empty throwaway repo. A user who expected the agent to work on the cwd repo gets a session that cannot see their code. Suggest printing a one-line notice (e.g. "no project resolved; spawning in Scratch") when this fallback triggers.
Implements #2803.
Adds a built-in Scratch pseudo-project so users can spawn agent sessions without registering a repo first.
Backend
CLI
Tests
Verification
Closes #2803