2026 03 20 Platform Infrastructure Evolution

Documentation for Plans

Platform Infrastructure Evolution — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Eliminate GitLab as the generation bottleneck, enable progressive real-time preview, add VM command batching, and prepare the pre-warmed VM pool — collectively saving 3-7 minutes per platform generation and enabling the next wave of features (self-healing, WhatsApp ops, visual debugging).

Architecture: Replace GitLab HTTP API with local git repos on the Hetzner server for instant file operations. Wire the existing embeddable-bridge streaming protocol into the generation pipeline so users see modules appearing in real-time. Add a batchExec method to reduce VM command round-trips. Introduce a pre-warmed VM pool for instant sandbox acquisition.

Tech Stack: NestJS, simple-git (already in monorepo), Angular signals, RxJS, embeddable-bridge streaming protocol, Firecracker microVMs, systemd

Session: S246 | Decisions: 8x3430d, 74x70ar, adpeihd, z91iqfj, stucro3, bp1vyz6, locgmpx

Specs:


Prerequisites


File Map

New Files

File Responsibility
APIs/backend-api/src/app/git-provider/local-git-provider.service.ts Local git implementation of GitProviderService using simple-git
APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts Tests for local git provider
APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.ts Pre-warmed VM pool manager
APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.spec.ts Tests for VM pool

Modified Files

File Change
libs/libs-foundational/core-models/src/lib/interfaces/git-provider.interface.ts Add 'local' to provider type if needed
APIs/backend-api/src/app/git-provider/git-provider.module.ts Register LocalGitProviderService, add factory for provider selection
APIs/backend-vitto-api/src/app/studio/universe-git.service.ts Refactor to use GitProviderService abstraction instead of direct GitLab HTTP calls
APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.ts Add executeBatchOnVm() method
APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts Auto-detect batchable VM tools, route to executeBatchOnVm()
APIs/sandbox-orchestrator/src/app/sandbox/sandbox-manager.service.ts Integrate VM pool for warm acquisition
APIs/sandbox-orchestrator/src/app/sandbox/sandbox.module.ts Register VmPoolService
libs/libs-structural/vitto-studio/src/lib/services/studio-generation-listener.service.ts Consume per-module SSE events to incrementally update VFS + trigger preview rebuild
deployment/sandbox-orchestrator/deploy-orchestrator-host.sh Already fixed (S246)

Phase 1: Local Git Provider (eliminate GitLab bottleneck)

Impact: Template init drops from 15-30s to <1s. File commits drop from 2-5 min to <1s. Total: ~3-4 min saved per generation.


Task 1.1: Create LocalGitProviderService

Files:

Read libs/libs-foundational/core-models/src/lib/interfaces/git-provider.interface.ts to understand all methods that must be implemented. Focus on: createRepository, pushFiles, getFileContent, getFileTree, createBranch, mergeBranch, getCommitHistory.

// APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts
import { LocalGitProviderService } from './local-git-provider.service';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';

describe('LocalGitProviderService', () => {
  let service: LocalGitProviderService;
  let tmpDir: string;

  beforeEach(async () => {
    tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'local-git-test-'));
    service = new LocalGitProviderService(tmpDir);
  });

  afterEach(async () => {
    await fs.rm(tmpDir, { recursive: true, force: true });
  });

  describe('createRepository', () => {
    it('should create a git repo with main and develop branches', async () => {
      const repo = await service.createRepository('test-space', 'test-universe');
      expect(repo).toBeDefined();
      expect(repo.name).toBe('test-universe');

      // Verify .git exists
      const gitDir = path.join(tmpDir, 'test-space', 'test-universe', '.git');
      const stat = await fs.stat(gitDir);
      expect(stat.isDirectory()).toBe(true);
    });
  });
});

Run: npx jest APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts --no-coverage Expected: FAIL — module not found

// APIs/backend-api/src/app/git-provider/local-git-provider.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { simpleGit, SimpleGit } from 'simple-git';
import * as fs from 'fs/promises';
import * as path from 'path';
import type {
  GitProviderService,
  GitRepoInfo,
  GitFileContent,
  GitFileInfo,
  GitBranchInfo,
  GitCommitInfo,
  GitMergeInfo,
} from '@libs-foundational/core-models';

@Injectable()
export class LocalGitProviderService implements GitProviderService {
  private readonly logger = new Logger(LocalGitProviderService.name);

  constructor(private readonly reposRoot: string) {}

  private repoPath(orgSlug: string, repoSlug: string): string {
    return path.join(this.reposRoot, orgSlug, repoSlug);
  }

  private git(absPath: string): SimpleGit {
    return simpleGit(absPath);
  }

  async createRepository(
    orgSlug: string,
    repoSlug: string,
    options?: { description?: string },
  ): Promise<GitRepoInfo> {
    const absPath = this.repoPath(orgSlug, repoSlug);
    await fs.mkdir(absPath, { recursive: true });

    const git = this.git(absPath);
    await git.init();
    await git.addConfig('user.name', 'Platform Generator');
    await git.addConfig('user.email', '[email protected]');

    // Initial commit on main
    const readmePath = path.join(absPath, 'README.md');
    await fs.writeFile(readmePath, `# ${repoSlug}\n\n${options?.description || 'Generated platform'}\n`);
    await git.add('README.md');
    await git.commit('chore: initialize repository');

    // Create develop branch
    await git.branch(['develop']);

    return {
      name: repoSlug,
      path: `${orgSlug}/${repoSlug}`,
      webUrl: `file://${absPath}`,
      cloneUrl: absPath,
      defaultBranch: 'main',
    };
  }

  async pushFiles(
    repoPath: string,
    files: Array<{ path: string; content: string }>,
    message: string,
    branch = 'develop',
  ): Promise<void> {
    const absPath = path.join(this.reposRoot, repoPath);
    const git = this.git(absPath);

    // Ensure branch exists and is checked out
    const branches = await git.branchLocal();
    if (!branches.all.includes(branch)) {
      await git.checkoutLocalBranch(branch);
    } else {
      await git.checkout(branch);
    }

    // Write all files
    for (const file of files) {
      const filePath = path.join(absPath, file.path);
      await fs.mkdir(path.dirname(filePath), { recursive: true });
      await fs.writeFile(filePath, file.content, 'utf-8');
    }

    // Stage and commit
    await git.add('.');
    const status = await git.status();
    if (status.staged.length > 0 || status.files.length > 0) {
      await git.commit(message);
    }
  }

  async getFileContent(
    repoPath: string,
    filePath: string,
    ref = 'develop',
  ): Promise<GitFileContent> {
    const absPath = path.join(this.reposRoot, repoPath);
    const git = this.git(absPath);
    const content = await git.show([`${ref}:${filePath}`]);
    return { path: filePath, content, encoding: 'utf-8' };
  }

  async getFileTree(
    repoPath: string,
    ref = 'develop',
    dirPath?: string,
  ): Promise<GitFileInfo[]> {
    const absPath = path.join(this.reposRoot, repoPath);
    const git = this.git(absPath);
    const treePath = dirPath ? `${ref}:${dirPath}` : ref;
    const result = await git.raw(['ls-tree', '-r', '--name-only', treePath]);
    return result
      .split('\n')
      .filter(Boolean)
      .map(name => ({
        name: path.basename(name),
        path: dirPath ? `${dirPath}/${name}` : name,
        type: 'blob' as const,
      }));
  }

  async createBranch(
    repoPath: string,
    branchName: string,
    fromRef = 'develop',
  ): Promise<GitBranchInfo> {
    const absPath = path.join(this.reposRoot, repoPath);
    const git = this.git(absPath);
    await git.checkoutBranch(branchName, fromRef);
    const log = await git.log({ maxCount: 1 });
    return {
      name: branchName,
      commitSha: log.latest?.hash || '',
    };
  }

  async mergeBranch(
    repoPath: string,
    source: string,
    target: string,
  ): Promise<GitMergeInfo> {
    const absPath = path.join(this.reposRoot, repoPath);
    const git = this.git(absPath);
    await git.checkout(target);
    const result = await git.merge([source]);
    return {
      merged: !result.failed,
      commitSha: result.commit || '',
      conflicts: result.conflicts || [],
    };
  }

  async getCommitHistory(
    repoPath: string,
    ref = 'develop',
    limit = 20,
  ): Promise<GitCommitInfo[]> {
    const absPath = path.join(this.reposRoot, repoPath);
    const git = this.git(absPath);
    const log = await git.log({ maxCount: limit, from: ref });
    return log.all.map(c => ({
      sha: c.hash,
      message: c.message,
      authorName: c.author_name,
      authorEmail: c.author_email,
      date: c.date,
    }));
  }

  async deleteBranch(repoPath: string, branchName: string): Promise<void> {
    const absPath = path.join(this.reposRoot, repoPath);
    const git = this.git(absPath);
    await git.deleteLocalBranch(branchName, true);
  }

  async listBranches(repoPath: string): Promise<GitBranchInfo[]> {
    const absPath = path.join(this.reposRoot, repoPath);
    const git = this.git(absPath);
    const branches = await git.branchLocal();
    return branches.all.map(name => ({ name, commitSha: '' }));
  }
}

Run: npx jest APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts --no-coverage Expected: PASS

describe('pushFiles', () => {
  it('should write files and commit to develop branch', async () => {
    await service.createRepository('s1', 'u1');
    await service.pushFiles('s1/u1', [
      { path: 'modules/users/users.component.ts', content: 'export class UsersComponent {}' },
      { path: 'modules/users/users.service.ts', content: 'export class UsersService {}' },
    ], 'feat: add users module');

    const tree = await service.getFileTree('s1/u1', 'develop');
    const paths = tree.map(f => f.path);
    expect(paths).toContain('modules/users/users.component.ts');
    expect(paths).toContain('modules/users/users.service.ts');
  });
});

describe('branches', () => {
  it('should create feature branch and merge to develop', async () => {
    await service.createRepository('s1', 'u1');
    await service.createBranch('s1/u1', 'feature/gen-test', 'develop');
    await service.pushFiles('s1/u1', [
      { path: 'test.ts', content: 'test' },
    ], 'feat: test file', 'feature/gen-test');

    const result = await service.mergeBranch('s1/u1', 'feature/gen-test', 'develop');
    expect(result.merged).toBe(true);

    const content = await service.getFileContent('s1/u1', 'test.ts', 'develop');
    expect(content.content).toBe('test');
  });
});

Run: npx jest APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts --no-coverage Expected: ALL PASS

git add APIs/backend-api/src/app/git-provider/local-git-provider.service.ts APIs/backend-api/src/app/git-provider/local-git-provider.service.spec.ts
git commit -m "[S246.1] feat: add LocalGitProviderService for local git repo management"

Task 1.2: Register Provider with Factory Selection

Files:

Understand how GitLabProviderService is registered today. We need to add a factory that selects local vs GitLab based on environment.

// In git-provider.module.ts — add a factory provider:
{
  provide: 'GIT_PROVIDER_SERVICE',
  useFactory: (config: ConfigService, http: HttpService) => {
    const localReposRoot = config.get<string>('LOCAL_REPOS_ROOT');
    if (localReposRoot) {
      return new LocalGitProviderService(localReposRoot);
    }
    // Fall back to GitLab
    return new GitLabProviderService(config, http);
  },
  inject: [ConfigService, HttpService],
}

Add to .env.local:

# Platform code storage: set LOCAL_REPOS_ROOT to use local git repos instead of GitLab.
# When set, GitLab becomes optional export (async push).
# LOCAL_REPOS_ROOT=/data/platforms

Run: npx nx build backend-api --configuration=production Expected: Build succeeds

git add APIs/backend-api/src/app/git-provider/git-provider.module.ts .env.local
git commit -m "[S246.1] feat: add provider factory — LOCAL_REPOS_ROOT selects local git vs GitLab"

Task 1.3: Template Initialization via Local Copy

Files:

Understand the full flow: GitLab fork (fast path) vs file-by-file copy (fallback). Note the return type TemplateInitResult.

// Add to UniverseGitService:
private async initializeFromLocalTemplate(
  repoPath: string,
  templatePath: string,
): Promise<{ fileCount: number }> {
  const git = simpleGit(repoPath);

  // Copy template files (excluding .git)
  const templateEntries = await fs.readdir(templatePath, { withFileTypes: true });
  let fileCount = 0;

  for (const entry of templateEntries) {
    if (entry.name === '.git' || entry.name === 'node_modules') continue;
    const src = path.join(templatePath, entry.name);
    const dest = path.join(repoPath, entry.name);
    await fs.cp(src, dest, { recursive: true });
    fileCount++;
  }

  await git.add('.');
  await git.commit('chore: initialize from platform-base-template');

  return { fileCount };
}

Add at the top of initializeFromTemplate():

const localReposRoot = this.config.get<string>('LOCAL_REPOS_ROOT');
const localTemplatePath = this.config.get<string>('LOCAL_TEMPLATE_PATH');

if (localReposRoot && localTemplatePath) {
  this.logger.log(`Using local template copy from ${localTemplatePath}`);
  const result = await this.initializeFromLocalTemplate(
    path.join(localReposRoot, orgSlug, repoSlug),
    localTemplatePath,
  );
  return {
    gitConfig: { ...gitConfig, provider: 'local' },
    templateLoaded: true,
    templateFileCount: result.fileCount,
    warnings: [],
  };
}

// Existing GitLab fork/copy logic continues below...

Set in .env.local:

LOCAL_REPOS_ROOT=/tmp/test-platforms
LOCAL_TEMPLATE_PATH=/Users/fguijarro/developer/repos-gspace/repo-templates/platform-base-template

Restart backend-vitto-api. Trigger a platform generation. Verify:

git add APIs/backend-vitto-api/src/app/studio/universe-git.service.ts
git commit -m "[S246.1] feat: local template copy when LOCAL_REPOS_ROOT is set — <1s vs 15-30s GitLab fork"

Task 1.4: Server Setup — Platform Storage Directory on Hetzner

ssh [email protected]
mkdir -p /data/platforms
chown root:root /data/platforms

From your Mac:

rsync -avz --exclude='node_modules' --exclude='.git' \
  /Users/fguijarro/developer/repos-gspace/repo-templates/platform-base-template/ \
  [email protected]:/data/templates/platform-base-template/
ssh [email protected] "ls /data/templates/platform-base-template/ | head -10"

Expected: angular.json, package.json, tsconfig.json, src/, etc.

Add to docs/english/Deployment/hetzner-firecracker-operations.md:

## Platform Storage

- `/data/platforms/` — Local git repos for generated platforms (when LOCAL_REPOS_ROOT is set)
- `/data/templates/platform-base-template/` — Template for new platform initialization
- Sync template: `rsync -avz --exclude='node_modules' --exclude='.git' repo-templates/platform-base-template/ [email protected]:/data/templates/platform-base-template/`
git add docs/english/Deployment/hetzner-firecracker-operations.md
git commit -m "[S246.1] docs: add platform storage paths to Hetzner operations guide"

Phase 2: Progressive Per-Module Preview

Impact: Users see modules appearing in real-time during generation instead of waiting 25-40 min for the batch result. Transforms the wait experience.

Key discovery: The execute phase ALREADY emits per-module SSE events with file content included (S234). The frontend just needs to consume them incrementally.


Task 2.1: Wire Generation SSE Events to VFS Incremental Update

Files:

Understand how it currently handles SSE events. Look for module-gen.module-generated event type.

The SSE event module-gen.module-generated already includes files: GeneratedFileSpec[] with full content (S234, execute.phase.ts:181-207). Wire this to VFS:

// In the SSE event handler for 'module-gen.module-generated':
case 'module-gen.module-generated': {
  const { moduleName, files, index, total } = event.data;

  if (files?.length) {
    // Write files to VFS immediately (don't wait for all modules)
    for (const file of files) {
      this.vfsService.writeFile(file.path, file.content);
    }

    // Trigger preview rebuild
    this.vfsService.notifyFileChange();

    this.logger.log(
      `[Progressive] Module ${index + 1}/${total}: ${moduleName} — ${files.length} files added to VFS`,
    );
  }
  break;
}

Read preview.service.ts — confirm the rebuildTrigger Subject fires on vfs.lastFileChange() via the existing effect(). If so, no changes needed in preview.service.ts — the rebuild is automatic.

Start a platform generation. Observe:

git add libs/libs-structural/vitto-studio/src/lib/services/studio-generation-listener.service.ts
git commit -m "[S246.1] feat: progressive preview — write per-module files to VFS as generated"

Task 2.2: Add Generation Activity Stream via Embeddable Bridge

Files:

For each generation event, emit an AI_STREAM_CHUNK message through the embeddable bridge so the host app can show a live activity feed:

// Import stream helpers
import { createStreamChunkMessage, generateStreamId } from '@libs-ux/embeddable-bridge';

// In the generation listener, on pipeline start:
private generationStreamId: string | null = null;

onPipelineStart() {
  this.generationStreamId = generateStreamId();
  // Emit start
  this.bridgeAdapter.send(createStreamStartMessage(this.generationStreamId));
}

// In each event handler, emit activity:
private emitActivity(message: string, phase: string) {
  if (!this.generationStreamId) return;
  this.bridgeAdapter.send(createStreamChunkMessage(
    this.generationStreamId,
    JSON.stringify({ message, phase, timestamp: Date.now() }),
    this.activityIndex++,
    false,
  ));
}

// Usage in event handlers:
case 'module-gen.phase-started':
  this.emitActivity(`Phase: ${event.data.phase}`, event.data.phase);
  break;

case 'module-gen.module-generated':
  this.emitActivity(
    `Module ${event.data.index + 1}/${event.data.total}: ${event.data.moduleName} — ${event.data.files?.length || 0} files`,
    'execute',
  );
  break;

case 'module-gen.complete':
  this.emitActivity('Generation complete', 'done');
  this.bridgeAdapter.send(createStreamEndMessage(this.generationStreamId, this.activityIndex));
  this.generationStreamId = null;
  break;

Check that the host app's HostAdapterService receives AI_STREAM_CHUNK messages. The bridge already handles these — verify in browser devtools Network/Console.

git add libs/libs-structural/vitto-studio/src/lib/services/studio-generation-listener.service.ts
git commit -m "[S246.1] feat: emit generation activity as embeddable-bridge stream chunks"

Phase 3: VM Command Batching

Impact: Reduces 3-7 seconds of network overhead per VM-heavy task to <1 second. Zero infrastructure changes.


Task 3.1: Add batchExec to SandboxedToolProxyService

Files:

describe('executeBatchOnVm', () => {
  it('should combine multiple commands into one SSH call with tagged output', async () => {
    const mockVmApiClient = {
      sandboxExec: jest.fn().mockResolvedValue({
        stdout: '===BATCH_START_0===\nfile1.ts\n===BATCH_EXIT_0=0===\n===BATCH_END_0===\n' +
                '===BATCH_START_1===\nOK\n===BATCH_EXIT_1=0===\n===BATCH_END_1===\n',
        stderr: '',
        exitCode: 0,
      }),
    };

    const results = await service.executeBatchOnVm('vm-123', [
      { command: 'ls src/', label: 'list-files' },
      { command: 'echo OK', label: 'echo-test' },
    ]);

    expect(mockVmApiClient.sandboxExec).toHaveBeenCalledTimes(1); // ONE call, not two
    expect(results).toHaveLength(2);
    expect(results[0].stdout).toContain('file1.ts');
    expect(results[1].stdout).toContain('OK');
  });
});

Run: npx jest APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.spec.ts --no-coverage -t "executeBatchOnVm" Expected: FAIL

// Add to SandboxedToolProxyService:

async executeBatchOnVm(
  vmId: string,
  commands: Array<{ command: string; cwd?: string; label?: string }>,
): Promise<Array<{ label: string; stdout: string; stderr: string; exitCode: number }>> {
  if (commands.length === 0) return [];
  if (commands.length === 1) {
    // Single command — no batching overhead
    const result = await this.vmApiClient.sandboxExec(vmId, {
      command: commands[0].command,
      cwd: commands[0].cwd,
    });
    return [{
      label: commands[0].label || 'cmd-0',
      stdout: result.stdout || '',
      stderr: result.stderr || '',
      exitCode: result.exitCode ?? 0,
    }];
  }

  // Build tagged batch script
  const script = commands.map((c, i) => [
    `echo "===BATCH_START_${i}==="`,
    c.cwd ? `cd ${c.cwd} 2>/dev/null` : '',
    `${c.command} 2>&1`,
    `_exit_${i}=$?`,
    `echo "===BATCH_EXIT_${i}=$_exit_${i}==="`,
    `echo "===BATCH_END_${i}==="`,
  ].filter(Boolean).join('\n')).join('\n');

  const result = await this.vmApiClient.sandboxExec(vmId, { command: script });
  return this.parseBatchOutput(result.stdout || '', commands);
}

private parseBatchOutput(
  stdout: string,
  commands: Array<{ label?: string }>,
): Array<{ label: string; stdout: string; stderr: string; exitCode: number }> {
  const results: Array<{ label: string; stdout: string; stderr: string; exitCode: number }> = [];

  for (let i = 0; i < commands.length; i++) {
    const startMarker = `===BATCH_START_${i}===`;
    const endMarker = `===BATCH_END_${i}===`;
    const exitPattern = new RegExp(`===BATCH_EXIT_${i}=(\\d+)===`);

    const startIdx = stdout.indexOf(startMarker);
    const endIdx = stdout.indexOf(endMarker);

    let output = '';
    let exitCode = 0;

    if (startIdx !== -1 && endIdx !== -1) {
      output = stdout.slice(startIdx + startMarker.length, endIdx).trim();
      const exitMatch = output.match(exitPattern);
      if (exitMatch) {
        exitCode = parseInt(exitMatch[1], 10);
        output = output.replace(exitPattern, '').trim();
      }
    }

    results.push({
      label: commands[i].label || `cmd-${i}`,
      stdout: output,
      stderr: '',
      exitCode,
    });
  }

  return results;
}

Run: npx jest APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.spec.ts --no-coverage -t "executeBatchOnVm" Expected: PASS

git add APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.ts APIs/backend-vitto-api/src/app/engine/vm/sandboxed-tool-proxy.service.spec.ts
git commit -m "[S246.1] feat: add batchExec to SandboxedToolProxyService — one HTTP call for N commands"

Task 3.2: Auto-Batch in Agent Loop

Files:

Read agent-loop.service.ts around line 522-572 (the Promise.allSettled block). Understand how multiple tool calls from one LLM response are dispatched.

Before the Promise.allSettled, check if multiple run_command/monorepo_run_command tools target the same VM. If so, route to executeBatchOnVm:

// Before dispatching tools in parallel:
if (options.vmId && options.sandboxedToolProxy) {
  const runCommandTools = toolCalls.filter(
    t => ['run_command', 'monorepo_run_command'].includes(canonicalVmToolName(t.name)),
  );
  const otherTools = toolCalls.filter(
    t => !['run_command', 'monorepo_run_command'].includes(canonicalVmToolName(t.name)),
  );

  if (runCommandTools.length > 1) {
    // Batch all run_command tools into one HTTP call
    const batchResults = await options.sandboxedToolProxy.executeBatchOnVm(
      options.vmId,
      runCommandTools.map(t => ({
        command: t.input.command as string,
        cwd: t.input.cwd as string | undefined,
        label: t.id,
      })),
    );

    // Map results back to tool response format
    for (let i = 0; i < runCommandTools.length; i++) {
      toolResults.set(runCommandTools[i].id, {
        output: batchResults[i].stdout,
        exitCode: batchResults[i].exitCode,
        executedOn: 'vm',
        vmId: options.vmId,
      });
    }

    // Dispatch remaining non-run_command tools normally
    toolCalls = otherTools;
  }
}

// ... existing Promise.allSettled for remaining tools

Trigger a Vitto task that runs multiple commands (e.g., "check git status and run tests"). Verify in logs:

git add APIs/backend-vitto-api/src/app/engine/agent-loop.service.ts
git commit -m "[S246.1] feat: auto-batch parallel VM run_command tools into single HTTP call"

Phase 4: Pre-Warmed VM Pool

Impact: VM acquisition drops from ~10s (cold boot) to <100ms (grab from pool). Enables instant sandboxing for self-healing, testing, and visual debugging features.


Task 4.1: Create VmPoolService

Files:

describe('VmPoolService', () => {
  it('should acquire a pre-warmed VM instantly', async () => {
    const pool = new VmPoolService(mockSandboxManager, mockConfig);
    await pool.initialize(); // Pre-warm

    const vm = await pool.acquire('user-1', 'test-session');
    expect(vm).toBeDefined();
    expect(vm.status).toBe('running');
  });

  it('should replenish pool after acquisition', async () => {
    const pool = new VmPoolService(mockSandboxManager, mockConfig);
    await pool.initialize();

    const initialSize = pool.getPoolSize();
    await pool.acquire('user-1', 'test-session');

    // Wait for async replenishment
    await new Promise(resolve => setTimeout(resolve, 100));
    expect(pool.getPoolSize()).toBe(initialSize); // Replenished
  });

  it('should fall back to cold boot when pool is empty', async () => {
    const pool = new VmPoolService(mockSandboxManager, mockConfig);
    // Don't initialize — pool is empty

    const vm = await pool.acquire('user-1', 'test-session');
    expect(vm).toBeDefined(); // Cold boot fallback
  });
});
// APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SandboxManagerService } from './sandbox-manager.service';
import type { SandboxInfo } from './models/sandbox.types';

@Injectable()
export class VmPoolService implements OnModuleInit {
  private readonly logger = new Logger(VmPoolService.name);
  private readonly pool: SandboxInfo[] = [];
  private readonly targetSize: number;
  private readonly memSizeMib: number;
  private readonly vcpuCount: number;
  private replenishing = false;

  constructor(
    private readonly manager: SandboxManagerService,
    private readonly config: ConfigService,
  ) {
    this.targetSize = parseInt(this.config.get('VM_POOL_SIZE', '3'), 10);
    this.memSizeMib = parseInt(this.config.get('VM_POOL_MEM_MIB', '2048'), 10);
    this.vcpuCount = parseInt(this.config.get('VM_POOL_VCPU', '2'), 10);
  }

  async onModuleInit() {
    if (this.targetSize > 0) {
      this.logger.log(`Pre-warming ${this.targetSize} VMs...`);
      await this.initialize();
      this.logger.log(`VM pool ready: ${this.pool.length}/${this.targetSize}`);
    }
  }

  async initialize(): Promise<void> {
    const promises = Array.from({ length: this.targetSize }, (_, i) =>
      this.createPoolVm(`pool-warm-${Date.now()}-${i}`),
    );
    const results = await Promise.allSettled(promises);
    for (const r of results) {
      if (r.status === 'fulfilled' && r.value) {
        this.pool.push(r.value);
      }
    }
  }

  async acquire(userId: string, sessionId: string): Promise<SandboxInfo> {
    const vm = this.pool.shift();
    if (vm) {
      this.logger.log(`Pool acquire: ${vm.sandboxId} for ${sessionId} (${this.pool.length} remaining)`);
      // Reassign ownership
      vm.sessionId = sessionId;
      vm.userId = userId;
      vm.lastUsed = Date.now();
      // Replenish in background
      this.replenishIfNeeded();
      return vm;
    }

    // Pool empty — cold boot fallback
    this.logger.warn(`Pool empty, cold booting for ${sessionId}`);
    const cold = await this.manager.create({
      sessionId,
      userId,
      memSizeMib: this.memSizeMib,
      vcpuCount: this.vcpuCount,
    });
    return cold;
  }

  release(sandboxId: string): void {
    // Don't return to pool — destroy and let replenishment create fresh ones
    this.manager.destroy(sandboxId).catch(err =>
      this.logger.warn(`Pool release destroy failed: ${err.message}`),
    );
  }

  getPoolSize(): number {
    return this.pool.length;
  }

  private async replenishIfNeeded(): Promise<void> {
    if (this.replenishing || this.pool.length >= this.targetSize) return;
    this.replenishing = true;
    try {
      while (this.pool.length < this.targetSize) {
        const vm = await this.createPoolVm(`pool-warm-${Date.now()}-${this.pool.length}`);
        if (vm) this.pool.push(vm);
      }
    } catch (err) {
      this.logger.warn(`Pool replenishment failed: ${err.message}`);
    } finally {
      this.replenishing = false;
    }
  }

  private async createPoolVm(sessionId: string): Promise<SandboxInfo | null> {
    try {
      return await this.manager.create({
        sessionId,
        userId: 'pool',
        memSizeMib: this.memSizeMib,
        vcpuCount: this.vcpuCount,
      });
    } catch (err) {
      this.logger.warn(`Failed to create pool VM: ${err.message}`);
      return null;
    }
  }
}

Run: npx jest APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.spec.ts --no-coverage Expected: ALL PASS

// APIs/sandbox-orchestrator/src/app/sandbox/sandbox.module.ts
import { VmPoolService } from './vm-pool.service';

@Module({
  controllers: [SandboxController, FileOpsController, DashboardSummaryController, SandboxMetaController],
  providers: [
    SandboxManagerService,
    FirecrackerService,
    NetworkService,
    VmExecutorService,
    CleanupService,
    SnapshotService,
    DashboardSummaryService,
    VmPoolService, // ← Add
  ],
  exports: [SandboxManagerService, SnapshotService, NetworkService, DashboardSummaryService, VmPoolService],
})
// In sandbox.controller.ts — add pool status endpoint:
@Get('pool/status')
async poolStatus() {
  return {
    size: this.vmPool.getPoolSize(),
    target: this.config.get('VM_POOL_SIZE', '3'),
  };
}

Add to .env.local:

# Pre-warmed VM pool (0 = disabled, default 3)
VM_POOL_SIZE=3
VM_POOL_MEM_MIB=2048
VM_POOL_VCPU=2
git add APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.ts APIs/sandbox-orchestrator/src/app/sandbox/vm-pool.service.spec.ts APIs/sandbox-orchestrator/src/app/sandbox/sandbox.module.ts APIs/sandbox-orchestrator/src/app/sandbox/sandbox.controller.ts
git commit -m "[S246.1] feat: pre-warmed VM pool — instant sandbox acquisition from warm pool"

Task 4.2: Deploy VM Pool to Hetzner

npx nx build sandbox-orchestrator --configuration=production --skip-nx-cache
scp dist/APIs/sandbox-orchestrator/main.js [email protected]:/var/lib/sandbox-orchestrator/main.js
ssh [email protected] "cat >> /etc/systemd/system/sandbox-orchestrator.service.d/pool.conf << 'EOF'
[Service]
Environment=VM_POOL_SIZE=3
Environment=VM_POOL_MEM_MIB=2048
Environment=VM_POOL_VCPU=2
EOF"
ssh [email protected] "systemctl daemon-reload && systemctl restart sandbox-orchestrator"
curl -sS "https://sandbox.gograb.space/sandboxes/pool/status"

Expected: {"size":3,"target":"3"}

curl -sS "https://sandbox.gograb.space/health"
curl -sS -H "X-API-Key: $SANDBOX_API_KEY" "https://sandbox.gograb.space/sandboxes/meta/dashboard-summary" | head -c 300

Expected: Health ok, dashboard shows 3 active VMs (pool), capacity reduced accordingly

git add docs/english/Deployment/hetzner-firecracker-operations.md
git commit -m "[S246.1] docs: add VM pool configuration to Hetzner operations guide"

Phase 5: Integration Testing & Verification


Task 5.1: End-to-End Generation Test with Local Git

In .env.local:

LOCAL_REPOS_ROOT=/tmp/test-platforms
LOCAL_TEMPLATE_PATH=/Users/fguijarro/developer/repos-gspace/repo-templates/platform-base-template

Send a message: "Build me a simple task management platform with tasks, projects, and a dashboard."

Watch the Studio preview panel:

ls /tmp/test-platforms/
# Should show: {spaceSlug}/{universeSlug}/
cd /tmp/test-platforms/{spaceSlug}/{universeSlug}
git log --oneline
# Should show: generation commit + initial commit
git branch
# Should show: main, develop, feature/gen-*

Check backend-vitto-api logs for batch execution:

[SandboxedToolProxy] Batch exec: 3 commands in 1 HTTP call

Use MCP tools:

outcome({ action: "report", decisionId: "adpeihd", wasSuccessful: true/false, feedback: "..." })
outcome({ action: "report", decisionId: "z91iqfj", wasSuccessful: true/false, feedback: "..." })

Rollback Strategy

Each phase is independent. If any phase breaks:

Phase Rollback
Phase 1 (local git) Remove LOCAL_REPOS_ROOT from env → falls back to GitLab
Phase 2 (progressive preview) Revert studio-generation-listener.service.ts → batch-all-then-show resumes
Phase 3 (batching) Revert agent-loop.service.ts → individual HTTP calls resume
Phase 4 (VM pool) Set VM_POOL_SIZE=0 → pool disabled, cold boot resumes

No phase depends on another. They can be implemented and deployed independently.


Success Criteria

Metric Before After How to Verify
Template init time 15-30s <1s Time the generation start in logs
File commit time 2-5 min <1s Time between "execute complete" and "commit done"
First module visible 25-40 min ~5 min Watch preview during generation
VM acquisition ~10s cold boot <100ms from pool Time sandbox create in logs
Multi-command overhead N × 860ms 1 × 860ms Count HTTP calls in logs
GitLab dependency Required Optional export Generate with LOCAL_REPOS_ROOT set, no GitLab API calls