Add isolation provider abstraction for worktree management (#87)

* Add isolation provider abstraction for worktree management

Introduces IIsolationProvider interface to abstract workflow isolation:
- WorktreeProvider implementation with semantic branch naming
- Database migration for isolation_env_id and isolation_provider columns
- GitHub adapter migrated to use provider for create/destroy
- /worktree commands updated to use provider
- Manual testing guide and migration status docs

* Fix worktree base location defaults and add isolation provider docs

- Default to ~/tmp/worktrees locally (matches worktree-manager skill)
- Default to /workspace/worktrees in Docker (inside mounted volume)
- Docker ignores WORKTREE_BASE override (end user protection)
- Local allows WORKTREE_BASE override for developers
- Add IIsolationProvider section to architecture.md
- Add docs/worktree-orchestration.md with lifecycle diagrams
This commit is contained in:
Rasmus Widing 2025-12-17 11:10:16 +02:00 committed by GitHub
parent 509978ec2e
commit 44eef594b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3001 additions and 96 deletions

View file

@ -0,0 +1,856 @@
# Feature: Isolation Provider Abstraction
The following plan should be complete, but validate documentation and codebase patterns before implementing.
Pay special attention to naming of existing utils, types, and modules. Import from the right files.
## Feature Description
Introduce an `IIsolationProvider` interface to abstract workflow isolation mechanisms. Git worktrees remain the default and first-class implementation, but the abstraction enables future isolation strategies (containers, cloud VMs) while providing a consistent API for all platform adapters.
This is a refactoring of existing worktree functionality into a provider pattern, not adding new features.
## User Story
As a platform maintainer
I want to abstract isolation mechanisms behind a provider interface
So that I can add new isolation strategies (containers, VMs) without modifying platform adapters
## Problem Statement
1. **Code Duplication**: Platform adapters (GitHub, Slack, Discord) directly call git utility functions, mixing platform-specific logic with isolation implementation details.
2. **Tight Coupling**: `createWorktreeForIssue()` is called directly from adapters, making it hard to swap isolation strategies.
3. **Scattered Logic**: Worktree creation, adoption, and cleanup logic is spread across `src/utils/git.ts`, `src/adapters/github.ts`, and `src/handlers/command-handler.ts`.
## Solution Statement
Apply the **Strategy Pattern** with a **Factory** to create a provider abstraction:
1. Define `IIsolationProvider` interface with create/destroy/get/list methods
2. Implement `WorktreeProvider` that encapsulates all git worktree logic
3. Create factory function `getIsolationProvider()` for centralized instantiation
4. Migrate platform adapters to use the provider via orchestrator
## Feature Metadata
**Feature Type**: Refactor
**Estimated Complexity**: Medium
**Primary Systems Affected**:
- `src/utils/git.ts` (functions moved to provider)
- `src/adapters/github.ts` (delegation to orchestrator)
- `src/orchestrator/orchestrator.ts` (new `ensureIsolation()` function)
- `src/handlers/command-handler.ts` (use provider for `/worktree` commands)
- `src/db/conversations.ts` (new columns)
**Dependencies**: None (uses existing git via child_process)
---
## CONTEXT REFERENCES
### Relevant Codebase Files IMPORTANT: READ THESE BEFORE IMPLEMENTING!
- `src/types/index.ts` (lines 1-117) - Why: Current interface definitions; add new isolation types here
- `src/utils/git.ts` (lines 1-272) - Why: Contains all worktree logic to migrate into provider
- `src/utils/git.test.ts` (lines 1-455) - Why: Existing Bun test patterns to mirror for provider tests
- `src/adapters/github.ts` (lines 616-700) - Why: Current worktree creation flow to refactor
- `src/adapters/github.test.ts` (lines 1-500) - Why: Test patterns using Bun mock.module
- `src/orchestrator/orchestrator.ts` (lines 231-266) - Why: CWD resolution logic to integrate with
- `src/db/conversations.ts` (lines 1-126) - Why: Database operations to extend
- `src/handlers/command-handler.ts` (lines 920-1130) - Why: `/worktree` commands to update
### New Files to Create
- `src/isolation/types.ts` - Interface definitions for isolation abstraction
- `src/isolation/providers/worktree.ts` - WorktreeProvider implementation
- `src/isolation/providers/worktree.test.ts` - Unit tests for WorktreeProvider
- `src/isolation/index.ts` - Factory function and exports
- `migrations/005_isolation_abstraction.sql` - Database schema update
### Relevant Documentation YOU SHOULD READ BEFORE IMPLEMENTING!
- [Bun Mock Functions](https://bun.com/guides/test/mock-functions)
- Specific section: Creating mock functions with `mock()`
- Why: Pattern for mocking provider methods in tests
- [Bun SpyOn Guide](https://bun.com/guides/test/spy-on)
- Specific section: Spying on object methods
- Why: Pattern for spying on git commands in tests
- [Bun Module Mocking](https://github.com/oven-sh/bun/discussions/6236)
- Specific section: `mock.module()` usage
- Why: Pattern for mocking provider in adapter tests
- [TypeScript Provider Pattern](https://www.webdevtutor.net/blog/typescript-provider-pattern)
- Why: Interface abstraction best practices
- [Design Patterns - Strategy Pattern](https://blog.logrocket.com/understanding-design-patterns-typescript-node-js/)
- Why: Runtime algorithm selection pattern we're implementing
### Patterns to Follow
**Naming Conventions:**
```typescript
// Interfaces: I-prefix
export interface IIsolationProvider { ... }
export interface IsolationRequest { ... } // Data types: no prefix
export interface IsolatedEnvironment { ... }
// Implementation classes: PascalCase
export class WorktreeProvider implements IIsolationProvider { ... }
```
**Error Handling (from src/utils/git.ts):**
```typescript
try {
await execFileAsync('git', [...]);
} catch (error) {
const err = error as Error & { stderr?: string };
if (err.stderr?.includes('already exists')) {
// Handle specific case
}
throw error;
}
```
**Bun Test Patterns (from src/utils/git.test.ts):**
```typescript
import { describe, test, expect, beforeEach, afterEach, mock, spyOn, type Mock } from 'bun:test';
describe('WorktreeProvider', () => {
let execSpy: Mock<typeof git.execFileAsync>;
beforeEach(() => {
execSpy = spyOn(git, 'execFileAsync');
});
afterEach(() => {
execSpy.mockRestore();
});
test('creates worktree for issue workflow', async () => {
execSpy.mockResolvedValue({ stdout: '', stderr: '' });
// ...
});
});
```
**Database Update Pattern (from src/db/conversations.ts):**
```typescript
export async function updateConversation(
id: string,
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'worktree_path' | 'isolation_env_id' | 'isolation_provider'>>
): Promise<void> {
const fields: string[] = [];
const values: (string | null)[] = [];
let i = 1;
// Dynamic field building...
}
```
---
## IMPLEMENTATION PLAN
### Phase 1: Core Abstraction
Create the isolation provider interface and types without modifying existing code.
**Tasks:**
- Define `IIsolationProvider` interface with lifecycle methods
- Define `IsolationRequest` and `IsolatedEnvironment` data types
- Implement `WorktreeProvider` class migrating logic from `git.ts`
- Create factory function `getIsolationProvider()`
### Phase 2: Database Schema
Add new columns for provider abstraction while maintaining backwards compatibility.
**Tasks:**
- Create migration adding `isolation_env_id` and `isolation_provider` columns
- Update `Conversation` type definition
- Extend `updateConversation()` to support new columns
- Add `getConversationByIsolationEnvId()` query
### Phase 3: Integration
Wire up the provider into orchestrator and adapters.
**Tasks:**
- Add `ensureIsolation()` function to orchestrator
- Update GitHub adapter to delegate worktree creation
- Update `/worktree` commands to use provider
- Deprecate direct calls to `createWorktreeForIssue()`
### Phase 4: Testing & Validation
Comprehensive test coverage for the new provider.
**Tasks:**
- Unit tests for `WorktreeProvider` (branch naming, create, adopt, destroy)
- Integration tests via test adapter
- Verify existing tests still pass
---
## STEP-BY-STEP TASKS
IMPORTANT: Execute every task in order, top to bottom. Each task is atomic and independently testable.
### Phase 1: Core Abstraction
#### CREATE src/isolation/types.ts
- **IMPLEMENT**: Define isolation interfaces
- **PATTERN**: Follow `src/types/index.ts` interface style
- **IMPORTS**: None (pure type definitions)
- **GOTCHA**: Use `readonly` for provider type to prevent mutation
- **VALIDATE**: `bun run type-check`
```typescript
/**
* Semantic context for creating isolated environments
* Platform-agnostic - describes WHAT needs isolation, not HOW
*/
export interface IsolationRequest {
codebaseId: string;
canonicalRepoPath: string; // Main repo path, never a worktree
workflowType: 'issue' | 'pr' | 'review' | 'thread' | 'task';
identifier: string; // "42", "feature-auth", thread hash, etc.
prBranch?: string; // PR-specific (for reproducible reviews)
prSha?: string;
description?: string;
}
/**
* Result of creating an isolated environment
*/
export interface IsolatedEnvironment {
id: string;
provider: 'worktree' | 'container' | 'vm' | 'remote';
workingPath: string;
branchName?: string;
status: 'active' | 'suspended' | 'destroyed';
createdAt: Date;
metadata: Record<string, unknown>;
}
/**
* Provider interface - git worktrees are DEFAULT implementation
*/
export interface IIsolationProvider {
readonly providerType: string;
create(request: IsolationRequest): Promise<IsolatedEnvironment>;
destroy(envId: string, options?: { force?: boolean }): Promise<void>;
get(envId: string): Promise<IsolatedEnvironment | null>;
list(codebaseId: string): Promise<IsolatedEnvironment[]>;
adopt?(path: string): Promise<IsolatedEnvironment | null>;
healthCheck(envId: string): Promise<boolean>;
}
```
#### CREATE src/isolation/providers/worktree.ts
- **IMPLEMENT**: WorktreeProvider class with all git worktree operations
- **PATTERN**: Mirror `src/utils/git.ts` function signatures and error handling
- **IMPORTS**: `execFileAsync`, `mkdirAsync` from `src/utils/git.ts`, crypto for hashing
- **GOTCHA**: Preserve PR ref-based fetching for fork support (`pull/${number}/head`)
- **GOTCHA**: Preserve adoption logic for skill-app worktree symbiosis
- **GOTCHA**: Use `join()` from `path` for cross-platform path construction
- **VALIDATE**: `bun run type-check && bun test src/isolation/providers/worktree.test.ts`
Key methods to implement:
1. `generateBranchName(request)` - Semantic branch naming (issue-42, pr-42, thread-abc123)
2. `generateEnvId(request)` - Unique environment ID
3. `getWorktreePath(request, branchName)` - Path construction with WORKTREE_BASE support
4. `findExisting(request, branchName)` - Check for adoption opportunities
5. `create(request)` - Main creation logic (migrate from `createWorktreeForIssue`)
6. `destroy(envId, options)` - Removal with force option
7. `get(envId)` - Get environment by ID
8. `list(codebaseId)` - List all environments for codebase
9. `healthCheck(envId)` - Check if worktree still exists
#### CREATE src/isolation/providers/worktree.test.ts
- **IMPLEMENT**: Unit tests for WorktreeProvider
- **PATTERN**: Follow `src/utils/git.test.ts` exactly - use `spyOn` for git commands
- **IMPORTS**: `describe, test, expect, beforeEach, afterEach, spyOn, type Mock` from `bun:test`
- **GOTCHA**: Use `spyOn` on exported functions, not mock.module (to preserve module structure)
- **VALIDATE**: `bun test src/isolation/providers/worktree.test.ts`
Test cases to implement:
1. `generateBranchName` - issue-N, pr-N, thread-{hash}, task-{slug}
2. `generateBranchName` - consistent hash for same identifier
3. `create` - creates worktree for issue workflow
4. `create` - creates worktree for PR with SHA (reproducible reviews)
5. `create` - creates worktree for PR without SHA (fallback)
6. `create` - adopts existing worktree if found
7. `create` - adopts worktree by PR branch name (skill symbiosis)
8. `destroy` - removes worktree
9. `destroy` - throws on uncommitted changes without force
10. `get` - returns null for non-existent environment
11. `list` - returns all worktrees for codebase
#### CREATE src/isolation/index.ts
- **IMPLEMENT**: Factory function and re-exports
- **PATTERN**: Follow `src/clients/factory.ts` pattern
- **IMPORTS**: WorktreeProvider, IIsolationProvider
- **GOTCHA**: Singleton pattern for provider instance
- **VALIDATE**: `bun run type-check`
```typescript
import { WorktreeProvider } from './providers/worktree';
import type { IIsolationProvider, IsolationRequest, IsolatedEnvironment } from './types';
export type { IIsolationProvider, IsolationRequest, IsolatedEnvironment };
let provider: IIsolationProvider | null = null;
export function getIsolationProvider(): IIsolationProvider {
if (!provider) {
provider = new WorktreeProvider();
}
return provider;
}
// For testing - reset singleton
export function resetIsolationProvider(): void {
provider = null;
}
```
### Phase 2: Database Schema
#### CREATE migrations/005_isolation_abstraction.sql
- **IMPLEMENT**: Add isolation columns to conversations table
- **PATTERN**: Follow `migrations/003_add_worktree.sql`
- **GOTCHA**: Keep `worktree_path` for backwards compatibility during transition
- **VALIDATE**: `psql $DATABASE_URL < migrations/005_isolation_abstraction.sql`
```sql
-- Add isolation provider abstraction columns
-- Version: 5.0
-- Description: Abstract isolation mechanisms (worktrees, containers, VMs)
ALTER TABLE remote_agent_conversations
ADD COLUMN IF NOT EXISTS isolation_env_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS isolation_provider VARCHAR(50) DEFAULT 'worktree';
-- Migrate existing worktree_path data
UPDATE remote_agent_conversations
SET isolation_env_id = worktree_path,
isolation_provider = 'worktree'
WHERE worktree_path IS NOT NULL
AND isolation_env_id IS NULL;
-- Create index for lookups by isolation environment
CREATE INDEX IF NOT EXISTS idx_conversations_isolation
ON remote_agent_conversations(isolation_env_id, isolation_provider);
-- Note: Keep worktree_path for backwards compatibility during transition
-- Future migration will DROP COLUMN worktree_path after full migration
COMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS
'Unique identifier for the isolated environment (worktree path, container ID, etc.)';
COMMENT ON COLUMN remote_agent_conversations.isolation_provider IS
'Type of isolation provider (worktree, container, vm, remote)';
```
#### UPDATE src/types/index.ts
- **IMPLEMENT**: Add isolation fields to Conversation interface
- **PATTERN**: Existing optional fields use `| null`
- **IMPORTS**: None
- **GOTCHA**: Keep `worktree_path` for backwards compatibility
- **VALIDATE**: `bun run type-check`
Add after `worktree_path`:
```typescript
isolation_env_id: string | null;
isolation_provider: string | null;
```
#### UPDATE src/db/conversations.ts
- **IMPLEMENT**: Extend `updateConversation()` to support new columns
- **PATTERN**: Follow existing dynamic field building pattern
- **IMPORTS**: None (already imports Conversation from types)
- **GOTCHA**: Handle both old (`worktree_path`) and new (`isolation_env_id`) fields
- **VALIDATE**: `bun test src/db/conversations.test.ts`
Add to `updateConversation` function:
```typescript
if (updates.isolation_env_id !== undefined) {
fields.push(`isolation_env_id = $${String(i++)}`);
values.push(updates.isolation_env_id);
}
if (updates.isolation_provider !== undefined) {
fields.push(`isolation_provider = $${String(i++)}`);
values.push(updates.isolation_provider);
}
```
Also update the `updates` type parameter to include new fields.
#### ADD getConversationByIsolationEnvId to src/db/conversations.ts
- **IMPLEMENT**: Query conversation by isolation environment ID
- **PATTERN**: Follow `getConversationByWorktreePath()` pattern
- **IMPORTS**: None
- **VALIDATE**: `bun test src/db/conversations.test.ts`
```typescript
export async function getConversationByIsolationEnvId(
envId: string
): Promise<Conversation | null> {
const result = await pool.query<Conversation>(
'SELECT * FROM remote_agent_conversations WHERE isolation_env_id = $1 LIMIT 1',
[envId]
);
return result.rows[0] ?? null;
}
```
### Phase 3: Integration
#### UPDATE src/orchestrator/orchestrator.ts
- **IMPLEMENT**: Add `ensureIsolation()` function
- **PATTERN**: Follow existing `handleMessage` flow
- **IMPORTS**: `getIsolationProvider` from `../isolation`, `IsolationRequest`
- **GOTCHA**: Only create isolation when codebase is configured
- **GOTCHA**: Update both `worktree_path` (backwards compat) AND new isolation fields
- **VALIDATE**: `bun test src/orchestrator/orchestrator.test.ts`
Add new function before `handleMessage`:
```typescript
import { getIsolationProvider } from '../isolation';
import type { IsolationRequest } from '../isolation/types';
async function ensureIsolation(
conversation: Conversation,
codebase: Codebase | null,
platform: string,
conversationId: string
): Promise<Conversation> {
// Skip if already isolated
if (conversation.isolation_env_id || conversation.worktree_path) {
return conversation;
}
// Skip if no codebase configured
if (!conversation.codebase_id || !codebase) {
return conversation;
}
// Determine workflow type from platform context
const workflowType = inferWorkflowType(platform, conversationId);
const identifier = extractIdentifier(platform, conversationId);
const provider = getIsolationProvider();
const env = await provider.create({
codebaseId: conversation.codebase_id,
canonicalRepoPath: codebase.default_cwd,
workflowType,
identifier,
description: `${platform} ${workflowType} ${conversationId}`,
});
// Update conversation with isolation info (both old and new fields for compatibility)
await db.updateConversation(conversation.id, {
isolation_env_id: env.id,
isolation_provider: env.provider,
worktree_path: env.workingPath, // Backwards compatibility
cwd: env.workingPath,
});
// Reload and return updated conversation
const updated = await db.getConversationByPlatformId(platform, conversationId);
return updated ?? conversation;
}
function inferWorkflowType(
platform: string,
conversationId: string
): IsolationRequest['workflowType'] {
if (platform === 'github') {
// GitHub: owner/repo#42 - could be issue or PR
// Detection is done in adapter, here we default to issue
return 'issue';
}
// Slack, Discord, Telegram: all are threads
return 'thread';
}
function extractIdentifier(platform: string, conversationId: string): string {
if (platform === 'github') {
// Extract number from owner/repo#42
const match = /#(\d+)$/.exec(conversationId);
return match?.[1] ?? conversationId;
}
// For thread platforms, use conversation ID (will be hashed by provider)
return conversationId;
}
```
**NOTE**: Do NOT call `ensureIsolation` from orchestrator yet. The GitHub adapter already handles isolation explicitly. We will integrate gradually.
#### UPDATE src/adapters/github.ts
- **IMPLEMENT**: Use provider for worktree creation instead of direct git.ts calls
- **PATTERN**: Keep existing flow but delegate to provider
- **IMPORTS**: `getIsolationProvider` from `../isolation`
- **GOTCHA**: Preserve linked issue worktree sharing logic
- **GOTCHA**: Preserve PR head branch and SHA fetching for reproducible reviews
- **VALIDATE**: `bun test src/adapters/github.test.ts`
Replace the worktree creation section (around line 651-699) with:
```typescript
// If no shared worktree found, create new one
if (!worktreePath) {
try {
// For PRs, fetch the head branch name and SHA from GitHub API
if (isPR) {
try {
const { data: prData } = await this.octokit.rest.pulls.get({
owner,
repo,
pull_number: number,
});
prHeadBranch = prData.head.ref;
prHeadSha = prData.head.sha;
console.log(
`[GitHub] PR #${String(number)} head branch: ${prHeadBranch}, SHA: ${prHeadSha}`
);
} catch (error) {
console.warn(
'[GitHub] Failed to fetch PR head branch, will create new branch instead:',
error
);
}
}
const provider = getIsolationProvider();
const env = await provider.create({
codebaseId: codebase.id,
canonicalRepoPath: repoPath,
workflowType: isPR ? 'pr' : 'issue',
identifier: String(number),
prBranch: prHeadBranch,
prSha: prHeadSha,
description: `GitHub ${isPR ? 'PR' : 'issue'} #${String(number)}`,
});
worktreePath = env.workingPath;
console.log(`[GitHub] Created worktree: ${worktreePath}`);
// Update conversation with isolation info
await db.updateConversation(existingConv.id, {
codebase_id: codebase.id,
cwd: worktreePath,
worktree_path: worktreePath,
isolation_env_id: env.id,
isolation_provider: env.provider,
});
} catch (error) {
// ... existing error handling ...
}
}
```
Also update `cleanupWorktree` to use provider:
```typescript
private async cleanupWorktree(owner: string, repo: string, number: number): Promise<void> {
// ... existing conversation lookup ...
const provider = getIsolationProvider();
// Clear isolation reference from THIS conversation first
await db.updateConversation(conversation.id, {
worktree_path: null,
isolation_env_id: null,
isolation_provider: null,
cwd: codebase.default_cwd,
});
// Check if OTHER conversations still use this environment
const otherConv = await db.getConversationByIsolationEnvId(worktreePath);
if (otherConv) {
console.log(`[GitHub] Keeping worktree, still used by ${otherConv.platform_conversation_id}`);
return;
}
// Safe to destroy
try {
await provider.destroy(worktreePath);
console.log(`[GitHub] Removed worktree: ${worktreePath}`);
} catch (error) {
// ... existing error handling ...
}
}
```
#### UPDATE src/handlers/command-handler.ts
- **IMPLEMENT**: Use provider for `/worktree` commands
- **PATTERN**: Keep existing command structure
- **IMPORTS**: `getIsolationProvider` from `../isolation`
- **GOTCHA**: `/worktree create` should NOT deactivate session (preserve context)
- **VALIDATE**: `bun test src/handlers/command-handler.test.ts`
Update `/worktree create` case:
```typescript
case 'create': {
const branchName = args[1];
if (!branchName) {
return { success: false, message: 'Usage: /worktree create <branch-name>' };
}
// Check if already using a worktree
if (conversation.worktree_path || conversation.isolation_env_id) {
const shortPath = shortenPath(conversation.worktree_path ?? conversation.isolation_env_id ?? '', mainPath);
return {
success: false,
message: `Already using worktree: ${shortPath}\n\nRun /worktree remove first.`,
};
}
const provider = getIsolationProvider();
try {
const env = await provider.create({
codebaseId: conversation.codebase_id!,
canonicalRepoPath: mainPath,
workflowType: 'task',
identifier: branchName,
description: `Manual worktree: ${branchName}`,
});
// Update conversation to use this worktree
await db.updateConversation(conversation.id, {
worktree_path: env.workingPath,
isolation_env_id: env.id,
isolation_provider: env.provider,
cwd: env.workingPath,
});
// NOTE: Do NOT deactivate session - preserve AI context
const shortPath = shortenPath(env.workingPath, mainPath);
return {
success: true,
message: `Worktree created!\n\nBranch: ${branchName}\nPath: ${shortPath}\n\nThis conversation now works in isolation.\nRun dependency install if needed (e.g., bun install).`,
modified: true,
};
} catch (error) {
// ... existing error handling ...
}
}
```
Update `/worktree remove` case similarly.
### Phase 4: Testing & Validation
#### ADD tests to src/db/conversations.test.ts
- **IMPLEMENT**: Tests for new isolation columns
- **PATTERN**: Follow existing test patterns
- **VALIDATE**: `bun test src/db/conversations.test.ts`
Add test cases:
```typescript
describe('isolation fields', () => {
test('updateConversation updates isolation fields', async () => {
// Create conversation
// Update with isolation_env_id and isolation_provider
// Verify fields are set
});
test('getConversationByIsolationEnvId returns correct conversation', async () => {
// Create conversation with isolation_env_id
// Query by ID
// Verify correct conversation returned
});
});
```
#### VERIFY all existing tests pass
- **VALIDATE**: `bun test`
- **GOTCHA**: Some tests may need updates for new Conversation fields
---
## TESTING STRATEGY
### Unit Tests
**WorktreeProvider Tests** (`src/isolation/providers/worktree.test.ts`):
- Test branch naming for all workflow types
- Test hash consistency for thread identifiers
- Test create flow for issues, PRs (with/without SHA)
- Test adoption of existing worktrees
- Test destroy with force flag
- Test list filtering by codebase
**Database Tests** (`src/db/conversations.test.ts`):
- Test updateConversation with new fields
- Test getConversationByIsolationEnvId query
### Integration Tests
**Via Test Adapter:**
```bash
# Start app
docker-compose --profile with-db up -d postgres
bun run dev
# Test isolation creation
curl -X POST http://localhost:3000/test/message \
-H "Content-Type: application/json" \
-d '{"conversationId":"test-123","message":"/worktree create test-branch"}'
# Verify worktree created
curl http://localhost:3000/test/messages/test-123 | jq
# Verify /worktree list shows it
curl -X POST http://localhost:3000/test/message \
-H "Content-Type: application/json" \
-d '{"conversationId":"test-123","message":"/worktree list"}'
```
### Edge Cases
1. **Worktree adoption**: Skill creates worktree, app adopts it on PR event
2. **Linked issue/PR sharing**: PR shares worktree with linked issue
3. **Stale worktree path**: Worktree deleted externally, orchestrator handles gracefully
4. **Concurrent creation**: Two platforms create isolation for same identifier
---
## VALIDATION COMMANDS
Execute every command to ensure zero regressions and 100% feature correctness.
### Level 1: Syntax & Style
```bash
# TypeScript type checking (MUST pass with 0 errors)
bun run type-check
# ESLint (MUST pass with 0 errors)
bun run lint
# Prettier formatting check
bun run format:check
```
**Expected**: All commands pass with exit code 0
### Level 2: Unit Tests
```bash
# Run all tests
bun test
# Run specific test files
bun test src/isolation/providers/worktree.test.ts
bun test src/db/conversations.test.ts
# Run with coverage
bun test --coverage
```
**Expected**: All tests pass
### Level 3: Integration Tests
```bash
# Start postgres
docker-compose --profile with-db up -d postgres
# Run migration
psql $DATABASE_URL < migrations/005_isolation_abstraction.sql
# Start app
bun run dev
# Test via test adapter (in another terminal)
curl -X POST http://localhost:3000/test/message \
-H "Content-Type: application/json" \
-d '{"conversationId":"iso-test","message":"/status"}'
```
### Level 4: Manual Validation
1. **GitHub webhook test**: Create issue, verify worktree created with provider
2. **Worktree commands**: Test `/worktree create`, `/worktree list`, `/worktree remove`
3. **Session preservation**: After `/worktree create`, verify AI context preserved
---
## ACCEPTANCE CRITERIA
- [x] `IIsolationProvider` interface defined in `src/isolation/types.ts`
- [ ] `WorktreeProvider` implements all interface methods
- [ ] `WorktreeProvider` passes all unit tests
- [ ] Database migration adds isolation columns
- [ ] `Conversation` type includes new fields
- [ ] `updateConversation()` supports new fields
- [ ] GitHub adapter uses provider for worktree creation
- [ ] `/worktree` commands use provider
- [ ] All existing tests pass
- [ ] No type errors (`bun run type-check`)
- [ ] No lint errors (`bun run lint`)
- [ ] Proper formatting (`bun run format:check`)
---
## COMPLETION CHECKLIST
- [ ] All tasks completed in order
- [ ] Each task validation passed immediately
- [ ] All validation commands executed successfully:
- [ ] Level 1: type-check, lint, format:check
- [ ] Level 2: bun test with coverage
- [ ] Level 3: Integration test via test adapter
- [ ] Level 4: Manual GitHub webhook test
- [ ] Full test suite passes (bun test)
- [ ] No linting errors (bun run lint)
- [ ] No formatting errors (bun run format:check)
- [ ] No type checking errors (bun run type-check)
- [ ] All acceptance criteria met
- [ ] Code reviewed for quality and maintainability
---
## NOTES
### Design Decisions
1. **Strategy Pattern over Factory**: Using Strategy pattern with factory for provider selection enables runtime swapping and easier testing.
2. **Backwards Compatibility**: Keep `worktree_path` column during transition. Both old and new fields updated together.
3. **Session Preservation**: `/worktree create` does NOT deactivate session - preserves AI context for better UX.
4. **Semantic Identifiers**: Environment IDs use semantic prefixes (`issue-42`, `pr-42`, `thread-abc123`) for readability.
5. **No Orchestrator Auto-Isolation Yet**: GitHub adapter already handles isolation explicitly. Orchestrator `ensureIsolation()` prepared but not wired in - can be enabled for Slack/Discord later.
### Migration Strategy
1. Phase 1-2: Create abstraction without breaking existing code
2. Phase 3: Gradually migrate adapters to use provider
3. Future: Remove `worktree_path` column after full migration
### Risks
| Risk | Impact | Mitigation |
|------|--------|------------|
| Migration breaks existing worktrees | High | Keep `worktree_path` during transition, dual-read |
| Test failures from new fields | Medium | Update test fixtures incrementally |
| Performance overhead | Low | Provider is thin wrapper, minimal overhead |
### External Documentation References
- [Bun Mock Functions](https://bun.com/guides/test/mock-functions)
- [Bun SpyOn](https://bun.com/guides/test/spy-on)
- [TypeScript Provider Pattern](https://www.webdevtutor.net/blog/typescript-provider-pattern)
- [Design Patterns in TypeScript](https://blog.logrocket.com/understanding-design-patterns-typescript-node-js/)

View file

@ -0,0 +1,390 @@
# Isolation Provider Manual Testing Guide
## Prerequisites
### 1. Start the Application
```bash
# Terminal 1: Start postgres
docker-compose --profile with-db up -d postgres
# Terminal 2: Run app with hot reload
bun run dev
```
### 2. Apply Database Migration
```bash
psql $DATABASE_URL < migrations/005_isolation_abstraction.sql
```
### 3. Verify App is Running
```bash
curl http://localhost:3090/health
# Expected: {"status":"ok"}
```
---
## Test Adapter (curl)
Use for quick validation without Slack/GitHub setup.
### Setup
```bash
# Clone a test repo
curl -X POST http://localhost:3090/test/message \
-H "Content-Type: application/json" \
-d '{"conversationId":"test-worktree","message":"/clone https://github.com/anthropics/anthropic-cookbook"}'
# Wait for clone to complete (~10s)
sleep 10
# Check status
curl http://localhost:3090/test/messages/test-worktree | jq '.messages[-1].message'
```
### Test Worktree Create
```bash
curl -X POST http://localhost:3090/test/message \
-H "Content-Type: application/json" \
-d '{"conversationId":"test-worktree","message":"/worktree create my-feature"}'
sleep 2
curl http://localhost:3090/test/messages/test-worktree | jq -r '.messages[-1].message'
```
**Expected:**
```
Worktree created!
Branch: task-my-feature
Path: /Users/.../worktrees/anthropic-cookbook/task-my-feature
This conversation now works in isolation.
Run dependency install if needed (e.g., bun install).
```
### Test Worktree List
```bash
curl -X POST http://localhost:3090/test/message \
-H "Content-Type: application/json" \
-d '{"conversationId":"test-worktree","message":"/worktree list"}'
sleep 1
curl http://localhost:3090/test/messages/test-worktree | jq -r '.messages[-1].message'
```
**Expected:** Shows worktree with `<- active` marker
### Test Worktree Remove
```bash
curl -X POST http://localhost:3090/test/message \
-H "Content-Type: application/json" \
-d '{"conversationId":"test-worktree","message":"/worktree remove"}'
sleep 2
curl http://localhost:3090/test/messages/test-worktree | jq -r '.messages[-1].message'
```
**Expected:** "Worktree removed... Switched back to main repo."
### Verify Database
```bash
psql $DATABASE_URL -c "
SELECT platform_conversation_id,
COALESCE(worktree_path, 'NULL') as worktree_path,
COALESCE(isolation_env_id, 'NULL') as isolation_env_id,
COALESCE(isolation_provider, 'NULL') as isolation_provider
FROM remote_agent_conversations
WHERE platform_conversation_id = 'test-worktree';"
```
### Cleanup
```bash
curl -X DELETE http://localhost:3090/test/messages/test-worktree
```
---
## Slack Testing
### Prerequisites
- Bot configured with `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN`
- Bot added to a test channel
- Bot mention name: `@Dylan` (or your configured name)
### Test 1: Setup Repository
In any Slack channel or thread:
```
@Dylan /clone https://github.com/anthropics/anthropic-cookbook
```
**Expected:** "Repository cloned successfully!"
### Test 2: Create Worktree
```
@Dylan /worktree create slack-feature-test
```
**Expected:**
```
Worktree created!
Branch: task-slack-feature-test
Path: .../worktrees/anthropic-cookbook/task-slack-feature-test
This conversation now works in isolation.
Run dependency install if needed (e.g., bun install).
```
### Test 3: Verify Isolation
```
@Dylan /status
```
**Expected:** Shows current working directory as the worktree path
### Test 4: List Worktrees
```
@Dylan /worktree list
```
**Expected:** Shows all worktrees with `<- active` marker on current one
### Test 5: Work in Isolation
```
@Dylan /command-invoke prime
```
**Expected:** AI works within the isolated worktree
### Test 6: Remove Worktree
```
@Dylan /worktree remove
```
**Expected:** "Worktree removed... Switched back to main repo."
### Test 7: Verify Cleanup
```
@Dylan /status
```
**Expected:** CWD back to main repository path
---
## GitHub Testing
### Prerequisites
- GitHub App or webhook configured
- `GITHUB_TOKEN` set for CLI operations
- Bot mention: `@Dylan` (configured via `GITHUB_BOT_MENTION`)
### Test 1: Issue Workflow - Auto Worktree Creation
1. **Create a new issue** in your test repository
2. **Trigger the bot** by commenting:
```
@Dylan /command-invoke plan
```
3. **Expected behavior:**
- Bot creates worktree `issue-{number}`
- Bot responds with plan in isolated environment
- Check logs: `[GitHub] Created worktree: .../issue-{number}`
4. **Verify isolation:**
```
@Dylan /status
```
**Expected:** Shows worktree path like `/worktrees/repo/issue-42`
5. **Close the issue**
6. **Expected cleanup:**
- Worktree automatically removed
- Check logs: `[GitHub] Removed worktree: ...`
### Test 2: PR Workflow - SHA-based Review
1. **Create a PR** (or use existing)
2. **Trigger review:**
```
@Dylan /command-invoke review
```
3. **Expected behavior:**
- Bot fetches PR head SHA
- Creates worktree at exact commit: `pr-{number}-review`
- Bot can review the exact code state
4. **Check the worktree:**
```
@Dylan /worktree list
```
**Expected:** Shows `pr-{number}-review` as active
5. **Merge or close the PR**
6. **Expected cleanup:** Worktree removed automatically
### Test 3: Linked Issue/PR Worktree Sharing
1. **Create issue #X**
2. **Start work on issue:**
```
@Dylan /command-invoke plan
```
**Expected:** Creates worktree `issue-X`
3. **Note the worktree path** from the response or logs
4. **Create a PR** with description containing `Fixes #X` or `Closes #X`
5. **Comment on the PR:**
```
@Dylan /status
```
6. **Expected:** PR uses the SAME worktree as the linked issue
- Check logs: `[GitHub] PR #Y linked to issue #X, sharing worktree: ...`
7. **Close the issue** (PR still open)
8. **Expected:** Worktree NOT removed (PR still using it)
9. **Merge/close the PR**
10. **Expected:** Worktree removed (no more references)
### Test 4: Fork PR Handling
1. **Have someone create a PR from a fork**
2. **Comment:**
```
@Dylan /command-invoke review
```
3. **Expected:**
- Uses GitHub PR refs (`pull/{number}/head`) to fetch fork code
- Creates worktree successfully even though branch is from fork
- Check logs for: `fetch origin pull/{number}/head`
---
## Database Verification Commands
### Check All Active Worktrees
```bash
psql $DATABASE_URL -c "
SELECT platform_type,
platform_conversation_id,
worktree_path,
isolation_env_id,
isolation_provider
FROM remote_agent_conversations
WHERE isolation_env_id IS NOT NULL
OR worktree_path IS NOT NULL
ORDER BY updated_at DESC;"
```
### Verify Both Fields Populated
```bash
psql $DATABASE_URL -c "
SELECT
COUNT(*) as total_with_isolation,
COUNT(worktree_path) as has_worktree_path,
COUNT(isolation_env_id) as has_isolation_env_id,
COUNT(isolation_provider) as has_isolation_provider
FROM remote_agent_conversations
WHERE worktree_path IS NOT NULL
OR isolation_env_id IS NOT NULL;"
```
**Expected:** All three counts should be equal (both fields populated)
### Check Specific Conversation
```bash
psql $DATABASE_URL -c "
SELECT * FROM remote_agent_conversations
WHERE platform_conversation_id = 'owner/repo#42';"
```
---
## Expected Branch Naming
| Workflow | Branch Name |
|----------|-------------|
| Issue #42 | `issue-42` |
| PR #42 (no existing branch) | `pr-42` |
| PR #42 review (with SHA) | `pr-42-review` |
| Manual `/worktree create foo` | `task-foo` |
| Slack/Discord thread (future) | `thread-{8-char-hash}` |
---
## Troubleshooting
### Worktree Creation Fails
```
Failed to create isolated worktree for branch `issue-42`
```
**Causes:**
- Branch already exists: Try with different issue number
- Git repo not clean: Check for uncommitted changes in main repo
- Permission issues: Check filesystem permissions
**Fix:**
```bash
# List existing worktrees
git -C /path/to/repo worktree list
# Remove stale worktree
git -C /path/to/repo worktree remove /path/to/worktree --force
```
### Database Column Missing
```
column "isolation_env_id" does not exist
```
**Fix:** Apply migration
```bash
psql $DATABASE_URL < migrations/005_isolation_abstraction.sql
```
### Worktree Not Cleaned Up on Close
Check server logs for errors. Common issues:
- Uncommitted changes in worktree (won't force-delete)
- Another conversation still references the worktree
**Manual cleanup:**
```bash
git -C /path/to/main/repo worktree remove /path/to/worktree --force
```
---
## Server Log Messages to Watch
### Successful Creation
```
[GitHub] PR #42 head branch: feature/auth, SHA: abc123
[GitHub] Created worktree: /path/to/worktrees/repo/pr-42-review
```
### Worktree Adoption (Skill Symbiosis)
```
[WorktreeProvider] Adopting existing worktree: /path/to/worktrees/repo/feature-auth
```
### Linked Issue Sharing
```
[GitHub] PR #43 linked to issue #42, sharing worktree: /path/to/worktrees/repo/issue-42
```
### Cleanup
```
[GitHub] Deactivated session abc123 for worktree cleanup
[GitHub] Removed worktree: /path/to/worktrees/repo/issue-42
[GitHub] Cleanup complete for owner/repo#42
```
### Shared Worktree Preserved
```
[GitHub] Keeping worktree /path/..., still used by owner/repo#43
```

View file

@ -0,0 +1,145 @@
# Isolation Provider Migration Status
## Overview
This document tracks the migration from direct git worktree usage to the `IIsolationProvider` abstraction.
## Completed (Phase 1)
### Core Abstraction
- [x] `src/isolation/types.ts` - Interfaces (`IsolationRequest`, `IsolatedEnvironment`, `IIsolationProvider`)
- [x] `src/isolation/providers/worktree.ts` - `WorktreeProvider` implementation
- [x] `src/isolation/providers/worktree.test.ts` - Unit tests (25 tests passing)
- [x] `src/isolation/index.ts` - Factory with singleton pattern
### Database
- [x] `migrations/005_isolation_abstraction.sql` - Adds `isolation_env_id` and `isolation_provider` columns
- [x] `src/types/index.ts` - Added new fields to `Conversation` type
- [x] `src/db/conversations.ts` - Updated `updateConversation()`, added `getConversationByIsolationEnvId()`
### Adapters
- [x] `src/adapters/github.ts` - Migrated to use provider for create/destroy
- [x] `src/handlers/command-handler.ts` - `/worktree` commands use provider
---
## Not Yet Migrated
### Orchestrator CWD Resolution
**File:** `src/orchestrator/orchestrator.ts`
The orchestrator still references `worktree_path` directly:
```typescript
// Line 151: Resume session CWD
const cwd = conversation.worktree_path ?? conversation.cwd ?? codebase.default_cwd;
// Line 240: New session CWD
let cwd = conversation.worktree_path ?? conversation.cwd ?? codebase?.default_cwd ?? '/workspace';
// Lines 256-259: Stale worktree cleanup
if (conversation.worktree_path) {
await db.updateConversation(conversation.id, {
worktree_path: null,
cwd: codebase?.default_cwd ?? '/workspace',
});
}
```
**Required changes:**
```typescript
// Should become:
const cwd = conversation.isolation_env_id ?? conversation.worktree_path ?? conversation.cwd ?? codebase?.default_cwd ?? '/workspace';
// And cleanup should clear both:
if (conversation.isolation_env_id || conversation.worktree_path) {
await db.updateConversation(conversation.id, {
worktree_path: null,
isolation_env_id: null,
isolation_provider: null,
cwd: codebase?.default_cwd ?? '/workspace',
});
}
```
### GitHub Linked Issue Sharing
**File:** `src/adapters/github.ts`
Lines 627-647 still query by `worktree_path` for linked issue detection:
```typescript
const issueConv = await db.getConversationByPlatformId('github', issueConvId);
if (issueConv?.worktree_path) {
worktreePath = issueConv.worktree_path;
// ...
}
```
**Required changes:**
- Should also check `isolation_env_id` field
- Or use `getConversationByIsolationEnvId()` for lookups
### Other Platform Adapters (Phase 3)
These adapters do NOT currently create worktrees automatically:
| Adapter | File | Auto-Isolation |
|---------|------|----------------|
| Slack | `src/adapters/slack.ts` | No |
| Discord | `src/adapters/discord.ts` | No |
| Telegram | `src/adapters/telegram.ts` | No |
**To enable (Phase 3):**
1. Add provider call in message handler
2. Use workflow type `thread` with conversation ID as identifier
3. Branch naming: `thread-{8-char-hash}`
### Database Column Cleanup (Future)
The `worktree_path` column is kept for backwards compatibility. After full migration:
```sql
-- Future migration (after all code uses isolation_env_id)
ALTER TABLE remote_agent_conversations DROP COLUMN worktree_path;
```
---
## Migration Checklist
### Before Dropping worktree_path
- [ ] All orchestrator references updated to `isolation_env_id`
- [ ] GitHub linked issue sharing uses `isolation_env_id`
- [ ] All tests updated to use new fields
- [ ] Production data migrated (already done via migration 005)
- [ ] Monitoring confirms no code paths use `worktree_path` exclusively
### Phase 3: Platform Parity
- [ ] Slack adapter auto-creates worktrees for threads
- [ ] Discord adapter auto-creates worktrees for threads
- [ ] Telegram adapter auto-creates worktrees for chats
- [ ] Test adapter supports isolation (for E2E testing)
---
## Files Still Referencing worktree_path
```
src/types/index.ts # Type definition (keep for now)
src/handlers/command-handler.ts # Backwards compat checks
src/handlers/command-handler.test.ts # Test fixtures
src/db/conversations.ts # Update function, query function
src/adapters/github.ts # Linked issue sharing, cleanup
src/orchestrator/orchestrator.ts # CWD resolution (NEEDS UPDATE)
src/orchestrator/orchestrator.test.ts # Test fixtures
src/db/conversations.test.ts # Test fixtures
src/adapters/github.test.ts # Test fixtures
```
---
## Testing the Migration
See: `.agents/plans/isolation-provider-manual-testing.md`

View file

@ -2,7 +2,7 @@
Comprehensive guide to understanding and extending the Remote Coding Agent platform.
**Navigation:** [Overview](#system-overview) • [Adding Platforms](#adding-platform-adapters) • [Adding AI Assistants](#adding-ai-assistant-clients) • [Commands](#command-system) • [Streaming](#streaming-modes) • [Database](#database-schema)
**Navigation:** [Overview](#system-overview) • [Platforms](#adding-platform-adapters) • [AI Assistants](#adding-ai-assistant-clients) • [Isolation](#isolation-providers) • [Commands](#command-system) • [Streaming](#streaming-modes) • [Database](#database-schema)
---
@ -28,19 +28,19 @@ The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrato
│ • Stream responses back to platforms │
└──────────────┬──────────────────────────────┘
┌───────────────┐
┌─────────────┐ ┌─────────────────────
Command │ │ AI Assistant
Handler │ │ Clients
│ │ • IAssistantClient
(Slash │ │ • Factory pattern
commands) │ │ • Streaming API
└─────────────┘ └────────┬────────────┘
│ │
└────────┬─────────┘
┌───────────────┐
┌───────────┐ ┌───────────────┐ ┌───────────────────┐
Command │ │ AI Assistant │ │ Isolation
Handler │ │ Clients │ │ Providers
│ │ │ │
(Slash │ │ IAssistant- │ │ IIsolationProvider
commands) │ │ Client │ │ (worktree, etc.)
└─────┬─────┘ └───────┬───────┘ └─────────┬─────────┘
└───────────────┼───────────────────┘
┌─────────────────────────────────────────────┐
│ PostgreSQL (3 Tables) │
│ • Codebases • Conversations • Sessions │
@ -451,6 +451,205 @@ if (event.type === 'error') {
---
## Isolation Providers
Isolation providers create isolated working environments (worktrees, containers, VMs) for concurrent workflows. The default implementation uses git worktrees.
### IIsolationProvider Interface
**Location:** `src/isolation/types.ts`
```typescript
export interface IIsolationProvider {
readonly providerType: string;
create(request: IsolationRequest): Promise<IsolatedEnvironment>;
destroy(envId: string, options?: { force?: boolean }): Promise<void>;
get(envId: string): Promise<IsolatedEnvironment | null>;
list(codebaseId: string): Promise<IsolatedEnvironment[]>;
adopt?(path: string): Promise<IsolatedEnvironment | null>;
healthCheck(envId: string): Promise<boolean>;
}
```
### Request & Response Types
```typescript
interface IsolationRequest {
codebaseId: string;
canonicalRepoPath: string; // Main repo path, never a worktree
workflowType: 'issue' | 'pr' | 'review' | 'thread' | 'task';
identifier: string; // "42", "feature-auth", etc.
prBranch?: string; // For PR adoption
prSha?: string; // For reproducible PR reviews
}
interface IsolatedEnvironment {
id: string; // Worktree path (for worktree provider)
provider: 'worktree' | 'container' | 'vm' | 'remote';
workingPath: string; // Where AI should work
branchName?: string;
status: 'active' | 'suspended' | 'destroyed';
createdAt: Date;
metadata: Record<string, unknown>;
}
```
### WorktreeProvider Implementation
**Location:** `src/isolation/providers/worktree.ts`
```typescript
export class WorktreeProvider implements IIsolationProvider {
readonly providerType = 'worktree';
async create(request: IsolationRequest): Promise<IsolatedEnvironment> {
// 1. Check for existing worktree (adoption)
// 2. Generate branch name from workflowType + identifier
// 3. Create git worktree at computed path
// 4. Return IsolatedEnvironment
}
async destroy(envId: string, options?: { force?: boolean }): Promise<void> {
// git worktree remove <path> [--force]
}
}
```
### Branch Naming Convention
| Workflow | Identifier | Generated Branch |
|----------|------------|------------------|
| issue | `"42"` | `issue-42` |
| pr | `"123"` | `pr-123` |
| pr + SHA | `"123"` | `pr-123-review` |
| task | `"my-feature"` | `task-my-feature` |
| thread | `"C123:ts.123"` | `thread-a1b2c3d4` (8-char hash) |
### Storage Location
```
LOCAL: ~/tmp/worktrees/<project>/<branch>/ ← WORKTREE_BASE can override
DOCKER: /workspace/worktrees/<project>/<branch>/ ← FIXED, no override
```
**Logic in `getWorktreeBase()`:**
1. Docker detected? → `/workspace/worktrees` (always, no override)
2. `WORKTREE_BASE` set? → use it (local only)
3. Default → `~/tmp/worktrees`
### Usage Pattern
**GitHub adapter** (`src/adapters/github.ts`):
```typescript
const provider = getIsolationProvider();
// On @bot mention
const env = await provider.create({
codebaseId: codebase.id,
canonicalRepoPath: repoPath,
workflowType: isPR ? 'pr' : 'issue',
identifier: String(number),
prBranch: prHeadBranch,
prSha: prHeadSha,
});
// Update conversation
await db.updateConversation(conv.id, {
cwd: env.workingPath,
isolation_env_id: env.id,
isolation_provider: env.provider,
});
// On issue/PR close
await provider.destroy(isolationEnvId);
```
**Command handler** (`/worktree create`):
```typescript
const provider = getIsolationProvider();
const env = await provider.create({
workflowType: 'task',
identifier: branchName,
// ...
});
```
### Worktree Adoption
The provider adopts existing worktrees before creating new ones:
1. **Path match**: If worktree exists at expected path → adopt
2. **Branch match**: If PR's branch has existing worktree → adopt (skill symbiosis)
```typescript
// Inside create()
const existing = await this.findExisting(request, branchName, worktreePath);
if (existing) {
return existing; // metadata.adopted = true
}
// ... else create new
```
### Database Fields
```sql
remote_agent_conversations
├── worktree_path -- LEGACY (kept for compatibility)
├── isolation_env_id -- NEW: provider-assigned ID (worktree path)
└── isolation_provider -- NEW: 'worktree' | 'container' | ...
```
**Lookup pattern:**
```typescript
const envId = conversation.isolation_env_id ?? conversation.worktree_path;
```
### Adding a New Isolation Provider
**1. Create provider:** `src/isolation/providers/your-provider.ts`
```typescript
export class ContainerProvider implements IIsolationProvider {
readonly providerType = 'container';
async create(request: IsolationRequest): Promise<IsolatedEnvironment> {
// Spin up Docker container with repo mounted
const containerId = await docker.createContainer({...});
return {
id: containerId,
provider: 'container',
workingPath: '/workspace',
status: 'active',
createdAt: new Date(),
metadata: { request },
};
}
async destroy(envId: string): Promise<void> {
await docker.removeContainer(envId);
}
}
```
**2. Register in factory:** `src/isolation/index.ts`
```typescript
export function getIsolationProvider(type?: string): IIsolationProvider {
switch (type) {
case 'container':
return new ContainerProvider();
default:
return new WorktreeProvider();
}
}
```
**See also:** [Worktree Orchestration](./worktree-orchestration.md) for detailed flow diagrams.
---
## Command System
The command system allows users to define custom workflows in Git-versioned markdown files.
@ -958,6 +1157,18 @@ Post single comment on issue with summary
- [ ] Test session persistence across restarts
- [ ] Test plan→execute transition (new session)
### Adding a New Isolation Provider
- [ ] Create `src/isolation/providers/your-provider.ts`
- [ ] Implement `IIsolationProvider` interface
- [ ] Handle `create()`, `destroy()`, `get()`, `list()`, `healthCheck()`
- [ ] Optional: implement `adopt()` for existing environment discovery
- [ ] Register in `src/isolation/index.ts` factory
- [ ] Update database columns if needed (`isolation_provider` type)
- [ ] Test creation and cleanup lifecycle
- [ ] Test concurrent environments (multiple conversations)
- [ ] Document in `docs/worktree-orchestration.md` (or create new doc)
### Modifying Command System
- [ ] Update `substituteVariables()` for new variable types
@ -1035,7 +1246,7 @@ await handleMessage(adapter, conversationId, finalMessage);
## Key Takeaways
1. **Interfaces enable extensibility**: Both `IPlatformAdapter` and `IAssistantClient` allow adding platforms and AI assistants without modifying core logic
1. **Interfaces enable extensibility**: `IPlatformAdapter`, `IAssistantClient`, and `IIsolationProvider` allow adding platforms, AI assistants, and isolation strategies without modifying core logic
2. **Async generators for streaming**: All AI clients return `AsyncGenerator<MessageChunk>` for unified streaming across different SDKs
@ -1047,15 +1258,18 @@ await handleMessage(adapter, conversationId, finalMessage);
6. **Plan→execute is special**: Only transition requiring new session (prevents token bloat during implementation)
7. **Factory pattern for clients**: Single factory function (`getAssistantClient()`) instantiates correct client based on conversation's `ai_assistant_type`
7. **Factory pattern**: `getAssistantClient()` and `getIsolationProvider()` instantiate correct implementations based on configuration
8. **Error recovery**: Always provide `/reset` escape hatch for users when sessions get stuck
9. **Isolation adoption**: Providers check for existing environments before creating new ones (enables skill symbiosis)
---
**For detailed implementation examples, see:**
- Platform adapter: `src/adapters/telegram.ts`, `src/adapters/github.ts`
- AI client: `src/clients/claude.ts`, `src/clients/codex.ts`
- Isolation provider: `src/isolation/providers/worktree.ts`
- Orchestrator: `src/orchestrator/orchestrator.ts`
- Command handler: `src/handlers/command-handler.ts`

View file

@ -0,0 +1,263 @@
# Worktree Orchestration
## Storage Location
```
LOCAL: ~/tmp/worktrees/<project>/<branch>/ ← WORKTREE_BASE can override
DOCKER: /workspace/worktrees/<project>/<branch>/ ← FIXED, no override
```
Detection order in `getWorktreeBase()`:
```
1. isDocker? → /workspace/worktrees (ALWAYS)
2. WORKTREE_BASE set? → use it (local only)
3. default → ~/tmp/worktrees
```
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ ENTRY POINTS │
├─────────────────────────────────────────────────────────────────┤
│ GitHub Adapter │ Command Handler (/worktree) │
│ - Issue/PR webhooks │ - /worktree create <branch>
│ - Auto-create on @bot │ - /worktree remove [--force] │
│ - Auto-cleanup on close │ - /worktree list / orphans │
└────────────┬─────────────┴────────────────┬─────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ ISOLATION PROVIDER │
│ getIsolationProvider() → WorktreeProvider (singleton) │
├─────────────────────────────────────────────────────────────────┤
│ create(request) → IsolatedEnvironment │
│ destroy(envId) → void │
│ get(envId) → IsolatedEnvironment | null │
│ list(codebaseId) → IsolatedEnvironment[] │
│ adopt(path) → IsolatedEnvironment | null │
│ healthCheck(id) → boolean │
└────────────┬────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ GIT OPERATIONS │
│ git worktree add/remove/list │
│ git fetch origin pull/<N>/head (for PRs) │
└─────────────────────────────────────────────────────────────────┘
```
## Request Types & Branch Naming
```typescript
interface IsolationRequest {
codebaseId: string;
canonicalRepoPath: string; // Main repo, never a worktree
workflowType: 'issue' | 'pr' | 'review' | 'thread' | 'task';
identifier: string;
prBranch?: string; // For PR adoption
prSha?: string; // For reproducible reviews
}
```
| Workflow | Identifier | Branch Name |
|----------|------------|-------------|
| issue | `"42"` | `issue-42` |
| pr | `"123"` | `pr-123` |
| pr + SHA | `"123"` | `pr-123-review` |
| task | `"my-feature"` | `task-my-feature` |
| thread | `"C123:ts.123"` | `thread-a1b2c3d4` (hash) |
## Creation Flow
```
IsolationRequest
┌──────────────┐ exists? ┌──────────────┐
│ Check path │────────YES─────▶│ ADOPT │──▶ return existing
│ worktreeExists() │ metadata.adopted=true
└──────┬───────┘ └──────────────┘
│ NO
┌──────────────┐ found? ┌──────────────┐
│ PR? Check │────────YES─────▶│ ADOPT │──▶ return existing
│ branch match │ │ by branch │
│ findWorktreeByBranch() └──────────────┘
└──────┬───────┘
│ NO
┌──────────────────────────────────────────────────┐
│ CREATE NEW WORKTREE │
│ │
│ Issue/Task: │
│ git worktree add <path> -b <branch>
│ (falls back to existing branch if exists) │
│ │
│ PR with SHA: │
│ git fetch origin pull/<N>/head │
│ git worktree add <path> <sha>
│ git checkout -b pr-<N>-review <sha>
│ │
│ PR without SHA: │
│ git fetch origin pull/<N>/head:pr-<N>-review │
│ git worktree add <path> pr-<N>-review │
└──────────────────────────────────────────────────┘
```
## GitHub Lifecycle
```
┌─────────────────── ISSUE/PR OPENED ───────────────────┐
│ │
@bot mention detected │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Check for shared worktree │ │
│ │ (linked issue/PR via "Fixes #X") │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ found? │ │
│ ┌────YES─────┴─────NO────┐ │
│ ▼ ▼ │
│ REUSE CREATE │
│ existing provider.create() │
│ │
│ ┌───────────────────────────────┐ │
│ │ UPDATE DATABASE │ │
│ │ cwd = worktreePath │ │
│ │ worktree_path = worktreePath │ │
│ │ isolation_env_id = envId │ │
│ │ isolation_provider = 'worktree'│ │
│ └───────────────────────────────┘ │
│ │
│ AI works in isolated worktree... │
│ │
└───────────────────────────────────────────────────────┘
┌─────────────────── ISSUE/PR CLOSED ───────────────────┐
│ │
│ cleanupPRWorktree() called │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 1. Clear THIS conversation's refs │ │
│ │ worktree_path = NULL │ │
│ │ isolation_env_id = NULL │ │
│ │ cwd = main repo │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 2. Check: other conversations │ │
│ │ using same worktree? │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ YES │ NO │
│ ┌────────────┴───────────┐ │
│ ▼ ▼ │
│ KEEP DESTROY │
│ (log: still provider.destroy(envId) │
│ used by...) │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ uncommitted │ │
│ │ changes? │ │
│ └─────────┬─────────┘ │
│ YES │ NO │
│ ┌─────────┴─────────┐ │
│ ▼ ▼ │
│ FAIL git worktree │
│ (notify user) remove <path>
│ │
└───────────────────────────────────────────────────────┘
```
## Shared Worktree (Linked Issue/PR)
```
Issue #42: "Fix login bug"
│ User works on issue
┌──────────────────────┐
│ worktree: issue-42 │
│ conversations: │
│ - owner/repo#42 │◀─── Issue references this
└──────────────────────┘
│ User opens PR with "Fixes #42"
┌──────────────────────┐
│ worktree: issue-42 │
│ conversations: │
│ - owner/repo#42 │◀─── Issue still references
│ - owner/repo#99 │◀─── PR SHARES same worktree
└──────────────────────┘
│ Issue #42 closed
┌──────────────────────┐
│ worktree: issue-42 │ ← KEPT (PR still using)
│ conversations: │
│ - owner/repo#99 │
└──────────────────────┘
│ PR #99 merged
┌──────────────────────┐
│ worktree: REMOVED │ ← No more references
└──────────────────────┘
```
## Database Schema
```sql
conversations
├── id
├── platform_conversation_id -- "owner/repo#42"
├── cwd -- Current working directory
├── worktree_path -- LEGACY (keep for compatibility)
├── isolation_env_id -- NEW: worktree path as ID
└── isolation_provider -- NEW: 'worktree' | 'container' | ...
```
Lookup pattern:
```typescript
const envId = conversation.isolation_env_id ?? conversation.worktree_path;
```
## Skill Symbiosis
The worktree-manager Claude Code skill uses `~/.claude/worktree-registry.json`.
**Adoption scenarios:**
1. **Path match**: Skill created worktree at expected path → adopted
2. **Branch match**: Skill created worktree for PR's branch → adopted
```
Skill creates: ~/tmp/worktrees/myapp/feature-auth/
PR opened for branch "feature/auth"
App checks: findWorktreeByBranch("feature/auth")
matches "feature-auth" (slugified)
ADOPT existing worktree
(no duplicate created)
```
## Key Files
| File | Purpose |
|------|---------|
| `src/isolation/types.ts` | `IIsolationProvider`, `IsolationRequest`, `IsolatedEnvironment` |
| `src/isolation/providers/worktree.ts` | `WorktreeProvider` implementation |
| `src/isolation/index.ts` | `getIsolationProvider()` factory |
| `src/utils/git.ts` | `getWorktreeBase()`, `listWorktrees()`, low-level git ops |
| `src/adapters/github.ts` | Webhook handling, `cleanupPRWorktree()` |
| `src/handlers/command-handler.ts` | `/worktree` command handling |

View file

@ -0,0 +1,25 @@
-- Add isolation provider abstraction columns
-- Version: 5.0
-- Description: Abstract isolation mechanisms (worktrees, containers, VMs)
ALTER TABLE remote_agent_conversations
ADD COLUMN IF NOT EXISTS isolation_env_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS isolation_provider VARCHAR(50) DEFAULT 'worktree';
-- Migrate existing worktree_path data
UPDATE remote_agent_conversations
SET isolation_env_id = worktree_path,
isolation_provider = 'worktree'
WHERE worktree_path IS NOT NULL
AND isolation_env_id IS NULL;
-- Create index for lookups by isolation environment
CREATE INDEX IF NOT EXISTS idx_conversations_isolation
ON remote_agent_conversations(isolation_env_id, isolation_provider);
-- Note: Keep worktree_path for backwards compatibility during transition
-- Future migration will DROP COLUMN worktree_path after full migration
COMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS
'Unique identifier for the isolated environment (worktree path, container ID, etc.)';
COMMENT ON COLUMN remote_agent_conversations.isolation_provider IS
'Type of isolation provider (worktree, container, vm, remote)';

View file

@ -15,7 +15,8 @@ import { promisify } from 'util';
import { readdir, access } from 'fs/promises';
import { join, resolve } from 'path';
import { parseAllowedUsers, isGitHubUserAuthorized } from '../utils/github-auth';
import { isWorktreePath, createWorktreeForIssue, removeWorktree } from '../utils/git';
import { isWorktreePath } from '../utils/git';
import { getIsolationProvider } from '../isolation';
import { getLinkedIssueNumbers } from '../utils/github-graphql';
const execAsync = promisify(exec);
@ -444,7 +445,14 @@ export class GitHubAdapter implements IPlatformAdapter {
const conversationId = this.buildConversationId(owner, repo, number);
const conversation = await db.getConversationByPlatformId('github', conversationId);
if (!conversation?.worktree_path) {
// Check both old and new isolation fields for backwards compatibility
if (!conversation) {
console.log(`[GitHub] No conversation to cleanup for ${conversationId}`);
return;
}
const isolationEnvId = conversation.isolation_env_id ?? conversation.worktree_path;
if (!isolationEnvId) {
console.log(`[GitHub] No worktree to cleanup for ${conversationId}`);
return;
}
@ -458,34 +466,39 @@ export class GitHubAdapter implements IPlatformAdapter {
}
const { codebase } = await this.getOrCreateCodebaseForRepo(owner, repo);
const worktreePath = conversation.worktree_path;
// Clear worktree path from THIS conversation first
// Clear isolation references from THIS conversation first
// This must happen before checking for other users of the worktree
await db.updateConversation(conversation.id, {
worktree_path: null,
isolation_env_id: null,
isolation_provider: null,
cwd: codebase.default_cwd,
});
// Check if any OTHER conversations still use this worktree (shared worktree case)
const otherConv = await db.getConversationByWorktreePath(worktreePath);
// Check both old and new fields for backwards compatibility
const otherConvByWorktree = await db.getConversationByWorktreePath(isolationEnvId);
const otherConvByEnvId = await db.getConversationByIsolationEnvId(isolationEnvId);
const otherConv = otherConvByWorktree ?? otherConvByEnvId;
if (otherConv) {
console.log(
`[GitHub] Keeping worktree ${worktreePath}, still used by ${otherConv.platform_conversation_id}`
`[GitHub] Keeping worktree ${isolationEnvId}, still used by ${otherConv.platform_conversation_id}`
);
console.log(`[GitHub] Cleanup complete for ${conversationId}`);
return;
}
// No other conversations use this worktree - safe to remove
// No other conversations use this worktree - safe to remove via provider
const provider = getIsolationProvider();
try {
await removeWorktree(codebase.default_cwd, worktreePath);
console.log(`[GitHub] Removed worktree: ${worktreePath}`);
await provider.destroy(isolationEnvId);
console.log(`[GitHub] Removed worktree: ${isolationEnvId}`);
} catch (error) {
const err = error as Error;
// Handle already-deleted worktree gracefully (e.g., manual cleanup)
if (err.message.includes('is not a working tree')) {
console.log(`[GitHub] Worktree already removed: ${worktreePath}`);
console.log(`[GitHub] Worktree already removed: ${isolationEnvId}`);
} else {
console.error('[GitHub] Failed to remove worktree:', error);
// Notify user about orphaned worktree (likely has uncommitted changes)
@ -493,7 +506,7 @@ export class GitHubAdapter implements IPlatformAdapter {
if (hasUncommittedChanges) {
await this.sendMessage(
conversationId,
`Warning: Could not remove worktree at \`${worktreePath}\` because it contains uncommitted changes. You may want to manually commit or discard these changes.`
`Warning: Could not remove worktree at \`${isolationEnvId}\` because it contains uncommitted changes. You may want to manually commit or discard these changes.`
);
}
}
@ -647,7 +660,7 @@ ${userComment}`;
}
}
// If no shared worktree found, create new one
// If no shared worktree found, create new one via provider
if (!worktreePath) {
try {
// For PRs, fetch the head branch name and SHA from GitHub API
@ -671,20 +684,28 @@ ${userComment}`;
}
}
worktreePath = await createWorktreeForIssue(
repoPath,
number,
isPR,
prHeadBranch,
prHeadSha
);
// Use isolation provider for worktree creation
const provider = getIsolationProvider();
const env = await provider.create({
codebaseId: codebase.id,
canonicalRepoPath: repoPath,
workflowType: isPR ? 'pr' : 'issue',
identifier: String(number),
prBranch: prHeadBranch,
prSha: prHeadSha,
description: `GitHub ${isPR ? 'PR' : 'issue'} #${String(number)}`,
});
worktreePath = env.workingPath;
console.log(`[GitHub] Created worktree: ${worktreePath}`);
// Update conversation with worktree path
// Update conversation with isolation info (both old and new fields for compatibility)
await db.updateConversation(existingConv.id, {
codebase_id: codebase.id,
cwd: worktreePath,
worktree_path: worktreePath,
isolation_env_id: env.id,
isolation_provider: env.provider,
});
} catch (error) {
const err = error as Error;

View file

@ -92,7 +92,12 @@ export async function getOrCreateConversation(
export async function updateConversation(
id: string,
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'worktree_path'>>
updates: Partial<
Pick<
Conversation,
'codebase_id' | 'cwd' | 'worktree_path' | 'isolation_env_id' | 'isolation_provider'
>
>
): Promise<void> {
const fields: string[] = [];
const values: (string | null)[] = [];
@ -110,6 +115,14 @@ export async function updateConversation(
fields.push(`worktree_path = $${String(i++)}`);
values.push(updates.worktree_path);
}
if (updates.isolation_env_id !== undefined) {
fields.push(`isolation_env_id = $${String(i++)}`);
values.push(updates.isolation_env_id);
}
if (updates.isolation_provider !== undefined) {
fields.push(`isolation_provider = $${String(i++)}`);
values.push(updates.isolation_provider);
}
if (fields.length === 0) {
return; // No updates
@ -123,3 +136,17 @@ export async function updateConversation(
values
);
}
/**
* Find a conversation by isolation environment ID
* Used for provider-based lookup and shared environment detection
*/
export async function getConversationByIsolationEnvId(
envId: string
): Promise<Conversation | null> {
const result = await pool.query<Conversation>(
'SELECT * FROM remote_agent_conversations WHERE isolation_env_id = $1 LIMIT 1',
[envId]
);
return result.rows[0] ?? null;
}

View file

@ -68,6 +68,32 @@ mock.module('../utils/git', () => ({
createWorktreeForIssue: mock(() => Promise.resolve('/workspace/worktrees/issue-1')),
}));
// Mock isolation provider
const mockIsolationCreate = mock(() =>
Promise.resolve({
id: '/workspace/my-repo/worktrees/task-feat-auth',
provider: 'worktree',
workingPath: '/workspace/my-repo/worktrees/task-feat-auth',
branchName: 'task-feat-auth',
status: 'active',
createdAt: new Date(),
metadata: {},
})
);
const mockIsolationDestroy = mock(() => Promise.resolve());
mock.module('../isolation', () => ({
getIsolationProvider: () => ({
providerType: 'worktree',
create: mockIsolationCreate,
destroy: mockIsolationDestroy,
get: mock(() => Promise.resolve(null)),
list: mock(() => Promise.resolve([])),
adopt: mock(() => Promise.resolve(null)),
healthCheck: mock(() => Promise.resolve(true)),
}),
}));
mock.module('child_process', () => ({
exec: mock(() => {}),
execFile: mockExecFile,
@ -101,6 +127,9 @@ function clearAllMocks(): void {
mockListWorktrees.mockClear();
mockRemoveWorktree.mockClear();
mockGetWorktreeBase.mockClear();
// Isolation mocks
mockIsolationCreate.mockClear();
mockIsolationDestroy.mockClear();
}
describe('CommandHandler', () => {
@ -271,6 +300,8 @@ describe('CommandHandler', () => {
codebase_id: null,
cwd: null,
worktree_path: null,
isolation_env_id: null,
isolation_provider: null,
created_at: new Date(),
updated_at: new Date(),
};
@ -642,9 +673,10 @@ describe('CommandHandler', () => {
expect(result.success).toBe(true);
expect(result.message).toContain('Worktree created');
expect(result.message).toContain('feat-auth');
expect(result.message).toMatch(/worktrees[\\\/]feat-auth/);
expect(result.message).toContain('task-feat-auth');
expect(result.message).toMatch(/worktrees[\\\/]task-feat-auth/);
expect(mockUpdateConversation).toHaveBeenCalled();
expect(mockIsolationCreate).toHaveBeenCalled();
});
test('should reject if already using a worktree', async () => {

View file

@ -11,6 +11,7 @@ import * as sessionDb from '../db/sessions';
import * as templateDb from '../db/command-templates';
import { isPathWithinWorkspace } from '../utils/path-validation';
import { listWorktrees, execFileAsync } from '../utils/git';
import { getIsolationProvider } from '../isolation';
/**
* Convert an absolute path to a relative path from the repository root
@ -930,7 +931,6 @@ Session:
}
const mainPath = codebase.default_cwd;
const worktreesDir = join(mainPath, 'worktrees');
switch (subcommand) {
case 'create': {
@ -939,9 +939,10 @@ Session:
return { success: false, message: 'Usage: /worktree create <branch-name>' };
}
// Check if already using a worktree
if (conversation.worktree_path) {
const shortPath = shortenPath(conversation.worktree_path, mainPath);
// Check if already using a worktree (check both old and new fields)
const existingIsolation = conversation.isolation_env_id ?? conversation.worktree_path;
if (existingIsolation) {
const shortPath = shortenPath(existingIsolation, mainPath);
return {
success: false,
message: `Already using worktree: ${shortPath}\n\nRun /worktree remove first.`,
@ -956,19 +957,16 @@ Session:
};
}
const worktreePath = join(worktreesDir, branchName);
try {
// Create worktree with new branch
await execFileAsync('git', [
'-C',
mainPath,
'worktree',
'add',
worktreePath,
'-b',
branchName,
]);
// Use isolation provider for worktree creation
const provider = getIsolationProvider();
const env = await provider.create({
codebaseId: conversation.codebase_id,
canonicalRepoPath: mainPath,
workflowType: 'task',
identifier: branchName,
description: `Manual worktree: ${branchName}`,
});
// Add to git safe.directory
await execFileAsync('git', [
@ -976,22 +974,23 @@ Session:
'--global',
'--add',
'safe.directory',
worktreePath,
env.workingPath,
]);
// Update conversation to use this worktree
await db.updateConversation(conversation.id, { worktree_path: worktreePath });
// Update conversation with isolation info (both old and new fields for compatibility)
await db.updateConversation(conversation.id, {
worktree_path: env.workingPath,
isolation_env_id: env.id,
isolation_provider: env.provider,
cwd: env.workingPath,
});
// Reset session for fresh start
const session = await sessionDb.getActiveSession(conversation.id);
if (session) {
await sessionDb.deactivateSession(session.id);
}
// NOTE: Do NOT deactivate session - preserve AI context per plan
const shortPath = shortenPath(worktreePath, mainPath);
const shortPath = shortenPath(env.workingPath, mainPath);
return {
success: true,
message: `Worktree created!\n\nBranch: ${branchName}\nPath: ${shortPath}\n\nThis conversation now works in isolation.\nRun dependency install if needed (e.g., npm install).`,
message: `Worktree created!\n\nBranch: ${env.branchName ?? branchName}\nPath: ${shortPath}\n\nThis conversation now works in isolation.\nRun dependency install if needed (e.g., bun install).`,
modified: true,
};
} catch (error) {
@ -1017,6 +1016,9 @@ Session:
const lines = stdout.trim().split('\n');
let msg = 'Worktrees:\n\n';
// Check both old and new fields for current worktree
const currentWorktree = conversation.isolation_env_id ?? conversation.worktree_path;
for (const line of lines) {
// Extract the path (first part before whitespace)
const parts = line.split(/\s+/);
@ -1027,8 +1029,7 @@ Session:
const restOfLine = parts.slice(1).join(' ');
const shortenedLine = restOfLine ? `${shortPath} ${restOfLine}` : shortPath;
const isActive =
conversation.worktree_path && line.startsWith(conversation.worktree_path);
const isActive = currentWorktree && line.startsWith(currentWorktree);
const marker = isActive ? ' <- active' : '';
msg += `${shortenedLine}${marker}\n`;
}
@ -1041,26 +1042,24 @@ Session:
}
case 'remove': {
if (!conversation.worktree_path) {
// Check both old and new fields
const isolationEnvId = conversation.isolation_env_id ?? conversation.worktree_path;
if (!isolationEnvId) {
return { success: false, message: 'This conversation is not using a worktree.' };
}
const worktreePath = conversation.worktree_path;
const forceFlag = args[1] === '--force';
try {
// Remove worktree (--force discards uncommitted changes)
const gitArgs = ['-C', mainPath, 'worktree', 'remove'];
if (forceFlag) {
gitArgs.push('--force');
}
gitArgs.push(worktreePath);
// Use isolation provider for removal
const provider = getIsolationProvider();
await provider.destroy(isolationEnvId, { force: forceFlag });
await execFileAsync('git', gitArgs);
// Clear worktree_path, keep cwd pointing to main repo
// Clear all isolation references, set cwd to main repo
await db.updateConversation(conversation.id, {
worktree_path: null,
isolation_env_id: null,
isolation_provider: null,
cwd: mainPath,
});
@ -1070,7 +1069,7 @@ Session:
await sessionDb.deactivateSession(session.id);
}
const shortPath = shortenPath(worktreePath, mainPath);
const shortPath = shortenPath(isolationEnvId, mainPath);
return {
success: true,
message: `Worktree removed: ${shortPath}\n\nSwitched back to main repo.`,
@ -1105,13 +1104,16 @@ Session:
};
}
// Check both old and new fields for current worktree
const currentWorktree = conversation.isolation_env_id ?? conversation.worktree_path;
let msg = 'All worktrees (from git):\n\n';
for (const wt of gitWorktrees) {
const isMainRepo = wt.path === mainPath;
if (isMainRepo) continue;
const shortPath = shortenPath(wt.path, mainPath);
const isCurrent = wt.path === conversation.worktree_path;
const isCurrent = wt.path === currentWorktree;
const marker = isCurrent ? ' ← current' : '';
msg += ` ${wt.branch}${shortPath}${marker}\n`;
}

29
src/isolation/index.ts Normal file
View file

@ -0,0 +1,29 @@
/**
* Isolation Provider Factory
*
* Centralized factory for isolation providers.
* Currently only supports WorktreeProvider (git worktrees).
*/
import { WorktreeProvider } from './providers/worktree';
import type { IIsolationProvider, IsolatedEnvironment, IsolationRequest } from './types';
export type { IIsolationProvider, IsolatedEnvironment, IsolationRequest };
let provider: IIsolationProvider | null = null;
/**
* Get the isolation provider instance (singleton)
* Currently only returns WorktreeProvider
*/
export function getIsolationProvider(): IIsolationProvider {
provider ??= new WorktreeProvider();
return provider;
}
/**
* Reset the isolation provider (for testing)
*/
export function resetIsolationProvider(): void {
provider = null;
}

View file

@ -0,0 +1,424 @@
import { describe, test, expect, beforeEach, afterEach, spyOn, type Mock } from 'bun:test';
import * as git from '../../utils/git';
import type { IsolationRequest } from '../types';
import { WorktreeProvider } from './worktree';
describe('WorktreeProvider', () => {
let provider: WorktreeProvider;
let execSpy: Mock<typeof git.execFileAsync>;
let mkdirSpy: Mock<typeof git.mkdirAsync>;
let worktreeExistsSpy: Mock<typeof git.worktreeExists>;
let listWorktreesSpy: Mock<typeof git.listWorktrees>;
let findWorktreeByBranchSpy: Mock<typeof git.findWorktreeByBranch>;
let getCanonicalRepoPathSpy: Mock<typeof git.getCanonicalRepoPath>;
beforeEach(() => {
provider = new WorktreeProvider();
execSpy = spyOn(git, 'execFileAsync');
mkdirSpy = spyOn(git, 'mkdirAsync');
worktreeExistsSpy = spyOn(git, 'worktreeExists');
listWorktreesSpy = spyOn(git, 'listWorktrees');
findWorktreeByBranchSpy = spyOn(git, 'findWorktreeByBranch');
getCanonicalRepoPathSpy = spyOn(git, 'getCanonicalRepoPath');
// Default mocks
execSpy.mockResolvedValue({ stdout: '', stderr: '' });
mkdirSpy.mockResolvedValue(undefined);
worktreeExistsSpy.mockResolvedValue(false);
listWorktreesSpy.mockResolvedValue([]);
findWorktreeByBranchSpy.mockResolvedValue(null);
getCanonicalRepoPathSpy.mockImplementation(async (path) => path);
});
afterEach(() => {
execSpy.mockRestore();
mkdirSpy.mockRestore();
worktreeExistsSpy.mockRestore();
listWorktreesSpy.mockRestore();
findWorktreeByBranchSpy.mockRestore();
getCanonicalRepoPathSpy.mockRestore();
});
describe('generateBranchName', () => {
test('generates issue-N for issue workflows', () => {
const request: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'issue',
identifier: '42',
};
expect(provider.generateBranchName(request)).toBe('issue-42');
});
test('generates pr-N for PR workflows', () => {
const request: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'pr',
identifier: '123',
};
expect(provider.generateBranchName(request)).toBe('pr-123');
});
test('generates review-N for review workflows', () => {
const request: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'review',
identifier: '456',
};
expect(provider.generateBranchName(request)).toBe('review-456');
});
test('generates thread-{hash} for thread workflows', () => {
const request: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'thread',
identifier: 'C123:1234567890.123456',
};
const name = provider.generateBranchName(request);
expect(name).toMatch(/^thread-[a-f0-9]{8}$/);
});
test('generates consistent hash for same identifier', () => {
const request: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'thread',
identifier: 'same-thread-id',
};
const name1 = provider.generateBranchName(request);
const name2 = provider.generateBranchName(request);
expect(name1).toBe(name2);
});
test('generates different hashes for different identifiers', () => {
const request1: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'thread',
identifier: 'thread-1',
};
const request2: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'thread',
identifier: 'thread-2',
};
expect(provider.generateBranchName(request1)).not.toBe(provider.generateBranchName(request2));
});
test('generates task-{slug} for task workflows', () => {
const request: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'task',
identifier: 'add-dark-mode',
};
expect(provider.generateBranchName(request)).toBe('task-add-dark-mode');
});
test('slugifies task identifiers properly', () => {
const request: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'task',
identifier: 'Add Dark Mode!!!',
};
expect(provider.generateBranchName(request)).toBe('task-add-dark-mode');
});
});
describe('create', () => {
const baseRequest: IsolationRequest = {
codebaseId: 'cb-123',
canonicalRepoPath: '/workspace/repo',
workflowType: 'issue',
identifier: '42',
};
test('creates worktree for issue workflow', async () => {
const env = await provider.create(baseRequest);
expect(env.provider).toBe('worktree');
expect(env.branchName).toBe('issue-42');
expect(env.workingPath).toContain('issue-42');
expect(env.status).toBe('active');
// Verify git worktree add was called with -b flag
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), '-b', 'issue-42']),
expect.any(Object)
);
});
test('creates worktree for PR with SHA (reproducible reviews)', async () => {
const request: IsolationRequest = {
...baseRequest,
workflowType: 'pr',
identifier: '42',
prBranch: 'feature/auth',
prSha: 'abc123def456',
};
await provider.create(request);
// Verify fetch with PR ref
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'fetch', 'origin', 'pull/42/head']),
expect.any(Object)
);
// Verify worktree add with SHA
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), 'abc123def456']),
expect.any(Object)
);
// Verify checkout -b for tracking branch
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', expect.any(String), 'checkout', '-b', 'pr-42-review', 'abc123def456']),
expect.any(Object)
);
});
test('creates worktree for PR without SHA (uses PR ref)', async () => {
const request: IsolationRequest = {
...baseRequest,
workflowType: 'pr',
identifier: '42',
prBranch: 'feature/auth',
};
await provider.create(request);
// Verify fetch with PR ref and local branch creation
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'fetch', 'origin', 'pull/42/head:pr-42-review']),
expect.any(Object)
);
// Verify worktree add with the local branch
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), 'pr-42-review']),
expect.any(Object)
);
});
test('adopts existing worktree if found', async () => {
worktreeExistsSpy.mockResolvedValue(true);
const env = await provider.create(baseRequest);
expect(env.metadata).toHaveProperty('adopted', true);
expect(env.workingPath).toContain('issue-42');
// Verify no git worktree add was called
const addCalls = execSpy.mock.calls.filter((call: unknown[]) => {
const args = call[1] as string[];
return args.includes('add');
});
expect(addCalls).toHaveLength(0);
});
test('adopts worktree by PR branch name (skill symbiosis)', async () => {
const request: IsolationRequest = {
...baseRequest,
workflowType: 'pr',
identifier: '42',
prBranch: 'feature/auth',
};
// First check (expected path) returns false
worktreeExistsSpy.mockResolvedValueOnce(false);
// findWorktreeByBranch finds existing worktree
findWorktreeByBranchSpy.mockResolvedValue('/workspace/worktrees/repo/feature-auth');
const env = await provider.create(request);
expect(env.workingPath).toBe('/workspace/worktrees/repo/feature-auth');
expect(env.metadata).toHaveProperty('adopted', true);
expect(env.metadata).toHaveProperty('adoptedFrom', 'branch');
// Verify no git commands for worktree creation
const addCalls = execSpy.mock.calls.filter((call: unknown[]) => {
const args = call[1] as string[];
return args.includes('add');
});
expect(addCalls).toHaveLength(0);
});
test('reuses existing branch if it already exists', async () => {
let callCount = 0;
execSpy.mockImplementation(async (_cmd: string, args: string[]) => {
callCount++;
// First worktree add call fails (branch exists)
if (callCount === 1 && args.includes('-b')) {
const error = new Error('fatal: A branch named issue-42 already exists.') as Error & { stderr?: string };
error.stderr = 'fatal: A branch named issue-42 already exists.';
throw error;
}
return { stdout: '', stderr: '' };
});
await provider.create(baseRequest);
// Verify first call attempted new branch
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), '-b', 'issue-42']),
expect.any(Object)
);
// Verify second call used existing branch
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), 'issue-42']),
expect.any(Object)
);
});
test('throws error if PR fetch fails', async () => {
const request: IsolationRequest = {
...baseRequest,
workflowType: 'pr',
identifier: '42',
prBranch: 'feature/auth',
};
execSpy.mockImplementation(async (_cmd: string, args: string[]) => {
if (args.includes('fetch')) {
throw new Error('fatal: unable to access repository');
}
return { stdout: '', stderr: '' };
});
await expect(provider.create(request)).rejects.toThrow('Failed to create worktree for PR #42');
});
});
describe('destroy', () => {
test('removes worktree', async () => {
const worktreePath = '/workspace/worktrees/repo/issue-42';
// Mock getCanonicalRepoPath to return the repo path
getCanonicalRepoPathSpy.mockResolvedValue('/workspace/repo');
await provider.destroy(worktreePath);
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'remove', worktreePath]),
expect.any(Object)
);
});
test('uses force flag when specified', async () => {
const worktreePath = '/workspace/worktrees/repo/issue-42';
// Mock getCanonicalRepoPath to return the repo path
getCanonicalRepoPathSpy.mockResolvedValue('/workspace/repo');
await provider.destroy(worktreePath, { force: true });
expect(execSpy).toHaveBeenCalledWith(
'git',
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'remove', '--force', worktreePath]),
expect.any(Object)
);
});
});
describe('get', () => {
test('returns null for non-existent environment', async () => {
worktreeExistsSpy.mockResolvedValue(false);
const result = await provider.get('/workspace/worktrees/repo/nonexistent');
expect(result).toBeNull();
});
test('returns environment for existing worktree', async () => {
worktreeExistsSpy.mockResolvedValue(true);
getCanonicalRepoPathSpy.mockResolvedValue('/workspace/repo');
listWorktreesSpy.mockResolvedValue([
{ path: '/workspace/repo', branch: 'main' },
{ path: '/workspace/worktrees/repo/issue-42', branch: 'issue-42' },
]);
const result = await provider.get('/workspace/worktrees/repo/issue-42');
expect(result).not.toBeNull();
expect(result?.provider).toBe('worktree');
expect(result?.branchName).toBe('issue-42');
});
});
describe('list', () => {
test('returns all worktrees for codebase (excluding main)', async () => {
listWorktreesSpy.mockResolvedValue([
{ path: '/workspace/repo', branch: 'main' },
{ path: '/workspace/worktrees/repo/issue-42', branch: 'issue-42' },
{ path: '/workspace/worktrees/repo/pr-123', branch: 'pr-123' },
]);
const result = await provider.list('/workspace/repo');
expect(result).toHaveLength(2);
expect(result[0].branchName).toBe('issue-42');
expect(result[1].branchName).toBe('pr-123');
});
test('returns empty array when no worktrees', async () => {
listWorktreesSpy.mockResolvedValue([{ path: '/workspace/repo', branch: 'main' }]);
const result = await provider.list('/workspace/repo');
expect(result).toHaveLength(0);
});
});
describe('healthCheck', () => {
test('returns true for existing worktree', async () => {
worktreeExistsSpy.mockResolvedValue(true);
const result = await provider.healthCheck('/workspace/worktrees/repo/issue-42');
expect(result).toBe(true);
});
test('returns false for non-existent worktree', async () => {
worktreeExistsSpy.mockResolvedValue(false);
const result = await provider.healthCheck('/workspace/worktrees/repo/nonexistent');
expect(result).toBe(false);
});
});
describe('adopt', () => {
test('adopts existing worktree', async () => {
worktreeExistsSpy.mockResolvedValue(true);
getCanonicalRepoPathSpy.mockResolvedValue('/workspace/repo');
listWorktreesSpy.mockResolvedValue([
{ path: '/workspace/repo', branch: 'main' },
{ path: '/workspace/worktrees/repo/feature-auth', branch: 'feature/auth' },
]);
const result = await provider.adopt('/workspace/worktrees/repo/feature-auth');
expect(result).not.toBeNull();
expect(result?.provider).toBe('worktree');
expect(result?.branchName).toBe('feature/auth');
expect(result?.metadata).toHaveProperty('adopted', true);
});
test('returns null for non-existent path', async () => {
worktreeExistsSpy.mockResolvedValue(false);
const result = await provider.adopt('/workspace/worktrees/repo/nonexistent');
expect(result).toBeNull();
});
});
});

View file

@ -0,0 +1,362 @@
/**
* Worktree Provider - Git worktree-based isolation
*
* Default isolation provider using git worktrees.
* Migrated from src/utils/git.ts with consistent semantics.
*/
import { createHash } from 'crypto';
import { basename, join } from 'path';
import {
execFileAsync,
findWorktreeByBranch,
getCanonicalRepoPath,
getWorktreeBase,
listWorktrees,
mkdirAsync,
worktreeExists,
} from '../../utils/git';
import type { IIsolationProvider, IsolatedEnvironment, IsolationRequest } from '../types';
export class WorktreeProvider implements IIsolationProvider {
readonly providerType = 'worktree';
/**
* Create an isolated environment using git worktrees
*/
async create(request: IsolationRequest): Promise<IsolatedEnvironment> {
const branchName = this.generateBranchName(request);
const worktreePath = this.getWorktreePath(request, branchName);
const envId = this.generateEnvId(request);
// Check for existing worktree (adoption)
const existing = await this.findExisting(request, branchName, worktreePath);
if (existing) {
return existing;
}
// Create new worktree
await this.createWorktree(request, worktreePath, branchName);
return {
id: envId,
provider: 'worktree',
workingPath: worktreePath,
branchName,
status: 'active',
createdAt: new Date(),
metadata: { request },
};
}
/**
* Destroy an isolated environment
* @param envId - The worktree path (used as environment ID)
* @param options - Options including force flag
*/
async destroy(envId: string, options?: { force?: boolean }): Promise<void> {
// For worktrees, envId is the worktree path
const worktreePath = envId;
// Get canonical repo path to run git commands
const repoPath = await getCanonicalRepoPath(worktreePath);
const gitArgs = ['-C', repoPath, 'worktree', 'remove'];
if (options?.force) {
gitArgs.push('--force');
}
gitArgs.push(worktreePath);
await execFileAsync('git', gitArgs, { timeout: 30000 });
}
/**
* Get environment by ID (worktree path)
*/
async get(envId: string): Promise<IsolatedEnvironment | null> {
const worktreePath = envId;
if (!(await worktreeExists(worktreePath))) {
return null;
}
// Get branch name from worktree
const repoPath = await getCanonicalRepoPath(worktreePath);
const worktrees = await listWorktrees(repoPath);
const wt = worktrees.find(w => w.path === worktreePath);
return {
id: envId,
provider: 'worktree',
workingPath: worktreePath,
branchName: wt?.branch,
status: 'active',
createdAt: new Date(), // Cannot determine actual creation time
metadata: {},
};
}
/**
* List all environments for a codebase
*/
async list(codebaseId: string): Promise<IsolatedEnvironment[]> {
// codebaseId is the canonical repo path for worktrees
const repoPath = codebaseId;
const worktrees = await listWorktrees(repoPath);
// Filter out main repo (first worktree is typically the main checkout)
return worktrees
.filter(wt => wt.path !== repoPath)
.map(wt => ({
id: wt.path,
provider: 'worktree' as const,
workingPath: wt.path,
branchName: wt.branch,
status: 'active' as const,
createdAt: new Date(),
metadata: {},
}));
}
/**
* Adopt an existing worktree (for skill-app symbiosis)
*/
async adopt(path: string): Promise<IsolatedEnvironment | null> {
if (!(await worktreeExists(path))) {
return null;
}
const repoPath = await getCanonicalRepoPath(path);
const worktrees = await listWorktrees(repoPath);
const wt = worktrees.find(w => w.path === path);
if (!wt) {
return null;
}
console.log(`[WorktreeProvider] Adopting existing worktree: ${path}`);
return {
id: path,
provider: 'worktree',
workingPath: path,
branchName: wt.branch,
status: 'active',
createdAt: new Date(),
metadata: { adopted: true },
};
}
/**
* Check if environment exists and is healthy
*/
async healthCheck(envId: string): Promise<boolean> {
return worktreeExists(envId);
}
/**
* Generate semantic branch name based on workflow type
*/
generateBranchName(request: IsolationRequest): string {
switch (request.workflowType) {
case 'issue':
return `issue-${request.identifier}`;
case 'pr':
return `pr-${request.identifier}`;
case 'review':
return `review-${request.identifier}`;
case 'thread':
// Use short hash for arbitrary thread IDs (Slack, Discord)
return `thread-${this.shortHash(request.identifier)}`;
case 'task':
return `task-${this.slugify(request.identifier)}`;
}
}
/**
* Generate unique environment ID
*/
generateEnvId(request: IsolationRequest): string {
const branchName = this.generateBranchName(request);
return this.getWorktreePath(request, branchName);
}
/**
* Get worktree path for request
*/
getWorktreePath(request: IsolationRequest, branchName: string): string {
const projectName = basename(request.canonicalRepoPath);
const worktreeBase = getWorktreeBase(request.canonicalRepoPath);
return join(worktreeBase, projectName, branchName);
}
/**
* Find existing worktree for adoption
*/
private async findExisting(
request: IsolationRequest,
branchName: string,
worktreePath: string
): Promise<IsolatedEnvironment | null> {
// Check if worktree already exists at expected path
if (await worktreeExists(worktreePath)) {
console.log(`[WorktreeProvider] Adopting existing worktree: ${worktreePath}`);
return {
id: worktreePath,
provider: 'worktree',
workingPath: worktreePath,
branchName,
status: 'active',
createdAt: new Date(),
metadata: { adopted: true, request },
};
}
// For PRs: also check if skill created a worktree with the PR's branch name
if (request.workflowType === 'pr' && request.prBranch) {
const existingByBranch = await findWorktreeByBranch(
request.canonicalRepoPath,
request.prBranch
);
if (existingByBranch) {
console.log(
`[WorktreeProvider] Adopting existing worktree for branch ${request.prBranch}: ${existingByBranch}`
);
return {
id: existingByBranch,
provider: 'worktree',
workingPath: existingByBranch,
branchName: request.prBranch,
status: 'active',
createdAt: new Date(),
metadata: { adopted: true, adoptedFrom: 'branch', request },
};
}
}
return null;
}
/**
* Create the actual worktree
*/
private async createWorktree(
request: IsolationRequest,
worktreePath: string,
branchName: string
): Promise<void> {
const repoPath = request.canonicalRepoPath;
const projectName = basename(repoPath);
const worktreeBase = getWorktreeBase(repoPath);
const projectWorktreeDir = join(worktreeBase, projectName);
// Ensure worktree base directory exists
await mkdirAsync(projectWorktreeDir, { recursive: true });
if (request.workflowType === 'pr' && request.prBranch) {
// For PRs: fetch and checkout the PR's head branch
await this.createFromPR(request, worktreePath);
} else {
// For issues, tasks, threads: create new branch
await this.createNewBranch(repoPath, worktreePath, branchName);
}
}
/**
* Create worktree from PR (handles both SHA and branch-based)
*/
private async createFromPR(request: IsolationRequest, worktreePath: string): Promise<void> {
const repoPath = request.canonicalRepoPath;
const prNumber = request.identifier;
try {
if (request.prSha) {
// If SHA provided, use it for reproducible reviews (hybrid approach)
// Fetch the specific commit SHA using PR refs (works for both fork and non-fork PRs)
await execFileAsync('git', ['-C', repoPath, 'fetch', 'origin', `pull/${prNumber}/head`], {
timeout: 30000,
});
// Create worktree at the specific SHA
await execFileAsync('git', ['-C', repoPath, 'worktree', 'add', worktreePath, request.prSha], {
timeout: 30000,
});
// Create a local tracking branch so it's not detached HEAD
await execFileAsync(
'git',
['-C', worktreePath, 'checkout', '-b', `pr-${prNumber}-review`, request.prSha],
{
timeout: 30000,
}
);
} else {
// Use GitHub's PR refs which work for both fork and non-fork PRs
await execFileAsync(
'git',
['-C', repoPath, 'fetch', 'origin', `pull/${prNumber}/head:pr-${prNumber}-review`],
{
timeout: 30000,
}
);
// Create worktree using the fetched PR ref
await execFileAsync(
'git',
['-C', repoPath, 'worktree', 'add', worktreePath, `pr-${prNumber}-review`],
{
timeout: 30000,
}
);
}
} catch (error) {
const err = error as Error;
throw new Error(`Failed to create worktree for PR #${prNumber}: ${err.message}`);
}
}
/**
* Create worktree with new branch
*/
private async createNewBranch(
repoPath: string,
worktreePath: string,
branchName: string
): Promise<void> {
try {
// Try to create with new branch
await execFileAsync('git', ['-C', repoPath, 'worktree', 'add', worktreePath, '-b', branchName], {
timeout: 30000,
});
} catch (error) {
const err = error as Error & { stderr?: string };
// Branch already exists - use existing branch
if (err.stderr?.includes('already exists')) {
await execFileAsync('git', ['-C', repoPath, 'worktree', 'add', worktreePath, branchName], {
timeout: 30000,
});
} else {
throw error;
}
}
}
/**
* Generate short hash for thread identifiers
*/
private shortHash(input: string): string {
const hash = createHash('sha256').update(input).digest('hex');
return hash.substring(0, 8);
}
/**
* Slugify string for branch names
*/
private slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50);
}
}

47
src/isolation/types.ts Normal file
View file

@ -0,0 +1,47 @@
/**
* Isolation Provider Abstraction Types
*
* Platform-agnostic interfaces for workflow isolation mechanisms.
* Git worktrees are the default implementation, but the abstraction
* enables future strategies (containers, VMs).
*/
/**
* Semantic context for creating isolated environments
* Platform-agnostic - describes WHAT needs isolation, not HOW
*/
export interface IsolationRequest {
codebaseId: string;
canonicalRepoPath: string; // Main repo path, never a worktree
workflowType: 'issue' | 'pr' | 'review' | 'thread' | 'task';
identifier: string; // "42", "feature-auth", thread hash, etc.
prBranch?: string; // PR-specific (for reproducible reviews)
prSha?: string;
description?: string;
}
/**
* Result of creating an isolated environment
*/
export interface IsolatedEnvironment {
id: string;
provider: 'worktree' | 'container' | 'vm' | 'remote';
workingPath: string;
branchName?: string;
status: 'active' | 'suspended' | 'destroyed';
createdAt: Date;
metadata: Record<string, unknown>;
}
/**
* Provider interface - git worktrees are DEFAULT implementation
*/
export interface IIsolationProvider {
readonly providerType: string;
create(request: IsolationRequest): Promise<IsolatedEnvironment>;
destroy(envId: string, options?: { force?: boolean }): Promise<void>;
get(envId: string): Promise<IsolatedEnvironment | null>;
list(codebaseId: string): Promise<IsolatedEnvironment[]>;
adopt?(path: string): Promise<IsolatedEnvironment | null>;
healthCheck(envId: string): Promise<boolean>;
}

View file

@ -9,6 +9,8 @@ export interface Conversation {
codebase_id: string | null;
cwd: string | null;
worktree_path: string | null;
isolation_env_id: string | null;
isolation_provider: string | null;
ai_assistant_type: string;
created_at: Date;
updated_at: Date;

View file

@ -71,6 +71,8 @@ describe('git utilities', () => {
describe('getWorktreeBase', () => {
const originalEnv = process.env.WORKTREE_BASE;
const originalWorkspacePath = process.env.WORKSPACE_PATH;
const originalHome = process.env.HOME;
afterEach(() => {
if (originalEnv === undefined) {
@ -78,24 +80,71 @@ describe('git utilities', () => {
} else {
process.env.WORKTREE_BASE = originalEnv;
}
if (originalWorkspacePath === undefined) {
delete process.env.WORKSPACE_PATH;
} else {
process.env.WORKSPACE_PATH = originalWorkspacePath;
}
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
});
test('returns sibling worktrees dir by default', () => {
test('returns ~/tmp/worktrees by default for local (non-Docker)', () => {
delete process.env.WORKTREE_BASE;
delete process.env.WORKSPACE_PATH;
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe(join('/workspace/my-repo', '..', 'worktrees'));
// Default for local: ~/tmp/worktrees (matches worktree-manager skill)
expect(result).toBe(join(homedir(), 'tmp', 'worktrees'));
});
test('uses WORKTREE_BASE env var when set', () => {
test('returns /workspace/worktrees for Docker environment', () => {
delete process.env.WORKTREE_BASE;
process.env.WORKSPACE_PATH = '/workspace';
const result = git.getWorktreeBase('/workspace/my-repo');
// Docker: inside mounted volume
expect(result).toBe('/workspace/worktrees');
});
test('detects Docker by HOME=/root + WORKSPACE_PATH', () => {
delete process.env.WORKTREE_BASE;
process.env.HOME = '/root';
process.env.WORKSPACE_PATH = '/app/workspace';
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe('/workspace/worktrees');
});
test('uses WORKTREE_BASE for local (non-Docker)', () => {
delete process.env.WORKSPACE_PATH; // Ensure not Docker
delete process.env.HOME; // Reset HOME to actual value
process.env.WORKTREE_BASE = '/custom/worktrees';
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe('/custom/worktrees');
});
test('expands tilde to home directory', () => {
test('ignores WORKTREE_BASE in Docker (end user protection)', () => {
process.env.WORKTREE_BASE = '/custom/worktrees';
process.env.WORKSPACE_PATH = '/workspace'; // Docker flag
const result = git.getWorktreeBase('/workspace/my-repo');
// Docker ALWAYS uses fixed location, override IGNORED
expect(result).toBe('/workspace/worktrees');
});
test('expands tilde in WORKTREE_BASE (local only)', () => {
delete process.env.WORKSPACE_PATH; // Ensure not Docker
process.env.WORKTREE_BASE = '~/tmp/worktrees';
const result = git.getWorktreeBase('/workspace/my-repo');
expect(result).toBe(join(homedir(), 'tmp/worktrees'));
expect(result).toBe(join(homedir(), 'tmp', 'worktrees'));
});
test('ignores WORKTREE_BASE with tilde in Docker', () => {
process.env.WORKSPACE_PATH = '/workspace'; // Docker flag
process.env.WORKTREE_BASE = '~/custom/worktrees';
const result = git.getWorktreeBase('/workspace/my-repo');
// Tilde never expanded in Docker because override is ignored entirely
expect(result).toBe('/workspace/worktrees');
});
});

View file

@ -30,21 +30,38 @@ export async function mkdirAsync(
/**
* Get the base directory for worktrees
* Uses WORKTREE_BASE env var if set, otherwise defaults to sibling of repo
* - Docker: FIXED at /workspace/worktrees (end users can't override)
* - Local: ~/tmp/worktrees by default, WORKTREE_BASE env var to override
*/
export function getWorktreeBase(repoPath: string): string {
export function getWorktreeBase(_repoPath: string): string {
// 1. Docker: FIXED location, no override for end users
const isDocker =
process.env.WORKSPACE_PATH === '/workspace' ||
(process.env.HOME === '/root' && process.env.WORKSPACE_PATH);
if (isDocker) {
return '/workspace/worktrees';
}
// 2. Local: Check WORKTREE_BASE override (for developers with custom setups)
const envBase = process.env.WORKTREE_BASE;
if (envBase) {
// Expand ~ to home directory using os.homedir() for cross-platform support
if (envBase.startsWith('~')) {
// Use join() to normalize path separators cross-platform
const pathAfterTilde = envBase.slice(1).replace(/^[/\\]/, ''); // Remove leading ~ and any separator
return join(homedir(), pathAfterTilde);
}
return envBase;
return expandTilde(envBase);
}
// Default: sibling to repo (original behavior)
return join(repoPath, '..', 'worktrees');
// 3. Local default: matches worktree-manager skill
return join(homedir(), 'tmp', 'worktrees');
}
/**
* Expand ~ to home directory
*/
function expandTilde(path: string): string {
if (path.startsWith('~')) {
const pathAfterTilde = path.slice(1).replace(/^[/\\]/, '');
return join(homedir(), pathAfterTilde);
}
return path;
}
/**