Worker: persistent per-user home + dotfiles materialization

- Each job/terminal now mounts a persistent per-user home (${workdir}/homes/
  {username}) at /home/{username}; the thread checkout lives at
  ~/Code/{spoon}/{branch} so every thread shows up as a folder in one home and
  dotfiles/tools/nvim plugins persist across sessions
- docker.ts helpers + git.ts cloneRepository take container home/cwd + dir name
  (backward-compatible defaults); codex/opencode/terminal use the per-user paths
- new user-environment.ts: fetchUserEnvironment (worker-token Convex action) +
  materializeUserHome — ensures ~/.bash_profile, applies the editable overlay
  files, and (hashed/idempotent) clones the public dotfiles repo + runs the
  setup command inside the job image
- stopWorkspace no longer deletes the home; only the container stops
- Verified: codex runs a real turn under the new /home/{user} + ~/Code layout;
  overlay .bashrc loads in the interactive shell
This commit is contained in:
Gabriel Brown
2026-06-24 09:51:39 -04:00
parent 683fc62129
commit 4c0de2cbf3
5 changed files with 224 additions and 33 deletions
+14 -6
View File
@@ -11,7 +11,6 @@ import { getTerminalWorkspace } from './worker';
const TERMINAL_IMAGE = env.terminalImage;
const IDLE_STOP_MS = env.terminalIdleMs;
const CONTAINER_WORKDIR = '/workspace/repo';
const docker = new Docker();
@@ -21,7 +20,11 @@ const containerName = (jobId: string) =>
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
const sessions = new Map<string, Session>();
const ensureTerminalContainer = async (jobId: string, workdir: string) => {
const ensureTerminalContainer = async (
jobId: string,
workdir: string,
containerHome: string,
) => {
const name = containerName(jobId);
const container = docker.getContainer(name);
const info = await container.inspect().catch(() => null);
@@ -35,11 +38,11 @@ const ensureTerminalContainer = async (jobId: string, workdir: string) => {
name,
Image: TERMINAL_IMAGE,
Cmd: ['sleep', 'infinity'],
WorkingDir: CONTAINER_WORKDIR,
WorkingDir: containerHome,
Tty: false,
Labels: { 'spoon.agent.terminal': jobId },
HostConfig: {
Binds: [`${source}:/workspace${suffix ? `:${suffix}` : ''}`],
Binds: [`${source}:${containerHome}${suffix ? `:${suffix}` : ''}`],
NetworkMode: env.network,
Memory: 4 * 1024 * 1024 * 1024,
AutoRemove: false,
@@ -81,7 +84,11 @@ const bridge = async (ws: WebSocket, jobId: string) => {
let stream: Duplex | undefined;
let exec: Docker.Exec | undefined;
try {
const container = await ensureTerminalContainer(jobId, workspace.workdir);
const container = await ensureTerminalContainer(
jobId,
workspace.workdir,
workspace.containerHome,
);
exec = await container.exec({
// Reattach a persistent tmux session across reconnects when tmux is
// available; otherwise fall back to a plain login shell.
@@ -94,9 +101,10 @@ const bridge = async (ws: WebSocket, jobId: string) => {
AttachStdout: true,
AttachStderr: true,
Tty: true,
WorkingDir: CONTAINER_WORKDIR,
WorkingDir: workspace.containerRepo,
Env: [
'TERM=xterm-256color',
`HOME=${workspace.containerHome}`,
...workspace.secrets.map((s) => `${s.name}=${s.value}`),
],
});