Skip to content

Commit b33ea9c

Browse files
committed
feat: add relaunchJobForCorrection to Orchestrator
New method relaunches a failed job in its existing worktree with a correction prompt containing the original task, touchSet violations, and allowed patterns. Reuses branch/worktree, kills stale tmux session, creates fresh tmux + job entry, and lets the agent fix violations before touchSet re-validates on completion.
1 parent fb5a53f commit b33ea9c

1 file changed

Lines changed: 184 additions & 0 deletions

File tree

src/lib/orchestrator.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,190 @@ If your work needs human review before it can proceed: mc_report(status: "needs_
881881
}
882882
}
883883

884+
async relaunchJobForCorrection(
885+
jobName: string,
886+
violations: string[],
887+
touchSetPatterns: string[],
888+
): Promise<void> {
889+
const plan = await loadPlan();
890+
if (!plan) {
891+
throw new Error('No active plan');
892+
}
893+
894+
const job = plan.jobs.find((j) => j.name === jobName);
895+
if (!job) {
896+
throw new Error(`Job "${jobName}" not found in plan`);
897+
}
898+
if (!job.worktreePath || !job.branch) {
899+
throw new Error(`Job "${jobName}" has no worktree or branch — cannot relaunch`);
900+
}
901+
902+
const placement = this.planPlacement ?? this.config.defaultPlacement ?? 'session';
903+
const mode = job.mode ?? this.config.omo?.defaultMode ?? 'vanilla';
904+
const sanitizedName = job.name.replace(/[^a-zA-Z0-9_-]/g, '-');
905+
const tmuxSessionName = `mc-${sanitizedName}`;
906+
const tmuxTarget =
907+
placement === 'session'
908+
? tmuxSessionName
909+
: (() => {
910+
const currentSession = getCurrentSession();
911+
if (!currentSession && !isInsideTmux()) {
912+
throw new Error('Window placement requires running inside tmux');
913+
}
914+
return `${currentSession}:${sanitizedName}`;
915+
})();
916+
917+
// Kill old tmux session if still alive
918+
try {
919+
if (placement === 'session') {
920+
await killSession(tmuxSessionName);
921+
} else {
922+
const [session, window] = tmuxTarget.split(':');
923+
if (session && window) {
924+
await killWindow(session, window);
925+
}
926+
}
927+
} catch {
928+
// Session may already be dead — that's fine
929+
}
930+
931+
// Build correction prompt with context from the original task
932+
const correctionPrompt =
933+
`CORRECTION TASK — TouchSet Violation\n\n` +
934+
`The previous task in this worktree was:\n"${job.prompt}"\n\n` +
935+
`It completed successfully but modified files outside the allowed scope.\n\n` +
936+
`Violations (files you must revert):\n${violations.map((v) => ` - ${v}`).join('\n')}\n\n` +
937+
`Allowed patterns (files you may keep):\n${touchSetPatterns.map((p) => ` - ${p}`).join('\n')}\n\n` +
938+
`Instructions:\n` +
939+
`1. Review the changes on this branch (git log, git diff)\n` +
940+
`2. Revert ONLY the violating files listed above — do NOT break the intended work\n` +
941+
`3. If a violating file is genuinely required, explain why in your commit message\n` +
942+
`4. Commit your corrections and report completion`;
943+
944+
const mcReportSuffix = `\n\nCRITICAL — STATUS REPORTING REQUIRED:
945+
You MUST call the mc_report tool at these points — this is NOT optional:
946+
947+
1. IMMEDIATELY when you start: mc_report(status: "working", message: "Starting: <brief description>")
948+
2. At each major milestone: mc_report(status: "progress", message: "<what you accomplished>", progress: <0-100>)
949+
3. If you get stuck or need input: mc_report(status: "blocked", message: "<what's blocking you>")
950+
4. WHEN YOU ARE COMPLETELY DONE: mc_report(status: "completed", message: "<summary of what was done>")
951+
952+
The "completed" call is MANDATORY — it signals Mission Control that your job is finished. Without it, your job will appear stuck as "running" and block the pipeline. Always call mc_report(status: "completed", ...) as your FINAL action.
953+
954+
If your work needs human review before it can proceed: mc_report(status: "needs_review", message: "<what needs review>")`;
955+
const autoCommitSuffix = (this.config.autoCommit !== false)
956+
? `\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.`
957+
: '';
958+
959+
let fullPrompt = correctionPrompt + mcReportSuffix + autoCommitSuffix;
960+
if (mode === 'ralph') {
961+
fullPrompt = `/ralph-loop ${fullPrompt}`;
962+
} else if (mode === 'ulw') {
963+
fullPrompt = `/ulw-loop ${fullPrompt}`;
964+
}
965+
966+
const worktreePath = job.worktreePath;
967+
let promptFilePath: string | undefined;
968+
969+
try {
970+
promptFilePath = await writePromptFile(worktreePath, fullPrompt);
971+
const model = this.planModelSnapshot ?? getCurrentModel();
972+
const launcherPath = await writeLauncherScript(worktreePath, promptFilePath, model);
973+
974+
const initialCommand = `bash '${launcherPath}'`;
975+
if (placement === 'session') {
976+
await createSession({
977+
name: tmuxSessionName,
978+
workdir: worktreePath,
979+
command: initialCommand,
980+
});
981+
} else {
982+
await createWindow({
983+
session: getCurrentSession()!,
984+
name: sanitizedName,
985+
workdir: worktreePath,
986+
command: initialCommand,
987+
});
988+
}
989+
990+
await setPaneDiedHook(tmuxTarget, `run-shell "echo '${job.id}' >> .mission-control/completed-jobs.log"`);
991+
cleanupPromptFile(promptFilePath);
992+
cleanupLauncherScript(worktreePath);
993+
994+
if (mode !== 'vanilla') {
995+
try {
996+
await new Promise((resolve) => setTimeout(resolve, 2000));
997+
switch (mode) {
998+
case 'plan':
999+
await sendKeys(tmuxTarget, '/start-work');
1000+
await sendKeys(tmuxTarget, 'Enter');
1001+
break;
1002+
case 'ralph':
1003+
await sendKeys(tmuxTarget, '/ralph-loop');
1004+
await sendKeys(tmuxTarget, 'Enter');
1005+
break;
1006+
case 'ulw':
1007+
await sendKeys(tmuxTarget, '/ulw-loop');
1008+
await sendKeys(tmuxTarget, 'Enter');
1009+
break;
1010+
}
1011+
} catch {
1012+
// Non-fatal: OMO command delivery is best-effort
1013+
}
1014+
}
1015+
1016+
// Clean up stale job entries and create fresh one for the relaunch
1017+
const existingState = await loadJobState();
1018+
const staleJobs = existingState.jobs.filter(
1019+
(j) => j.name === job.name && j.status !== 'running',
1020+
);
1021+
for (const stale of staleJobs) {
1022+
await removeReport(stale.id).catch(() => {});
1023+
await removeJob(stale.id).catch(() => {});
1024+
}
1025+
1026+
const jobId = randomUUID();
1027+
await addJob({
1028+
id: jobId,
1029+
name: job.name,
1030+
worktreePath,
1031+
branch: job.branch,
1032+
tmuxTarget,
1033+
placement,
1034+
status: 'running',
1035+
prompt: correctionPrompt,
1036+
mode,
1037+
createdAt: new Date().toISOString(),
1038+
planId: plan.id,
1039+
});
1040+
1041+
await updatePlanJob(plan.id, job.name, {
1042+
status: 'running',
1043+
tmuxTarget,
1044+
error: undefined,
1045+
});
1046+
} catch (error) {
1047+
if (promptFilePath) {
1048+
cleanupPromptFile(promptFilePath, 0);
1049+
}
1050+
1051+
try {
1052+
if (placement === 'session') {
1053+
await killSession(tmuxSessionName);
1054+
} else {
1055+
const [session, window] = tmuxTarget.split(':');
1056+
if (session && window) {
1057+
await killWindow(session, window);
1058+
}
1059+
}
1060+
} catch {}
1061+
1062+
throw new Error(
1063+
`Failed to relaunch job "${jobName}" for correction: ${error instanceof Error ? error.message : String(error)}`,
1064+
);
1065+
}
1066+
}
1067+
8841068
private handleJobComplete = (job: Job): void => {
8851069
if (!job.planId || !this.activePlanId || job.planId !== this.activePlanId) {
8861070
return;

0 commit comments

Comments
 (0)