# Spoon: Stabilize → Unify → Build

**Date:** 2026-07-10
**Status:** Approved (design review with Gib, 2026-07-10)

## Purpose

Fix the bugs found in the 2026-07-10 full audit of `apps/next`, `apps/agent-worker`,
and `packages/backend/convex`, finish migrating the system onto the Phase-2
"one long-running per-user box" architecture, and build the remaining features
that make Spoon the intended product: a self-hosted site where each user owns a
persistent Fedora container (dotfiles, terminal, agent CLIs) and uses it to keep
forks close to upstream and to do agent-driven or manual work on projects.

## Decisions (from design review)

- **Tenancy:** single instance shared by the owner + trusted friends. Per-user
  boxes/homes must be correct; no hostile-tenant hardening (shared Docker daemon
  is acceptable), but no cross-user data exposure either (the symlink escape must
  be fixed).
- **Agent runtimes:** Codex, OpenCode, **and Claude Code**, all exec'ing into the
  per-user box through one adapter interface. The legacy per-job OpenCode
  container path is deleted.
- **Feature scope:** all four tracks — standalone dev-box page, persistent
  terminal UX, upstream-sync correctness, notifications & polish.
- **Ordering:** stabilize first, then unify the runtime, then build features.
  Each phase ships working.

## Audit summary (what this design fixes)

Severity-ranked highlights; file references are as of commit `b092955`.

### Critical

1. **Worker cleanup deletes all user homes.** `cleanupOrphanedWorkspaces`
   (`apps/agent-worker/src/worker.ts:1931`) rm -rf's any top-level workdir entry
   not in the active set; the Phase-2 `homes/` directory is never in that set.
   `POST /cleanup` (Settings → Worker) wipes every user's home.
2. **Jobs wedge permanently.** (a) `claimNextInternal` commits `claimed` before
   the claim action validates profile/secrets (`convex/agentJobsNode.ts:64`) — a
   validation throw strands the job; (b) no server-side timeout: `lastHeartbeatAt`
   is never read, the worker never calls `heartbeatWorkspace`, no recovery cron
   exists — a worker crash strands jobs and blocks their threads forever;
   (c) a parsed maintenance review resolves the thread but leaves the job
   `running` (`worker.ts:1732`, `agentJobs.ts:1423`).
3. **Cancel is ignored.** The worker is never told about `cancel`; its next
   `updateStatus` write flips the job out of `cancelled` and the container keeps
   running (`agentJobs.ts:925`, `1120`).

### High

4. **Auto-sync ignores settings.** `autoSyncEnabled` (default false),
   `requireAiLowRiskForSync`, `requireCleanCompareForSync`, `autoReviewEnabled`
   are never read; `refreshOwnedSpoon` fast-forwards unconditionally
   (`convex/githubSync.ts:205`).
5. **Symlink path escape.** `safeWorkspacePath` / `safeHomeJoin` validate
   lexically then follow symlinks (`worker.ts:1059`,
   `user-environment.ts:35`) — a repo symlink lets file routes read/write worker
   secrets and other users' homes.
6. **Terminal renders quarter-size.** xterm measures cell size at `fit()` before
   the Victor Mono webfont loads and nothing re-fits
   (`apps/next/src/components/agent-workspace/workspace-terminal.tsx:145`). The
   ResizeObserver only sees container-box changes. Monaco already has the fix
   pattern (`monaco-theme.ts:161` uses `document.fonts.ready`).
7. **Terminal resize after connect is a no-op** worker-side: PTY size is set
   once via `stty` at launch; resize messages only mutate local vars
   (`apps/agent-worker/src/terminal.ts:40-64,103`).
8. **Box lifecycle races/leaks.** `acquireUserBox`/`ensureUserContainer` aren't
   concurrency-safe (duplicate `docker run`, reaper TOCTOU); terminal disconnect
   during acquire leaks a ref forever; refcount is a clamped shared counter, so
   double-release reaps a box under an active user; worker restarts orphan all
   `spoon-box-*` containers (`user-container.ts:15-34`, `terminal.ts:66-98`,
   `runtime/docker.ts:341`).
9. **Workspace shell isn't keyed by job.** Thread A → B navigation shows A's
   files and overwrites B's persisted UI state with A's
   (`threads/[threadId]/page.tsx:56`, `agent-workspace-shell.tsx:88,280`).

### Medium

10. Exec timeout/abort kills the local `docker exec` client, not the process in
    the box; box lacks `--init`, so zombies accumulate (`runtime/docker.ts:385`).
11. OpenCode still uses the pre-Phase-2 per-job `spoon-agent-job-{jobId}`
    container — two containers per OpenCode job (`worker.ts:620-696`).
12. Drift >100 commits yields a wrong head SHA (`githubClient.ts:122`), and the
    thread-dedup fallback key `Date.now()` (`githubSync.ts:232`) creates a new
    maintenance thread + job every scheduled run.
13. No GitHub webhooks; `gitConnections.status` never leaves `active`; no
    rate-limit retry/backoff.
14. Deleted/foreign spoon or thread URLs throw to `global-error.tsx` (no `(app)`
    `error.tsx`/`not-found.tsx`).
15. Failed initial turn leaves `activeWorkspaces` entry with released box ref
    (`worker.ts:1410-1437`); terminal PTY destroyed on tab switch (Radix tabs
    unmount); diff/tree loads race with no ordering guard; editor never refreshes
    a file the agent rewrote; recovery panel over-triggers on one transient
    status error.
16. Materialized secret env files persist in plaintext in the persistent home
    (`worker.ts:1167`).
17. Settings forms seed `useState` from late-resolving queries and can save
    defaults over real config (`spoon-agent-settings-form.tsx:61`); several
    fire-and-forget mutations toast success unconditionally
    (`dotfiles-manager.tsx:161`, `ai-provider-profiles-panel.tsx:235`, etc.).
18. `updateStatus` accepts arbitrary transitions; worker-token compare untrimmed
    in one place (`userDotfilesNode.ts:22`); `getEnvironmentForJob` doesn't bind
    to the claiming worker; unbounded owner-wide `.collect()`s;
    `podman :Z` relabel conflicts on shared mounts; `redact.ts` `$1` replacement
    for group-less patterns; podman/host_port dev path diverges from prod.

### Low / cleanup

19. Dead code: `/updates`, `/agents`, `/settings/ai`, `(auth)/profile` routes;
    `components/landing/tech-stack.tsx`; empty `components/agents|updates` dirs;
    unused per-job docker helpers (`runInJobContainer`, `streamInJobContainer`,
    `execInWorkspaceContainer`, `inspectWorkspaceContainer`); env vars
    `terminalImage`, `terminalIdleMs`; `maxConcurrentJobs` unused (serial loop).
20. UX copy: sign-in form wipes input on failure; "phone" instead of email in
    forgot-password; "Confirm Passsword" typo; "Signing Up..." on Verify Email;
    avatar label blank for empty-string names; `role='link'` with nested
    interactive elements; zero-state flash while dashboards load.

## Design

### Phase 1 — Stabilize

Targeted fixes on the current architecture, in small independent commits.

- **Cleanup rewrite.** `cleanupOrphanedWorkspaces` becomes layout-aware: it
  never removes `homes/` or any `homes/{username}` root; it may remove
  (a) legacy pre-Phase-2 top-level job dirs, and (b) per-thread checkouts
  `homes/*/Code/{spoon}/{branch}` whose thread/job is terminal — and only for
  users with no running box. Health and cleanup enumerate `spoon-box-*`.
- **Job lifecycle safety.**
  - Validate everything (spoon exists, profile exists/enabled, secrets
    decryptable) inside `claimNextInternal` before patching, so a bad job goes
    `failed` with a reason instead of wedging `claimed`.
  - Worker heartbeats every ~30s per active workspace (`heartbeatWorkspace`).
  - New recovery cron: non-terminal jobs with stale heartbeat (or `claimed` with
    no heartbeat within N minutes) → `timed_out`; thread status follows; bounded
    automatic requeue for never-started claims.
  - `updateStatus` validates transitions; writes to terminal-status jobs are
    rejected (fixes cancel-revert).
  - Heartbeat response carries `cancelRequested`; the worker tears down the
    workspace (abort turn, kill in-box processes for that job, release box).
  - `applyMaintenanceDecision` closes the job (terminal status) and stops the
    workspace.
- **Sync settings honored.** Auto-sync requires `autoSyncEnabled` (and the
  `require*` flags when set); maintenance/AI review gated on `autoReviewEnabled`.
  Migration: set `autoSyncEnabled=true` on existing spoons and flip the default
  for new GitHub-created spoons, preserving today's observed behavior while
  making it controllable.
- **Symlink containment.** All worker file access (`file` GET/PUT, tree, dotfile
  overlay writes) resolves `fs.realpath` of the parent and re-checks containment;
  reject symlinked targets for writes.
- **Terminal sizing.**
  - Client: after `term.open()`, re-run `fit()` + send resize on
    `document.fonts.ready` and on Nerd-Font load; first fit in `requestAnimationFrame`.
  - Worker: replace the `script -qfc` pipe hack with a real TTY exec via
    dockerode (`exec.start({Tty:true})` + `exec.resize({h,w})`), making resize
    messages actually resize the PTY. tmux reattach behavior unchanged.
- **Workspace shell keyed by job.** `<AgentWorkspaceShell key={jobId} .../>` on
  both mounting routes.
- **Box lifecycle correctness.** Per-username async mutex serializing
  acquire/ensure/release/reap; acquisitions return a handle whose release is
  idempotent (no shared counter clamp); ref registered before any await
  completes the acquire path (disconnect during acquire releases properly);
  boxes run with `--init`; on startup the worker reconciles existing
  `spoon-box-*` (adopt into the registry with zero refs → idle reap applies).
- **Not-found handling.** `(app)/error.tsx` + friendly not-found states for
  spoon/thread detail (queries return `null` for missing/unowned; UI
  distinguishes loading `undefined` from missing `null`).
- **Worker-token compare** unified through one trimming helper;
  `getEnvironmentForJob` asserts `claimedBy`.

### Phase 2 — Runtime unification (one box, three agents)

- **`AgentRuntime` adapter interface** (worker):
  `prepareAuth(workspace)`, `runTurn(workspace, prompt, onEvent) → TurnResult`,
  `abort(workspace)`. All adapters exec into the user's box via
  `streamExecInContainer`; process-group kill on timeout/abort (in-box
  `pkill`-by-marker or setsid + kill), never just the local client.
- **Codex adapter:** current `runCodexTurn` refactored behind the interface.
- **OpenCode adapter:** runs OpenCode inside the box (non-interactive
  `opencode run --format json`, or `opencode serve` exec'd in-box if session
  continuity requires it). Delete: `startWorkspaceContainer` per-job path,
  `spoon-agent-job-{jobId}` naming, `containerAccess` host_port/network split
  and related env, dead per-job docker helpers.
- **Claude Code adapter:** `claude -p --output-format stream-json` in the box.
  New provider-profile kind: Anthropic API key, or pasted OAuth credentials
  (same encrypted-snapshot pattern as Codex auth.json). CLI added to the Fedora
  job image, version-pinned.
- **Runtime picker:** thread creation + per-spoon agent settings select
  codex/opencode/claude; provider profiles declare which runtimes they satisfy.
  Queue-time validation rejects runtime/profile mismatches (no more run-time
  discovery of legacy `openai_direct`).
- **Secrets hygiene:** materialized env files written `0600`, deleted on
  workspace stop, re-materialized per run; never committed (existing staging
  guard stays).

### Phase 3 — Dev box as a first-class surface

- **`/machine` page:** box status (running/stopped, image, uptime, mem cap),
  start/stop/restart actions, full-page terminal rooted at `~`, and a
  home-scoped file browser (reuse FileTree/CodeEditor against new worker
  `/box/*` file routes). Worker gains user-scoped HMAC terminal tokens and
  `/box/tree|file|terminal` routes with the same ownership proxy pattern
  (Next mints tokens after Convex auth; browser still never sees worker
  secrets).
- **Persistent workspace terminal:** terminal tab `forceMount` + CSS hide, so
  the PTY/scrollback survive tab switches; `fit()` + resize-send when the tab
  becomes visible; "waiting for workspace" state distinct from "connecting".
- **Terminal QoL:** web links addon, copy-on-select toggle.

### Phase 4 — Upstream-sync correctness

- Head SHA fetched from the branch ref (not inferred from the truncated compare
  list); paginate compares where counts matter; stable dedup key for maintenance
  threads (merge-base + head digest; never `Date.now()`); skip thread creation
  when no head is resolvable.
- **GitHub webhooks** (Convex `httpAction`, signature-verified via
  `GITHUB_APP_WEBHOOK_SECRET`): `push` on watched repos → immediate targeted
  refresh; `installation`/`installation_repositories` lifecycle →
  `gitConnections.status = needs_reauth/revoked`, surfaced in
  Settings → Integrations. Hourly cron remains as fallback.
- Octokit retry + throttling plugins; sync status distinguishes
  "rate-limited (will retry)" from hard errors.
- Fix unbounded `.collect()`s on hot paths (indexed, paginated).

### Phase 5 — Notifications & polish

- **Notifications:** `notifications` table + in-app inbox (header bell), and
  email via the existing UseSend integration for: upstream moved / maintenance
  thread created, agent turn finished / needs input, sync failed,
  connection needs re-auth. Per-user preference toggles in settings. Web push
  deferred.
- **Polish sweep:** loading skeletons for dashboard/spoons/threads lists; error
  handling on all fire-and-forget mutations (toast on failure, success only
  after resolve); settings forms remount keyed on loaded record id; sign-in
  keeps input on failure + copy fixes ("phone" → email, Passsword typo,
  Verify Email pending text); editor buffer refresh when the agent edits an
  open non-dirty file (dirty files get a conflict flag); diff/tree request
  sequencing guard; recovery panel requires N consecutive failures; auto-scroll
  only when already near bottom; mobile workspace layout (collapsible file
  tree, viewport-relative min-height); avatar/`role='link'` accessibility fixes;
  delete dead routes/components/env vars; `redact.ts` group-less replacement
  fix; `'use server'` removed from profile page.
- **Docs:** README + docs/ rewritten for the box-first model (they still
  describe the per-job world); server-deploy notes updated (webhook URL, new
  env, Claude Code).

## Error-handling principles (cross-cutting)

- The Convex job state machine is authoritative; transitions are validated
  server-side and the worker conforms (never resurrects terminal jobs).
- UI never reports success before the awaited mutation resolves; background
  refresh failures surface after consecutive-failure thresholds, not instantly
  and not never.
- Worker spawn failures are always distinguishable from empty successes
  (`normalizeRunResult` everywhere; no `exitCode ?? 0`).

## Testing

- **Convex (convex-test):** claim validation/rollback, heartbeat-timeout cron,
  transition validation incl. cancel-vs-worker races, auto-sync gating matrix,
  maintenance-thread dedup, webhook signature handling.
- **Worker (vitest):** box registry mutex/handle semantics (concurrent
  acquire/release/reap), path containment incl. symlinks, runtime adapters'
  stream parsing (codex/opencode/claude fixtures), cleanup layout rules.
- **Next (component tests):** terminal fit-on-fonts-ready + resize-on-visible,
  workspace shell remount on job switch, form re-sync, not-found states.
- **Smoke:** extend `scripts/smoke-agent-container` to run one turn per runtime
  inside the box; manual checklist per phase in the plan.

## Out of scope

- Hostile multi-tenant isolation (gVisor/rootless per-user daemons).
- Non-GitHub provider automation; pushing agent branches to extra remotes.
- Mobile workspace editing; app-store release flow.
- Long-running preview stacks for forked projects.
- Web push notifications (email + in-app only for now).
