2026 03 20 Internal Dev Server Architecture Design

Documentation for Plans

Internal Dev Server (IDS) — Architecture Design Spec

Date: 2026-03-20 Session: S244 Status: Draft Scope: Replace per-executive Firecracker microVMs with a shared, persistent, dedicated development server using git worktrees and Linux namespace isolation for internal gspace1 development.


Problem Statement

The current executive workspace VM plan provisions individual 12 GiB / 4 vCPU Firecracker microVMs per executive. This has five compounding problems:

  1. Slow cold start. 10s Firecracker boot + 5-10 min yarn install + git clone = executives wait minutes before Vitto can write code.
  2. Resource waste. Each VM duplicates the OS (~1 GiB), .git objects (~1.5 GiB), node_modules (~500 MB), and toolchain. Five executives = 5x duplication.
  3. Customer blast radius. Executive VMs share the Hetzner AX42-U (128 GiB) with customer-facing sandboxes. 6+ concurrent executives = host OOM, customer VMs affected.
  4. Wrong isolation model. Firecracker provides hardware-level isolation for untrusted multi-tenant workloads. Executives are trusted users on the same codebase — they need branch isolation, not kernel isolation.
  5. No admission control. MAX_CONTAINERS=30 counts units, not memory. The orchestrator will provision the 11th 12 GiB VM that OOM-kills the host.

Goal: 3-8 second session start (not minutes), 6x less memory per session, zero customer impact, proper branch isolation between executives.


Architecture Overview

Current Flow (Per-Executive Firecracker VM)

Executive sends WhatsApp message to Vitto
  channel-processing extracts platformRole
  tier-resolver selects 'vm' tier
  vm-session-manager.acquire()
    VmApiClient.createSandbox(memSizeMib=12288, vcpuCount=4)
      backend-ai-api proxy
        sandbox-orchestrator Firecracker microVM (cold boot, 10s)
          vm-init.sh: seed entropy, mount fs, start services
            (manual) git clone gspace1, yarn install (5-10 min)
              finally ready

Total time to first productive command: 5-11 minutes.

Proposed Flow (Internal Dev Server)

Executive sends WhatsApp message to Vitto
  channel-processing extracts platformRole
  tier-resolver selects 'ids' tier (new)
  ids-session-manager.acquire()
    IDS Agent REST API: POST /sessions { userId, branch }
      git worktree add /sessions/{id} -b session-{N}  (~1s)
      pnpm install (symlinks from global store)         (~2-5s)
      create namespace container (systemd-nspawn)        (~0.5s)
      return { sessionId, endpoint }
        ready

Total time to first productive command: 3-8 seconds.


Design Principles

  1. Separate workloads, separate infrastructure. Internal dev NEVER shares host with customer sandboxes.
  2. Share what is identical, isolate what is different. Same repo, same dependencies = shared. Different branches, different processes = isolated.
  3. Persistent, not ephemeral. The server is always on, always warm. Sessions are lightweight overlays.
  4. Additive, not destructive. Existing Firecracker infrastructure stays intact for customer VMs and standard developer tier. IDS is a new provider alongside it.

Infrastructure

Dedicated Server

Recommendation: Hetzner AX102 (~109 EUR/month, pre-April 2026 pricing)

Spec Value Rationale
CPU AMD Ryzen 9 7950X3D, 16C/32T 3D V-Cache excels at NX compilation workloads
RAM 128 GiB DDR5 10+ concurrent sessions with dev servers running
Storage 2x 1.92 TB NVMe Fast I/O for git, node_modules, NX cache
Network 1 Gbit/s Sufficient for git fetch, API calls
OS Ubuntu 22.04 LTS Match existing sandbox-orchestrator rootfs

Why not reuse the existing Hetzner AX42-U?

Pre-Installed Toolchain

Baked into the server on first setup (not per-session):

Component Version Notes
Node.js Match .nvmrc (currently 20 LTS) Via nvm for easy version switching
pnpm Latest stable Global virtual store for near-instant per-worktree installs
git Latest Worktree support
build-essential, python3 System packages Native Node addon compilation
jq, ripgrep, fd Latest Developer utilities
NX From monorepo Global NX cache shared across all worktrees

Core Components

1. Canonical Clone (Shared, Read-Only Base)

/workspace/gspace1/              -- always on main, auto-fetched
  .git/objects/                  -- shared object store (all worktrees reference this)
  .pnpm-store/                   -- content-addressable dependency store
  .nx/cache/                     -- shared NX computation cache
  node_modules/                  -- main branch dependencies (pnpm symlinks)

Maintenance cron (every 15 minutes):

cd /workspace/gspace1
git fetch --all --prune
pnpm install --frozen-lockfile  # update store if lockfile changed on main

This ensures new branches/commits are available to all worktrees within 15 minutes of being pushed.

2. Session Worktrees (Per-Executive, Isolated)

/workspace/sessions/
  ids-{userId}-{sessionNumber}/     -- git worktree, own branch
      node_modules/                 -- pnpm symlinks (instant)
      dist/                         -- per-session build output
      [full checkout]

Lifecycle:

  1. Create: git worktree add /workspace/sessions/{id} -b session-{N}
  2. Install: cd /workspace/sessions/{id} && pnpm install (2-5s, symlinks to global store)
  3. Use: Vitto runs commands in the worktree via namespace container
  4. Idle timeout: 24 hours of no activity triggers cleanup
  5. Cleanup: git worktree remove /workspace/sessions/{id} && git worktree prune

Branch rules:

3. Namespace Containers (Process + Network Isolation)

Each active session runs inside a lightweight Linux namespace container:

Host: Hetzner AX102

  nspawn: session-245             nspawn: session-246
  PID ns: isolated                PID ns: isolated
  Net ns: own stack               Net ns: own stack
  Ports: 3000-3099 (own lo)       Ports: 3000-3099 (own lo)
  cgroup: 8 GiB mem, 4 CPU       cgroup: 8 GiB mem, 4 CPU
  bind: /sessions/s-245/          bind: /sessions/s-246/

Isolation guarantees:

Dimension Mechanism Guarantee
Processes PID namespace Session A cannot see/kill session B's processes
Ports Network namespace Both sessions can bind port 3000 independently
Memory cgroup v2 limits One session's OOM does not affect others or the host
CPU cgroup v2 CPU shares Fair scheduling, no session can starve others
Filesystem Bind mount + read-only base Session sees only its worktree + shared read-only deps
Users UID mapping (optional) Session runs as non-root inside namespace

Why systemd-nspawn over Docker?

4. IDS Agent (REST API)

A lightweight NestJS service running on the dedicated server. Mirrors the sandbox-orchestrator's API surface for easy integration.

Endpoints:

Method Path Purpose
POST /sessions Create worktree + namespace container
GET /sessions/:id Get session status
DELETE /sessions/:id Destroy session (cleanup worktree + namespace)
POST /sessions/:id/exec Run command in session's namespace
POST /sessions/:id/read-file Read file from session's worktree
POST /sessions/:id/write-file Write file to session's worktree
GET /sessions/user/:userId Get active session for user (reuse)
POST /sessions/:id/git Git operations (commit, push, status, diff)
GET /health Health check + resource stats
GET /metrics Per-session memory, CPU, disk, uptime

Auth: API key (same pattern as sandbox-orchestrator's X-API-Key).

Config (.env):

IDS_API_KEY=<shared secret>
IDS_PORT=3020
WORKSPACE_BASE=/workspace/gspace1
SESSIONS_DIR=/workspace/sessions
SESSION_IDLE_TTL_HOURS=24
SESSION_MAX_MEMORY_MIB=8192        # per-session cgroup limit
SESSION_MAX_CPU_SHARES=4           # per-session CPU allocation
MAX_CONCURRENT_SESSIONS=15         # admission control
TOTAL_MEMORY_BUDGET_MIB=102400     # ~100 GiB budget (128 GiB - OS overhead)
GIT_FETCH_INTERVAL_MINUTES=15

Admission control (the fix for Gap 1 in current plan):

On POST /sessions:
  1. Check MAX_CONCURRENT_SESSIONS (count-based limit)
  2. Check TOTAL_MEMORY_BUDGET_MIB - sum(active session cgroup limits) >= SESSION_MAX_MEMORY_MIB
  3. If either fails: 503 Service Unavailable with reason

5. Command Execution Model

Commands run inside the session's namespace via machinectl shell:

IDS Agent receives: POST /sessions/{id}/exec { command: "nx build backend-vitto-api" }
  Validate command against allowlist (reuse VITTO_COMMAND_ALLOWLIST)
  machinectl shell ids-session-{id} /bin/bash -c "cd /workspace && nx build backend-vitto-api"
  Stream stdout/stderr back
  Return exit code + output

Security:


Integration with Existing Codebase

What Changes

Component Change Effort
vm-session-manager.service.ts Add conditional: if isIdsEligible() route to IdsApiClientService instead of VmApiClientService Small (15-20 lines)
New: IdsApiClientService HTTP client to IDS Agent (mirrors VmApiClientService API) Medium (~200 lines)
New: ids-session-manager.service.ts Session lifecycle: acquire (reuse/create), release, health check Medium (~150 lines)
sandboxed-tool-proxy.service.ts Add IDS routing: if session is IDS-backed, call IdsApiClient instead of VmApiClient Small (10-15 lines)
tier-resolver.service.ts (Optional) Add 'ids' tier alongside 'vm'. Or keep routing through existing 'vm' tier with provider abstraction Small
New: ids-agent Standalone NestJS service for the dedicated Hetzner server Medium (~500-800 lines)

What Stays Unchanged

The Key Routing Change

In vm-session-manager.service.ts:

async acquire(userId: string, template?: string, opts?: VmAcquireOptions): Promise<VmSession> {
  const useIds = isExecutiveFullStackWorkspaceVmEligible(
    opts?.platformRole,
    opts?.jwtRoles,
  );

  if (useIds) {
    // Route to Internal Dev Server (shared, persistent, fast)
    return this.idsSessionManager.acquire(userId, {
      branch: opts?.branch,
      platformRole: opts?.platformRole,
    });
  }

  // Existing Firecracker path for standard developer VMs (unchanged)
  // ...
}

The VmSession interface stays the same — both Firecracker and IDS return { vmId, userId, status }. Downstream code does not know or care which backend fulfilled the session.


pnpm Migration Strategy

The biggest performance enabler is pnpm with enableGlobalVirtualStore, which turns per-worktree dependency installation from 5-10 minutes to 2-5 seconds.

Why pnpm?

yarn classic (current) pnpm + globalVirtualStore
Per-worktree install time 3-10 min (full copy) 2-5 sec (symlinks)
Per-worktree disk for deps 500 MB+ Near zero (symlinks to shared store)
Lockfile format yarn.lock pnpm-lock.yaml
NX compatibility Full Full (NX supports pnpm natively)
Migration effort N/A Medium (lockfile conversion, CI updates)

Phased Approach

Phase A (immediate, no pnpm): Pre-built node_modules tarball from main branch. On session create, untar into worktree (~30-60s). Good enough for MVP.

Phase B (after validation): Migrate monorepo to pnpm. Update CI, lock file, package.json packageManager field. Session create drops to 2-5s.

Phase C (optimization): Enable pnpm's enableGlobalVirtualStore. One content-addressable store on disk, all worktrees are pure symlinks. Near-zero marginal disk per worktree.

The IDS architecture works with any phase. pnpm just makes it shine.


Session Lifecycle Details

Acquire (Executive Sends Message)

1. Check activeSessions map for userId
   HIT: return existing session (instant, 0 API calls)
   MISS: continue

2. Check IDS Agent: GET /sessions/user/{userId}
   HIT (idle session, <24h old): restart namespace, return (~1-2s)
   MISS: continue

3. Create new session:
   POST /sessions {
     userId,
     branch: "session-{N}",    // from SAGP protocol
   }

   IDS Agent:
     a. git fetch origin (incremental, ~1-2s)
     b. git worktree add /workspace/sessions/{id} -b session-{N}
     c. cd worktree && pnpm install (or untar node_modules)
     d. Create nspawn container with cgroup limits
     e. Return session handle

4. Store in activeSessions map
5. Return to caller

Execute (Vitto Runs Tools)

monorepo_run_command("nx test my-lib --watch=false")
  sandboxed-tool-proxy detects IDS-backed session
  idsApiClient.exec(sessionId, { command, cwd, timeoutMs })
  IDS Agent: machinectl shell in namespace
  Return stdout/stderr/exitCode

Release (Conversation Ends or Idle)

1. Vitto conversation ends: idsSessionManager.release(sessionId)
2. IDS Agent: POST /sessions/{id}/release
   a. Kill all processes in namespace (dev servers, watchers)
   b. Keep worktree on disk (uncommitted changes preserved)
   c. Mark session as "idle" with timestamp
3. Remove from activeSessions map

After 24h idle:
   a. git stash (preserve any uncommitted work)
   b. git worktree remove /workspace/sessions/{id}
   c. git worktree prune
   d. Remove nspawn config

Cleanup Cron (Every Hour)

# Remove idle sessions older than TTL
for session in /workspace/sessions/ids-*; do
  last_used=$(stat -c %Y "$session")
  age_hours=$(( (now - last_used) / 3600 ))
  if [ $age_hours -gt $SESSION_IDLE_TTL_HOURS ]; then
    git -C /workspace/gspace1 worktree remove "$session" --force
  fi
done
git -C /workspace/gspace1 worktree prune

Observability

IDS Agent /metrics Endpoint

{
  "host": {
    "totalMemoryMib": 131072,
    "usedMemoryMib": 24576,
    "cpuUsagePercent": 35,
    "diskUsedGib": 120,
    "diskTotalGib": 1920
  },
  "sessions": {
    "active": 3,
    "idle": 2,
    "maxConcurrent": 15,
    "memoryBudgetRemainingMib": 77824
  },
  "perSession": [
    {
      "sessionId": "ids-user1-245",
      "userId": "user1",
      "branch": "session-245",
      "status": "active",
      "memoryUsageMib": 3200,
      "cpuPercent": 12,
      "createdAt": "2026-03-20T10:00:00Z",
      "lastCommand": "nx serve backend-vitto-api",
      "lastCommandAt": "2026-03-20T14:30:00Z"
    }
  ]
}

Alerts

Condition Action
Host memory > 85% Warning to ops channel
Host memory > 95% Kill oldest idle session, alert
Session memory > cgroup limit OOM inside namespace (isolated, no host impact)
Disk usage > 80% Aggressive cleanup of idle worktrees (reduce TTL to 4h)
Git fetch fails 3x Alert: new branches/commits not propagating
IDS Agent unreachable Fallback to Firecracker VMs (existing path)

Fallback Strategy

If the IDS server is unreachable (maintenance, failure, network):

// In ids-session-manager.service.ts
async acquire(userId: string, opts?: IdsAcquireOptions): Promise<VmSession> {
  try {
    return await this.idsApiClient.createSession(userId, opts);
  } catch (error) {
    this.logger.warn(
      `IDS unreachable, falling back to Firecracker VM: ${error.message}`,
    );
    // Fall back to existing Firecracker path (slower but works)
    return this.vmSessionManager.acquireFirecracker(userId, opts);
  }
}

The Firecracker path stays intact as a fallback. Executives get slower sessions during IDS outages, but are never blocked.


Security Model

Trust Boundary

Executives are trusted internal users with platformRole === 'executive'. They already have:

The IDS server is inside the trust boundary. We are not adding new capabilities — just a faster backend.

Defense in Depth

Layer Mechanism
API access API key authentication (same as sandbox-orchestrator)
Command filtering Allowlist enforcement (reuse VITTO_COMMAND_ALLOWLIST)
Resource limits cgroup v2 per session (memory + CPU caps)
Process isolation PID namespace (sessions cannot see each other)
Network isolation Network namespace (sessions cannot access each other's ports)
Filesystem Bind-mount session worktree only; shared deps read-only
Git safety Worktree-level isolation; one branch per worktree enforced by git
Audit All commands logged with userId, sessionId, timestamp

What IDS Does NOT Protect Against


Comparison: Current Plan vs IDS

Metric Per-Exec Firecracker IDS (Proposed)
Session start ~10s boot + 5-10 min install 3-8 seconds
Memory per session 12 GiB dedicated 2-3 GiB incremental
RAM for 5 executives 60 GiB (on customer host!) 15-20 GiB (dedicated host)
Customer impact High (shared host) Zero (separate server)
Git clone per session Full (~1.5 GiB, 30-60s) Zero (shared objects)
Dependency install Full yarn install (3-10 min) 2-5s (pnpm) or 30-60s (tarball)
Monthly cost Implicit (eats customer capacity) ~109 EUR (dedicated, predictable)
Isolation model Hardware (Firecracker kernel) Namespace (cgroup + PID + net)
Admission control Count-only (MAX_CONTAINERS=30) Memory-aware (budget tracking)
Observability None Per-session metrics + alerts
Fallback None Auto-fallback to Firecracker

Implementation Plan

Phase 1: Dedicated Server + Basic IDS Agent (2-3 days)

Deliverables:

Phase 2: Namespace Isolation + Cgroup Limits (1 day)

Deliverables:

Phase 3: Dependency Optimization (independent timeline)

Deliverables:

Phase 4: Observability + Hardening (1 day)

Deliverables:


Risks and Mitigations

Risk Severity Probability Mitigation
IDS server goes down (SPOF) Medium Low Auto-fallback to Firecracker VMs. Hetzner dedicated servers have 99.9% SLA. Recovery: reboot or reprovision (~15 min).
Namespace escape (kernel exploit) Low Very low Executives are trusted users. If paranoid: add gVisor or user namespace mapping.
Stale worktrees accumulate disk Low Medium TTL-based cleanup cron (24h default). git worktree prune hourly. Aggressive cleanup at 80% disk usage.
pnpm migration is large Medium Medium Phase it: start with tarball-based node_modules (no migration needed), move to pnpm later. IDS works with either.
Two servers to maintain Low Certain IDS Agent is ~500-800 lines, far simpler than sandbox-orchestrator (no Firecracker, no TAP devices, no entropy seeding, no rootfs building).
Git worktree + submodule instability Medium Low gspace1 does not use git submodules. Non-issue.
Branch collision (two execs, same branch) Low Low Git enforces one branch per worktree. SAGP protocol ensures unique session branches.

Open Questions

  1. pnpm migration timeline. Should this be done before or after IDS? IDS works with yarn classic (tarball approach), but pnpm unlocks the full speed benefit.
  2. Dev server persistence. When an executive asks Vitto to yarn serve:backend-vitto-api, should the dev server survive across conversations? Current design: killed on session release. Alternative: keep running if session is "idle" (not "released").
  3. Multiple simultaneous sessions per executive. Current design: one session per userId (matching existing VmSessionManager). Should one executive be able to have two worktrees on different branches simultaneously?
  4. IDS Agent deployment. Standalone binary? Docker container on the Hetzner? systemd service? (Recommendation: systemd service, matching sandbox-orchestrator pattern.)
  5. NX remote cache. Should we set up NX Cloud or a local remote cache on the IDS server so that builds cached by one executive benefit all others?

Operational visibility (dedicated hosts)

GoGrab Dev → System → Hetzner (/infrastructure/hetzner): primary data path is self-hosted — SANDBOX_SERVER_URL on backend-ai-api (same as computer-use / SelfHostedProvider); the API probes GET {origin}/health on sandbox-orchestrator. No Hetzner Cloud account required. Optional SELF_HOSTED_INFRA_JSON adds more rows (e.g. future IDS host). Optional: HETZNER_API_TOKEN merges servers from the Hetzner Cloud API; reboot / power actions apply only to those cloud rows (bare metal is SSH / host panel).


Appendix A: Compatibility with Existing Uncommitted Changes

The current branch (session-239-244_2) has ~50 uncommitted files from a prior agent implementing the per-executive Firecracker VM plan. Analysis of the diff shows:

Category Files Action
Role infrastructure (platformRole plumbing, permission threading) ~15 files KEEP — required by IDS too
Monorepo tool registration (plugin, aliases, security) ~8 files KEEP — same tools, different backend
Sandbox resource passthrough (memSizeMib, vcpuCount) ~8 files KEEP — harmless, IDS provider ignores
VM session manager (executive Firecracker logic) 1 file REWORK — add IDS conditional alongside existing code
Tests ~8 files KEEP + EXTEND — add IDS-specific test cases
Unrelated (users.service, mongodb-sync, etc.) ~10 files KEEP — not related to VM/IDS

No reverts needed. IDS is additive. The existing changes provide the role infrastructure that IDS depends on.


Appendix B: IDS Agent — Minimal Implementation Skeleton

ids-agent/
  src/
    main.ts                      # NestJS bootstrap
    app.module.ts                # Module registration
    sessions/
      sessions.controller.ts   # REST endpoints
      sessions.service.ts      # Worktree + namespace lifecycle
      sessions.types.ts        # DTOs and interfaces
      sessions.guard.ts        # API key validation
    worktree/
      worktree.service.ts      # Git worktree operations
    namespace/
      namespace.service.ts     # systemd-nspawn lifecycle
    health/
      health.controller.ts     # /health, /metrics
      metrics.service.ts       # Per-session resource tracking
    cleanup/
      cleanup.service.ts       # TTL-based worktree cleanup cron
  .env                             # Configuration
  package.json
  tsconfig.json

Estimated: ~500-800 lines of application code. The simplicity is the point.