Skip to content

Conversation

@ammar-agent
Copy link
Collaborator

@ammar-agent ammar-agent commented Nov 10, 2025

Summary

Implements workspace forking for SSH runtimes. Fork operations copy the entire workspace (preserving uncommitted changes) while keeping the UI responsive by moving the slow copy operation to the init phase.

Implementation

Follows the existing createWorkspace + initWorkspace pattern:

  • Fast path (forkWorkspace): Detect branch + create empty directory → instant return
  • Slow path (initWorkspace): Copy files + checkout branch + run init hook → fire-and-forget with progress streaming

Changes

1. Added sourceWorkspacePath parameter to WorkspaceInitParams

export interface WorkspaceInitParams {
  // ... existing fields ...
  /** If provided, copy from this workspace instead of syncing from local (fork scenario) */
  sourceWorkspacePath?: string;
}

2. Implemented SSHRuntime.forkWorkspace

  • Detects current branch in source workspace via git branch --show-current
  • Creates empty directory for new workspace (mkdir -p)
  • Returns immediately with source workspace path and branch info
  • Result: Fork IPC returns in ~1 second

3. Updated SSHRuntime.initWorkspace to handle fork scenario

  • Fork mode (when sourceWorkspacePath provided):
    • Copy from source workspace via cp -a (preserves uncommitted changes, timestamps, permissions)
    • Checkout new branch
    • Run init hook
  • Normal mode (when sourceWorkspacePath undefined):
    • Sync from local via rsync
    • Checkout branch
    • Run init hook

4. Updated LocalRuntime.forkWorkspace

  • Uses existing createWorkspace (git worktree) with source branch as trunk
  • Instant operation (git worktrees are fast)
  • Calls init hook if present

5. Updated IPC handler

  • Calls runtime.initWorkspace() with sourceWorkspacePath for fork scenario
  • Fire-and-forget pattern with progress streaming via initLogger
  • Copies chat history (chat.jsonl) from source to forked workspace

6. Refactored tests to use describe.each matrix

  • Tests run for both local and ssh runtimes
  • Single SSH server shared across all tests (performance optimization)
  • Proper path handling: SSH uses constructed paths, local uses metadata paths
  • SSH-specific test: preserves uncommitted changes (local creates clean worktrees)

Benefits

UI stays responsive: Fork IPC returns immediately (~1s) even for large workspaces
Progress streaming: Init events stream via initLogger during copy operation
Uncommitted changes preserved: cp -a copies everything (SSH only)
Matches existing patterns: Same architecture as createWorkspace + initWorkspace
Runtime parity: Both local and SSH runtimes fully tested

Test Coverage

All 14 tests passing in ~63 seconds:

Basic fork operations:

  • ✅ Invalid name validation (both runtimes)
  • ✅ Successful fork (both runtimes)

Init hook execution:

  • ✅ Verifies init hook runs during fork (both runtimes)

Fork with API operations:

  • ✅ Send messages to forked workspace (local)
  • ✅ Preserve chat history (local)

Fork preserves filesystem state:

  • ✅ Preserve uncommitted changes (SSH only - local worktrees are clean checkouts)
  • ✅ Preserve git state/branch (both runtimes)

Test structure:

describe.each<{ type: "local" | "ssh" }>([{ type: "local" }, { type: "ssh" }])(
  "Runtime: $type",
  ({ type }) => {
    // Tests run for both runtimes with proper path handling
  }
);

Files Modified

  • src/runtime/Runtime.ts (+2 lines): Added sourceWorkspacePath? parameter
  • src/runtime/SSHRuntime.ts (+110 lines): Implemented forkWorkspace + updated initWorkspace
  • src/runtime/LocalRuntime.ts (+35 lines): Implemented forkWorkspace + updated initWorkspace
  • src/services/ipcMain.ts (+170 lines): Implemented WORKSPACE_FORK IPC handler
  • tests/ipcMain/forkWorkspace.test.ts (+641 lines): Matrix testing for both runtimes
  • tests/ipcMain/setup.ts (+60 lines): Shared SSH test helpers
  • tests/README.md (+80 lines): Documentation for test infrastructure

Net change: 910 insertions, 332 deletions

Generated with cmux

- Add sourceWorkspacePath to WorkspaceInitParams for fork scenario
- Update SSHRuntime.forkWorkspace to only detect branch and create empty dir (instant)
- Move cp -a operation from forkWorkspace to initWorkspace (fire-and-forget)
- Update IPC handler to call initWorkspace instead of runInitHook for forks
- Refactor tests to use describe.each matrix for both local and SSH runtimes

Benefits:
- Fork IPC returns in ~1s instead of up to 5 minutes
- UI stays responsive during fork operations
- Progress streaming preserved via initLogger
- Uncommitted changes still preserved (cp -a in init phase)

All 14 tests passing (7 local + 7 SSH).
@ammar-agent ammar-agent changed the title 🤖 perf: make SSH fork instant by moving copy to init phase 🤖 feat: implement SSH workspace forking with instant UX Nov 10, 2025
Since we use initWorkspace() with sourceWorkspacePath for fork scenario,
runInitHook() doesn't need to be exposed on the Runtime interface.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@ammario ammario changed the title 🤖 feat: implement SSH workspace forking with instant UX 🤖 feat: implement SSH workspace forking Nov 10, 2025
@ammario ammario changed the title 🤖 feat: implement SSH workspace forking 🤖 feat: implement SSH workspace forking Nov 10, 2025
- Add setupForkTest() helper for setup/cleanup
- Add withForkTest() wrapper to eliminate try/finally blocks
- All tests now use withForkTest() - no manual cleanup needed
- Reduced indentation depth from 5 levels to 3 levels
- 61 lines removed through deduplication
Addresses Codex P1 feedback:
- In initWorkspace: expand source and workspace paths before cp -a
- In forkWorkspace: expand paths before git -C and mkdir -p

Without expandTildeForSSH, paths like ~/cmux/... would be quoted
literally, causing commands to fail with 'No such file or directory'
when operating on a literal ~ directory name.
**Problem:**
Test 'should block file_read in forked workspace until init completes' was failing because it measured the wrong timing interval. The test measured how long sendMessage() took to return (~100ms), but sendMessage() returns immediately - tools execute asynchronously during streaming.

**Root cause:**
- Test assumed sendMessage() duration = tool execution time
- In reality: sendMessage() invokes IPC and returns, tools run later during streaming
- Tools DO correctly wait for init (via wrapWithInitWait), but test couldn't detect it

**Fix:**
1. **Test timing:** Measure from fork to init-end completion (must be >2.5s for 3s init hook)
2. **Verify blocking:** Presence of both tool-call-end and init-end events proves tools waited
   (if tools didn't wait, they'd fail accessing non-existent files during init)
3. **Prevent fork-during-init:** Added check in WORKSPACE_FORK handler to reject forks when source workspace is initializing

**Test infrastructure improvements:**
- Centralized DEFAULT_TEST_MODEL constant
- Moved provider setup into sendMessage() helper (eliminates ~50 lines of duplication)
- Added guidance to AGENTS.md about avoiding manual test setup boilerplate

**Test results:**
- ✅ All 18 fork tests pass (both local and SSH runtimes)
- ✅ Init blocking works correctly (no bug in initStateManager)
- ✅ Fork-during-init properly rejected with clear error message
sendMessageAndWait was bypassing sendMessage() and directly invoking IPC,
which meant it didn't get the provider setup that was centralized in sendMessage().
This caused all tests using sendMessageAndWait to fail with api_key_not_found errors.

Fixed by adding the same provider setup logic to sendMessageAndWait using the
shared setupProviderCache WeakSet.
Some tests need to verify error handling when providers are NOT set up
(e.g., api_key_not_found errors). Added skipProviderSetup option to sendMessage()
and sendMessageWithModel() to allow tests to opt out of automatic provider setup.

Also fixed initWorkspace test to use sendMessage() helper instead of invoking IPC
directly, which was causing provider setup to be skipped unintentionally.
- Remove skipProviderSetup flag (over-engineering for one test)
- Simplify test timing logic (just verify success, no timestamp math)
- Keep provider setup centralized in sendMessage()
- Let error test call IPC directly (what it was doing before)

Reduces ~200 lines to ~50 lines while keeping valuable additions:
- Fork-during-init protection
- Test constants (DEFAULT_TEST_MODEL)
- Documentation guidance
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant