Block phase progress without live backend

This commit is contained in:
Codex committed 2026-07-07 16:04:40 -04:00
1 parent f502bfbd42
commit 49d0827650
2 files changed
+52 -3

No files matched your search

+28 -3
View File
@@ -786,6 +786,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
public resume(): void { public resume(): void {
if (!this.running || !this.paused) return; if (!this.running || !this.paused) return;
this.paused = false; this.paused = false;
this.stallReason = null;
this.emit('command:resumed'); this.emit('command:resumed');
} }
@@ -837,6 +838,9 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
/** Track whether we've seeded initial tasks for the current mission */ /** Track whether we've seeded initial tasks for the current mission */
private taskSeeded: boolean = false; 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 * Main tick loop — seeds tasks, dispatches to operators, advances phases
*/ */
@@ -889,13 +893,28 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
); );
const inFlight = allMissionTasks.filter(t => this.activeDispatches.has(t.id)); 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) { 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); const phaseIndex = mission.phases.indexOf(mission.currentPhase);
if (phaseIndex === -1) return; // Guard: phase not found (race condition) if (phaseIndex === -1) return; // Guard: phase not found (race condition)
if (phaseIndex < mission.phases.length - 1) { if (phaseIndex < mission.phases.length - 1) {
// Advance to next phase and generate tasks // Advance to next phase and generate tasks
this.mission.advancePhase(mission.id); this.mission.advancePhase(mission.id);
this.stallReason = null;
const targets = this.targetEnv.getAllTargets(); const targets = this.targetEnv.getAllTargets();
for (const target of targets) { for (const target of targets) {
this.mission.generateNextPhaseTasks(target.address); this.mission.generateNextPhaseTasks(target.address);
@@ -972,8 +991,12 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
// double-fire the completion hook. // double-fire the completion hook.
if (!this.activeDispatches.has(task.id)) return; if (!this.activeDispatches.has(task.id)) return;
this.clearDispatch(task.id); this.clearDispatch(task.id);
taskQueue.complete(task.id, result); if (result.success === false) {
this.hooks.onTaskCompleted?.(task); taskQueue.fail(task.id, result.error || result.output || 'task returned unsuccessful result');
} else {
taskQueue.complete(task.id, result);
this.hooks.onTaskCompleted?.(task);
}
}).catch((_error) => { }).catch((_error) => {
if (!this.activeDispatches.has(task.id)) return; if (!this.activeDispatches.has(task.id)) return;
this.clearDispatch(task.id); this.clearDispatch(task.id);
@@ -1199,6 +1222,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
vault: ReturnType<EvidenceVault['getStats']>; vault: ReturnType<EvidenceVault['getStats']>;
opsec: ReturnType<OpsecController['getStats']>; opsec: ReturnType<OpsecController['getStats']>;
activeMission: string | null; activeMission: string | null;
stallReason: string | null;
} { } {
const activeMission = this.mission.getActiveMission(); const activeMission = this.mission.getActiveMission();
@@ -1212,6 +1236,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
vault: this.vault.getStats(), vault: this.vault.getStats(),
opsec: this.opsec.getStats(), opsec: this.opsec.getStats(),
activeMission: activeMission?.id || null, activeMission: activeMission?.id || null,
stallReason: this.stallReason,
}; };
} }
+24
View File
@@ -5982,6 +5982,13 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' }); res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' });
return; return;
} }
if (provider === 'local-agent') {
const localAgent = await requireLiveLocalAgent(model);
if (!localAgent.ok) {
res.status(503).json({ error: localAgent.error });
return;
}
}
if (targets.length === 0) { if (targets.length === 0) {
res.status(400).json({ error: 'At least one target required' }); res.status(400).json({ error: 'At least one target required' });
@@ -6217,6 +6224,7 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
res.json({ res.json({
active: status.running, active: status.running,
paused: status.paused, paused: status.paused,
stallReason: status.stallReason,
name: status.name, name: status.name,
tickCount: status.tickCount, tickCount: status.tickCount,
mission: mission ? { mission: mission ? {
@@ -7380,6 +7388,22 @@ async function refreshConnectedLocalAgentHealth(force = false): Promise<void> {
})); }));
} }
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) // 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<void> => { app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise<void> => {
try { try {