Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/lib/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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}`);
Expand Down Expand Up @@ -161,4 +162,3 @@ export async function refreshIntegrationFromMain(
return { success: true };
}


11 changes: 10 additions & 1 deletion src/lib/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
127 changes: 105 additions & 22 deletions src/lib/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ 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,
getCurrentSession,
isInsideTmux,
isPaneRunning,
isTmuxHealthy,
killSession,
killWindow,
sendKeys,
setPaneDiedHook,
} from './tmux';

Expand Down Expand Up @@ -158,6 +162,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;
Expand Down Expand Up @@ -327,7 +332,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;

Expand Down Expand Up @@ -400,11 +407,22 @@ export class Orchestrator {

private async reconcile(): Promise<void> {
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<void> {
const plan = await loadPlan();

if (!plan || isTerminalPlanStatus(plan.status)) {
Expand Down Expand Up @@ -649,9 +667,6 @@ export class Orchestrator {
}
await savePlan(latestPlan);
}
} finally {
this.isReconciling = false;
}
}

private async launchJob(job: JobSpec): Promise<void> {
Expand All @@ -661,7 +676,9 @@ 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 mode = job.mode ?? this.config.omo?.defaultMode ?? 'vanilla';
const sanitizedName = job.name.replace(/[^a-zA-Z0-9_-]/g, '-');
const tmuxSessionName = `mc-${sanitizedName}`;
const tmuxTarget =
Expand All @@ -688,7 +705,34 @@ 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 });

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:
Expand All @@ -704,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);
Expand All @@ -729,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',
Expand All @@ -748,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,
});
Expand Down Expand Up @@ -885,18 +956,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,
);
}
}
}

Expand Down Expand Up @@ -925,7 +1008,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' ? '❌' : '⏳';
Expand Down Expand Up @@ -977,7 +1060,7 @@ If your work needs human review before it can proceed: mc_report(status: "needs_
'--head',
plan.integrationBranch,
'--base',
defaultBranch,
baseBranch,
'--title',
title,
'--body',
Expand Down
2 changes: 2 additions & 0 deletions src/lib/plan-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface PlanSpec {
name: string;
mode: 'autopilot' | 'copilot' | 'supervisor';
placement?: 'session' | 'window';
baseBranch?: string;
status: PlanStatus;
jobs: JobSpec[];
integrationBranch: string;
Expand Down Expand Up @@ -57,6 +58,7 @@ export interface JobSpec {
copyFiles?: string[];
symlinkDirs?: string[];
commands?: string[];
mode?: 'vanilla' | 'plan' | 'ralph' | 'ulw';
}

export const VALID_PLAN_TRANSITIONS: Record<PlanStatus, PlanStatus[]> = {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/providers/worktree-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface WorktreeProvider {
create(opts: {
branch: string;
basePath?: string;
startPoint?: string;
postCreate?: PostCreateHook;
}): Promise<string>;

Expand All @@ -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<SyncResult>;
sync(path: string, strategy: 'rebase' | 'merge', baseBranch?: string, source?: 'local' | 'origin'): Promise<SyncResult>;
}
3 changes: 3 additions & 0 deletions src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
Expand Down Expand Up @@ -68,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({
Expand Down Expand Up @@ -109,6 +111,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,
});

Expand Down
Loading