Skip to content

feat: project-less scratch sessions#2808

Open
AgentWrapper wants to merge 1 commit into
mainfrom
ao/agent-orchestrator-23/scratch-sessions
Open

feat: project-less scratch sessions#2808
AgentWrapper wants to merge 1 commit into
mainfrom
ao/agent-orchestrator-23/scratch-sessions

Conversation

@AgentWrapper

Copy link
Copy Markdown
Owner

Implements #2803.

Adds a built-in Scratch pseudo-project so users can spawn agent sessions without registering a repo first.

Backend

  • New domain constants: ProjectKindScratch and ScratchProjectID.
  • Session service defaults empty projectId to scratch and falls back to the daemon's configured default harness.
  • Session manager creates a throwaway bare repo under ~/.ao/scratch// per scratch session; existing gitworktree/branch machinery is reused unchanged.
  • Project service pins Scratch at the top of List results.
  • Spawn controller normalizes empty projectId to scratch.
  • gitworktree Create now honors cfg.RepoPath so scratch sessions can supply their per-session repo.

CLI

  • ao spawn falls back to Scratch when no project context is available.
  • Scratch spawns with no explicit agent skip local preflight; harness resolution is deferred to the daemon.
  • CWD project resolution skips the Scratch pseudo-project.

Tests

  • New scratch spawn coverage in service/session, session_manager, and CLI.
  • New project service test for the pinned Scratch summary.
  • Updated HTTP controller project list/delete assertions to account for the pinned Scratch entry.
  • Spawn tests are isolated from inherited AO_PROJECT_ID/AO_SESSION_ID.

Verification

  • go test ./... passes in backend.
  • golangci-lint reports no new issues in changed packages.
  • OpenAPI spec/schema.ts unchanged (ProjectKind is an open string, no enum drift).
  • Frontend typecheck could not run because node_modules is not installed in this worktree; this is a pre-existing environment issue unrelated to the change.

Closes #2803

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 AgentWrapper left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Scratch sessions cannot be killed, saved on shutdown, or restored. Spawn passes the bare-repo path to Workspace.Create via cfg.RepoPath, but nothing persists it. workspaceInfo(rec) (manager.go:2569) builds ports.WorkspaceInfo without RepoPath, so the adapter falls back to repoPath(info.ProjectID) and the daemon-wired projectRepoResolver errors with no project registered with id "scratch". Consequences:

    • Kill destroys the runtime, then workspace.Destroy errors, so Kill returns before MarkTerminated — a zombie session (dead runtime, non-terminated, un-removable from the dashboard).
    • SaveAndTeardownAll/boot reconcile: StashUncommitted errors, so scratch sessions are never shutdown-saved.
    • RestoreAll builds its WorkspaceConfig without RepoPath, so restore fails.
    • In-spawn rollback (destroySpawnWorkspace) fails too, since Workspace.Create returns info without RepoPath (workspace.go:145).

    The new manager test only exercises Spawn, which is why this went unnoticed. Persist the repo path (e.g. in SessionMetadata or the session_worktrees row) and thread it through workspaceInfo/restore config — or derive <dataDir>/scratch/<sessionID> for scratch sessions in those paths.

Should fix

  1. Reserved id not enforced: validateProjectID still accepts scratch, so ao project add --project-id scratch succeeds; the list then has two scratch entries and the registered project is unreachable (spawns silently land in the throwaway repo).
  2. List/Get inconsistency: GET /api/v1/projects includes scratch but GET /api/v1/projects/scratch returns 404. The CLI already special-cases around this; other consumers (frontend picker) will hit the 404.
  3. Disk leak: nothing ever removes <dataDir>/scratch/<sessionID>; every throwaway session permanently leaks a bare repo under ~/.ao/scratch.

Minor

  1. 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.
  2. 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 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — KillDestroy, SaveAndTeardownAllStashUncommitted/ForceDestroy, RestoreAllRestore — 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) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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{

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sessions): project-less scratch sessions — spawn an agent without registering a repo

2 participants