16 KiB
16 KiB
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
- 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-2homes/directory is never in that set.POST /cleanup(Settings → Worker) wipes every user's home. - Jobs wedge permanently. (a)
claimNextInternalcommitsclaimedbefore the claim action validates profile/secrets (convex/agentJobsNode.ts:64) — a validation throw strands the job; (b) no server-side timeout:lastHeartbeatAtis never read, the worker never callsheartbeatWorkspace, 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 jobrunning(worker.ts:1732,agentJobs.ts:1423). - Cancel is ignored. The worker is never told about
cancel; its nextupdateStatuswrite flips the job out ofcancelledand the container keeps running (agentJobs.ts:925,1120).
High
- Auto-sync ignores settings.
autoSyncEnabled(default false),requireAiLowRiskForSync,requireCleanCompareForSync,autoReviewEnabledare never read;refreshOwnedSpoonfast-forwards unconditionally (convex/githubSync.ts:205). - Symlink path escape.
safeWorkspacePath/safeHomeJoinvalidate 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. - 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:161usesdocument.fonts.ready). - Terminal resize after connect is a no-op worker-side: PTY size is set
once via
sttyat launch; resize messages only mutate local vars (apps/agent-worker/src/terminal.ts:40-64,103). - Box lifecycle races/leaks.
acquireUserBox/ensureUserContaineraren't concurrency-safe (duplicatedocker 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 allspoon-box-*containers (user-container.ts:15-34,terminal.ts:66-98,runtime/docker.ts:341). - 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
- Exec timeout/abort kills the local
docker execclient, not the process in the box; box lacks--init, so zombies accumulate (runtime/docker.ts:385). - OpenCode still uses the pre-Phase-2 per-job
spoon-agent-job-{jobId}container — two containers per OpenCode job (worker.ts:620-696). - Drift >100 commits yields a wrong head SHA (
githubClient.ts:122), and the thread-dedup fallback keyDate.now()(githubSync.ts:232) creates a new maintenance thread + job every scheduled run. - No GitHub webhooks;
gitConnections.statusnever leavesactive; no rate-limit retry/backoff. - Deleted/foreign spoon or thread URLs throw to
global-error.tsx(no(app)error.tsx/not-found.tsx). - Failed initial turn leaves
activeWorkspacesentry 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. - Materialized secret env files persist in plaintext in the persistent home
(
worker.ts:1167). - Settings forms seed
useStatefrom 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.). updateStatusaccepts arbitrary transitions; worker-token compare untrimmed in one place (userDotfilesNode.ts:22);getEnvironmentForJobdoesn't bind to the claiming worker; unbounded owner-wide.collect()s;podman :Zrelabel conflicts on shared mounts;redact.ts$1replacement for group-less patterns; podman/host_port dev path diverges from prod.
Low / cleanup
- Dead code:
/updates,/agents,/settings/ai,(auth)/profileroutes;components/landing/tech-stack.tsx; emptycomponents/agents|updatesdirs; unused per-job docker helpers (runInJobContainer,streamInJobContainer,execInWorkspaceContainer,inspectWorkspaceContainer); env varsterminalImage,terminalIdleMs;maxConcurrentJobsunused (serial loop). - 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.
cleanupOrphanedWorkspacesbecomes layout-aware: it never removeshomes/or anyhomes/{username}root; it may remove (a) legacy pre-Phase-2 top-level job dirs, and (b) per-thread checkoutshomes/*/Code/{spoon}/{branch}whose thread/job is terminal — and only for users with no running box. Health and cleanup enumeratespoon-box-*. - Job lifecycle safety.
- Validate everything (spoon exists, profile exists/enabled, secrets
decryptable) inside
claimNextInternalbefore patching, so a bad job goesfailedwith a reason instead of wedgingclaimed. - Worker heartbeats every ~30s per active workspace (
heartbeatWorkspace). - New recovery cron: non-terminal jobs with stale heartbeat (or
claimedwith no heartbeat within N minutes) →timed_out; thread status follows; bounded automatic requeue for never-started claims. updateStatusvalidates 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). applyMaintenanceDecisioncloses the job (terminal status) and stops the workspace.
- Validate everything (spoon exists, profile exists/enabled, secrets
decryptable) inside
- Sync settings honored. Auto-sync requires
autoSyncEnabled(and therequire*flags when set); maintenance/AI review gated onautoReviewEnabled. Migration: setautoSyncEnabled=trueon 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 (
fileGET/PUT, tree, dotfile overlay writes) resolvesfs.realpathof the parent and re-checks containment; reject symlinked targets for writes. - Terminal sizing.
- Client: after
term.open(), re-runfit()+ send resize ondocument.fonts.readyand on Nerd-Font load; first fit inrequestAnimationFrame. - Worker: replace the
script -qfcpipe 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.
- Client: after
- 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 existingspoon-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 returnnullfor missing/unowned; UI distinguishes loadingundefinedfrom missingnull). - Worker-token compare unified through one trimming helper;
getEnvironmentForJobassertsclaimedBy.
Phase 2 — Runtime unification (one box, three agents)
AgentRuntimeadapter interface (worker):prepareAuth(workspace),runTurn(workspace, prompt, onEvent) → TurnResult,abort(workspace). All adapters exec into the user's box viastreamExecInContainer; process-group kill on timeout/abort (in-boxpkill-by-marker or setsid + kill), never just the local client.- Codex adapter: current
runCodexTurnrefactored behind the interface. - OpenCode adapter: runs OpenCode inside the box (non-interactive
opencode run --format json, oropencode serveexec'd in-box if session continuity requires it). Delete:startWorkspaceContainerper-job path,spoon-agent-job-{jobId}naming,containerAccesshost_port/network split and related env, dead per-job docker helpers. - Claude Code adapter:
claude -p --output-format stream-jsonin 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
/machinepage: 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|terminalroutes 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 viaGITHUB_APP_WEBHOOK_SECRET):pushon watched repos → immediate targeted refresh;installation/installation_repositorieslifecycle →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:
notificationstable + 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.tsgroup-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
(
normalizeRunResulteverywhere; noexitCode ?? 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-containerto 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).