import { spawn, spawnSync } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { loadMission, saveMission } from './mission.js'; import { parseTasksFile, updateTaskStatus } from './tasks-file.js'; import type { Mission, MissionMilestone, MissionSession, RunTaskOptions, TaskRun, } from './types.js'; const SESSION_LOCK_FILE = 'session.lock'; const NEXT_TASK_FILE = 'next-task.json'; interface SessionLockState { session_id: string; runtime: 'claude' | 'codex'; pid: number; started_at: string; project_path: string; milestone_id?: string; } function orchestratorDirPath(mission: Mission): string { if (path.isAbsolute(mission.orchestratorDir)) { return mission.orchestratorDir; } return path.join(mission.projectPath, mission.orchestratorDir); } function sessionLockPath(mission: Mission): string { return path.join(orchestratorDirPath(mission), SESSION_LOCK_FILE); } function nextTaskCapsulePath(mission: Mission): string { return path.join(orchestratorDirPath(mission), NEXT_TASK_FILE); } function tasksFilePath(mission: Mission): string { if (path.isAbsolute(mission.tasksFile)) { return mission.tasksFile; } return path.join(mission.projectPath, mission.tasksFile); } function buildSessionId(mission: Mission): string { return `sess-${String(mission.sessions.length + 1).padStart(3, '0')}`; } function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } function currentMilestone(mission: Mission): MissionMilestone | undefined { return ( mission.milestones.find((milestone) => milestone.status === 'in-progress') ?? mission.milestones.find((milestone) => milestone.status === 'pending') ); } async function readTasks(mission: Mission) { const filePath = tasksFilePath(mission); try { const content = await fs.readFile(filePath, 'utf8'); return parseTasksFile(content); } catch (error) { if ( typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'ENOENT' ) { return []; } throw error; } } function currentBranch(projectPath: string): string | undefined { const result = spawnSync('git', ['-C', projectPath, 'branch', '--show-current'], { encoding: 'utf8', }); if (result.status !== 0) { return undefined; } const branch = result.stdout.trim(); return branch.length > 0 ? branch : undefined; } function percentage(done: number, total: number): number { if (total === 0) return 0; return Math.floor((done / total) * 100); } function formatDurationSeconds(totalSeconds: number): string { if (totalSeconds < 60) return `${totalSeconds}s`; if (totalSeconds < 3600) { const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}m ${seconds}s`; } const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); return `${hours}h ${minutes}m`; } function buildContinuationPrompt(params: { mission: Mission; taskId: string; runtime: 'claude' | 'codex'; tasksDone: number; tasksTotal: number; currentMilestone?: MissionMilestone; previousSession?: MissionSession; branch?: string; }): string { const { mission, taskId, runtime, tasksDone, tasksTotal, currentMilestone: milestone, previousSession, branch, } = params; const pct = percentage(tasksDone, tasksTotal); const previousDuration = previousSession?.durationSeconds !== undefined ? formatDurationSeconds(previousSession.durationSeconds) : '—'; return [ '## Continuation Mission', '', `Continue **${mission.name}** from existing state.`, '', '## Setup', '', `- **Project:** ${mission.projectPath}`, `- **State:** ${mission.tasksFile} (${tasksDone}/${tasksTotal} tasks complete)`, `- **Manifest:** ${mission.manifestFile}`, `- **Scratchpad:** ${mission.scratchpadFile}`, '- **Protocol:** ~/.config/mosaic/guides/ORCHESTRATOR.md', `- **Quality gates:** ${mission.qualityGates ?? '—'}`, `- **Target runtime:** ${runtime}`, '', '## Resume Point', '', `- **Current milestone:** ${milestone?.name ?? '—'} (${milestone?.id ?? '—'})`, `- **Next task:** ${taskId}`, `- **Progress:** ${tasksDone}/${tasksTotal} (${pct}%)`, `- **Branch:** ${branch ?? '—'}`, '', '## Previous Session Context', '', `- **Session:** ${previousSession?.sessionId ?? '—'} (${previousSession?.runtime ?? '—'}, ${previousDuration})`, `- **Ended:** ${previousSession?.endedReason ?? '—'}`, `- **Last completed task:** ${previousSession?.lastTaskId ?? '—'}`, '', '## Instructions', '', '1. Read `~/.config/mosaic/guides/ORCHESTRATOR.md` for full protocol', `2. Read \`${mission.manifestFile}\` for mission scope and status`, `3. Read \`${mission.scratchpadFile}\` for session history and decisions`, `4. Read \`${mission.tasksFile}\` for current task state`, '5. `git pull --rebase` to sync latest changes', `6. Launch runtime with \`${runtime} -p\``, `7. Continue execution from task **${taskId}**`, '8. Follow Two-Phase Completion Protocol', `9. You are the SOLE writer of \`${mission.tasksFile}\``, ].join('\n'); } function resolveLaunchCommand( runtime: 'claude' | 'codex', prompt: string, configuredCommand: string[] | undefined, ): string[] { if (configuredCommand === undefined || configuredCommand.length === 0) { return [runtime, '-p', prompt]; } const hasPromptPlaceholder = configuredCommand.some((value) => value === '{prompt}'); const withInterpolation = configuredCommand.map((value) => value === '{prompt}' ? prompt : value, ); if (hasPromptPlaceholder) { return withInterpolation; } return [...withInterpolation, prompt]; } async function writeAtomicJson(filePath: string, payload: unknown): Promise { const directory = path.dirname(filePath); await fs.mkdir(directory, { recursive: true }); const tempPath = path.join( directory, `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random() .toString(16) .slice(2)}`, ); await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); await fs.rename(tempPath, filePath); } async function readSessionLock(mission: Mission): Promise { const filePath = sessionLockPath(mission); let raw: string; try { raw = await fs.readFile(filePath, 'utf8'); } catch (error) { if ( typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'ENOENT' ) { return undefined; } throw error; } let data: Partial; try { data = JSON.parse(raw) as Partial; } catch { return undefined; } if ( typeof data.session_id !== 'string' || (data.runtime !== 'claude' && data.runtime !== 'codex') || typeof data.pid !== 'number' || typeof data.started_at !== 'string' || typeof data.project_path !== 'string' ) { return undefined; } return { session_id: data.session_id, runtime: data.runtime, pid: data.pid, started_at: data.started_at, project_path: data.project_path, milestone_id: data.milestone_id, }; } async function writeSessionLock(mission: Mission, lock: SessionLockState): Promise { await writeAtomicJson(sessionLockPath(mission), lock); } function markSessionCrashed(mission: Mission, sessionId: string, endedAt: string): Mission { const sessions = mission.sessions.map((session) => { if (session.sessionId !== sessionId) return session; if (session.endedAt !== undefined) return session; const startedEpoch = Date.parse(session.startedAt); const endedEpoch = Date.parse(endedAt); const durationSeconds = Number.isFinite(startedEpoch) && Number.isFinite(endedEpoch) ? Math.max(0, Math.floor((endedEpoch - startedEpoch) / 1000)) : undefined; return { ...session, endedAt, endedReason: 'crashed' as const, durationSeconds, }; }); return { ...mission, sessions }; } export async function runTask( mission: Mission, taskId: string, options: RunTaskOptions = {}, ): Promise { const runtime = options.runtime ?? 'claude'; const mode = options.mode ?? 'interactive'; const freshMission = await loadMission(mission.projectPath); const tasks = await readTasks(freshMission); const matches = tasks.filter((task) => task.id === taskId); if (matches.length === 0) { throw new Error(`Task not found: ${taskId}`); } if (matches.length > 1) { throw new Error(`Duplicate task IDs found: ${taskId}`); } const task = matches[0]!; if (task.status === 'done' || task.status === 'cancelled') { throw new Error(`Task ${taskId} cannot be run from status ${task.status}`); } const tasksTotal = tasks.length; const tasksDone = tasks.filter((candidate) => candidate.status === 'done').length; const selectedMilestone = freshMission.milestones.find((milestone) => milestone.id === options.milestoneId) ?? freshMission.milestones.find((milestone) => milestone.id === task.milestone) ?? currentMilestone(freshMission); const continuationPrompt = buildContinuationPrompt({ mission: freshMission, taskId, runtime, tasksDone, tasksTotal, currentMilestone: selectedMilestone, previousSession: freshMission.sessions.at(-1), branch: currentBranch(freshMission.projectPath), }); const launchCommand = resolveLaunchCommand(runtime, continuationPrompt, options.command); const startedAt = new Date().toISOString(); const sessionId = buildSessionId(freshMission); const lockFile = sessionLockPath(freshMission); await writeAtomicJson(nextTaskCapsulePath(freshMission), { generated_at: startedAt, runtime, mission_id: freshMission.id, mission_name: freshMission.name, project_path: freshMission.projectPath, quality_gates: freshMission.qualityGates ?? '', current_milestone: { id: selectedMilestone?.id ?? '', name: selectedMilestone?.name ?? '', }, next_task: taskId, progress: { tasks_done: tasksDone, tasks_total: tasksTotal, pct: percentage(tasksDone, tasksTotal), }, current_branch: currentBranch(freshMission.projectPath) ?? '', }); if (mode === 'print-only') { return { missionId: freshMission.id, taskId, sessionId, runtime, launchCommand, startedAt, lockFile, }; } await updateTaskStatus(freshMission, taskId, 'in-progress'); await writeSessionLock(freshMission, { session_id: sessionId, runtime, pid: 0, started_at: startedAt, project_path: freshMission.projectPath, milestone_id: selectedMilestone?.id, }); const child = spawn(launchCommand[0]!, launchCommand.slice(1), { cwd: freshMission.projectPath, env: { ...process.env, ...(options.env ?? {}), }, stdio: 'inherit', }); await new Promise((resolve, reject) => { child.once('spawn', () => { resolve(); }); child.once('error', (error) => { reject(error); }); }); const pid = child.pid; if (pid === undefined) { throw new Error('Failed to start task runtime process (pid missing)'); } await writeSessionLock(freshMission, { session_id: sessionId, runtime, pid, started_at: startedAt, project_path: freshMission.projectPath, milestone_id: selectedMilestone?.id, }); const updatedMission: Mission = { ...freshMission, status: 'active', sessions: [ ...freshMission.sessions, { sessionId, runtime, pid, startedAt, milestoneId: selectedMilestone?.id, lastTaskId: taskId, }, ], }; await saveMission(updatedMission); return { missionId: updatedMission.id, taskId, sessionId, runtime, launchCommand, startedAt, pid, lockFile, }; } export async function resumeTask( mission: Mission, taskId: string, options: Omit = {}, ): Promise { const freshMission = await loadMission(mission.projectPath); const lock = await readSessionLock(freshMission); if (lock !== undefined && lock.pid > 0 && isPidAlive(lock.pid)) { throw new Error(`Session ${lock.session_id} is still running (PID ${lock.pid}).`); } let nextMissionState = freshMission; if (lock !== undefined) { const endedAt = new Date().toISOString(); nextMissionState = markSessionCrashed(freshMission, lock.session_id, endedAt); await saveMission(nextMissionState); await fs.rm(sessionLockPath(nextMissionState), { force: true }); } return runTask(nextMissionState, taskId, options); }