From 49d08276500942c586664d746da7785be077cb36 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 11:13:35 -0400 Subject: [PATCH] Block phase progress without live backend --- src/index.ts | 31 ++++++++++++++++++++++++++++--- src/server.ts | 24 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index ee8bf98..2c0d6dc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -786,6 +786,7 @@ export class TempestCommand extends EventEmitter { public resume(): void { if (!this.running || !this.paused) return; this.paused = false; + this.stallReason = null; this.emit('command:resumed'); } @@ -837,6 +838,9 @@ export class TempestCommand extends EventEmitter { /** Track whether we've seeded initial tasks for the current mission */ private taskSeeded: boolean = false; + /** Human-readable reason a mission was paused because required work failed. */ + private stallReason: string | null = null; + /** * Main tick loop — seeds tasks, dispatches to operators, advances phases */ @@ -889,13 +893,28 @@ export class TempestCommand extends EventEmitter { ); const inFlight = allMissionTasks.filter(t => this.activeDispatches.has(t.id)); - // If we have tasks, all are done, and nothing is in-flight → advance phase + // If we have tasks, all are done, and nothing is in-flight → advance phase. + // Failed required tasks are terminal, but they are not successful progress. + // Stall instead of walking the phase bar forward with no backend/model work. if (allMissionTasks.length > 0 && pendingOrActive.length === 0 && inFlight.length === 0) { + const failedCurrentPhase = allMissionTasks.filter( + t => t.phase === mission.currentPhase && t.status === 'failed' + ); + if (failedCurrentPhase.length > 0) { + const firstError = failedCurrentPhase[0].result?.error; + this.stallReason = `stalled in ${mission.currentPhase}: ${failedCurrentPhase.length} required task(s) failed` + + (firstError ? ` — ${firstError}` : ''); + this.paused = true; + this.emit('command:paused'); + return; + } + const phaseIndex = mission.phases.indexOf(mission.currentPhase); if (phaseIndex === -1) return; // Guard: phase not found (race condition) if (phaseIndex < mission.phases.length - 1) { // Advance to next phase and generate tasks this.mission.advancePhase(mission.id); + this.stallReason = null; const targets = this.targetEnv.getAllTargets(); for (const target of targets) { this.mission.generateNextPhaseTasks(target.address); @@ -972,8 +991,12 @@ export class TempestCommand extends EventEmitter { // double-fire the completion hook. if (!this.activeDispatches.has(task.id)) return; this.clearDispatch(task.id); - taskQueue.complete(task.id, result); - this.hooks.onTaskCompleted?.(task); + if (result.success === false) { + taskQueue.fail(task.id, result.error || result.output || 'task returned unsuccessful result'); + } else { + taskQueue.complete(task.id, result); + this.hooks.onTaskCompleted?.(task); + } }).catch((_error) => { if (!this.activeDispatches.has(task.id)) return; this.clearDispatch(task.id); @@ -1199,6 +1222,7 @@ export class TempestCommand extends EventEmitter { vault: ReturnType; opsec: ReturnType; activeMission: string | null; + stallReason: string | null; } { const activeMission = this.mission.getActiveMission(); @@ -1212,6 +1236,7 @@ export class TempestCommand extends EventEmitter { vault: this.vault.getStats(), opsec: this.opsec.getStats(), activeMission: activeMission?.id || null, + stallReason: this.stallReason, }; } diff --git a/src/server.ts b/src/server.ts index 53fb037..c6ce004 100755 --- a/src/server.ts +++ b/src/server.ts @@ -5982,6 +5982,13 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise { res.json({ active: status.running, paused: status.paused, + stallReason: status.stallReason, name: status.name, tickCount: status.tickCount, mission: mission ? { @@ -7380,6 +7388,22 @@ async function refreshConnectedLocalAgentHealth(force = false): Promise { })); } +async function requireLiveLocalAgent(model: string | undefined): Promise<{ ok: true; id: string } | { ok: false; error: string }> { + const id = String(model || 'codex').trim(); + const entry = connectedLocalAgents.get(id); + if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` }; + + await refreshConnectedLocalAgentHealth(true); + const refreshed = connectedLocalAgents.get(id); + if (refreshed?.lastPing?.ok) return { ok: true, id }; + + return { + ok: false, + error: `local agent "${id}" is connected but not live` + + (refreshed?.lastPing?.error ? ` — ${refreshed.lastPing.error}` : ''), + }; +} + // GET /api/agents/local/detect — which agents are installed / authed / ready (no tokens spent) app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise => { try {