Phase 5 of v2.0. External agent dispatch via SSH to host. ACP dispatch (acp-dispatch.ts): spawns agent via SSH with JSON-RPC stdio pipe. Wraps opencode/goose in ACP mode. Captures structured events (file operations, tool calls) mapped to parts taxonomy. Falls back to PTY if ACP handshake fails. PTY dispatch (pty-dispatch.ts): raw SSH spawn for agents without ACP support (claude, pi). Captures stdout/stderr as plain text. Simpler but less structured than ACP. SSH helper (ssh.ts): shared spawn wrapper for SSH commands to samkintop@100.114.205.53 (Tailscale IP, same as booterm). Uses openssh-client installed in the runtime Dockerfile stage. Worktree management (worktrees.ts): createWorktree (git worktree add via SSH), diffWorktree (git diff HEAD...task-branch), cleanupWorktree (git worktree remove --force). One worktree per task at /tmp/booworktrees/<taskId>. Dispatcher updated: checks available_agents.supports_acp to pick transport. Path B flow: create worktree → dispatch agent → diff worktree → queue diff into pending_changes → cleanup worktree → mark task complete. Agent probe updated: probes via SSH to find host-installed agents (which opencode && opencode --version over SSH). Dockerfile: openssh-client added to runtime stage. Config: SSH_HOST env var (default 100.114.205.53). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
/**
|
|
* Git worktree management for external agent dispatch.
|
|
*
|
|
* Each dispatched task gets its own git worktree so the external agent
|
|
* can modify files freely without touching the main working tree.
|
|
* After the agent completes, we diff the worktree against HEAD and
|
|
* queue the diff into pending_changes.
|
|
*/
|
|
import { sshExec } from './ssh.js';
|
|
|
|
const WORKTREE_BASE = '/tmp/booworktrees';
|
|
|
|
/**
|
|
* Create a git worktree for a task on the host.
|
|
* Returns the absolute path to the worktree directory.
|
|
*/
|
|
export async function createWorktree(
|
|
projectPath: string,
|
|
taskId: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<string> {
|
|
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
|
|
const branchName = `task-${taskId}`;
|
|
|
|
// Ensure the base directory exists
|
|
await sshExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
|
|
|
|
// Create the worktree with a new branch from HEAD
|
|
const result = await sshExec(
|
|
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
|
|
{ signal: opts?.signal, timeoutMs: 30_000 },
|
|
);
|
|
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(`Failed to create worktree: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
}
|
|
|
|
return worktreePath;
|
|
}
|
|
|
|
/**
|
|
* Get the unified diff of changes made in the worktree vs the parent branch (HEAD).
|
|
* Returns an empty string if there are no changes.
|
|
*/
|
|
export async function diffWorktree(
|
|
worktreePath: string,
|
|
projectPath: string,
|
|
opts?: { signal?: AbortSignal },
|
|
): Promise<string> {
|
|
// First, commit any uncommitted changes in the worktree so we can diff branches
|
|
// Stage all changes
|
|
const addResult = await sshExec(
|
|
`cd ${shellEscape(worktreePath)} && git add -A`,
|
|
{ signal: opts?.signal, timeoutMs: 30_000 },
|
|
);
|
|
if (addResult.exitCode !== 0) {
|
|
throw new Error(`Failed to stage worktree changes: ${addResult.stderr.trim()}`);
|
|
}
|
|
|
|
// Check if there are staged changes
|
|
const statusResult = await sshExec(
|
|
`cd ${shellEscape(worktreePath)} && git diff --cached --quiet`,
|
|
{ signal: opts?.signal, timeoutMs: 10_000 },
|
|
);
|
|
|
|
if (statusResult.exitCode === 0) {
|
|
// No changes
|
|
return '';
|
|
}
|
|
|
|
// Commit staged changes (needed to produce a clean branch diff)
|
|
await sshExec(
|
|
`cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`,
|
|
{ signal: opts?.signal, timeoutMs: 15_000 },
|
|
);
|
|
|
|
// Diff the worktree branch against the parent commit (HEAD of main tree)
|
|
const diffResult = await sshExec(
|
|
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
|
{ signal: opts?.signal, timeoutMs: 60_000 },
|
|
);
|
|
|
|
if (diffResult.exitCode !== 0) {
|
|
throw new Error(`Failed to diff worktree: ${diffResult.stderr.trim()}`);
|
|
}
|
|
|
|
return diffResult.stdout;
|
|
}
|
|
|
|
/**
|
|
* Remove a worktree and its associated branch.
|
|
* Best-effort — does not throw on failure (task may have already been cleaned up).
|
|
*/
|
|
export async function cleanupWorktree(
|
|
projectPath: string,
|
|
taskId: string,
|
|
): Promise<void> {
|
|
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
|
|
const branchName = `task-${taskId}`;
|
|
|
|
// Remove the worktree (--force handles dirty state)
|
|
await sshExec(
|
|
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
|
|
{ timeoutMs: 15_000 },
|
|
).catch(() => {});
|
|
|
|
// Delete the task branch
|
|
await sshExec(
|
|
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`,
|
|
{ timeoutMs: 10_000 },
|
|
).catch(() => {});
|
|
}
|
|
|
|
/** Minimal shell escape for paths (single-quote wrapping). */
|
|
function shellEscape(s: string): string {
|
|
// Replace single quotes with escaped version, wrap in single quotes
|
|
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
}
|