diff --git a/src/agent/loop.ts b/src/agent/loop.ts index d9ecfd20..1426b109 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -71,6 +71,7 @@ const logger = createLogger("loop"); const MAX_TOOL_CALLS_PER_TURN = 10; const MAX_CONSECUTIVE_ERRORS = 5; const MAX_REPETITIVE_TURNS = 3; +let localWorkerRecoveryDone = false; export interface AgentLoopOptions { identity: AutomatonIdentity; @@ -350,6 +351,25 @@ export async function runAgentLoop( } } + // One-time per process: reset tasks assigned to local workers from a previous process. + // Local workers are in-process async tasks that die with the process, + // so any 'assigned' tasks with local:// addresses are stale after restart. + if (!localWorkerRecoveryDone && hasTable(db.raw, "task_graph")) { + localWorkerRecoveryDone = true; + const staleLocalTasks = db.raw.prepare( + "SELECT id, assigned_to FROM task_graph WHERE status = 'assigned' AND assigned_to LIKE 'local://%'", + ).all() as { id: string; assigned_to: string }[]; + for (const task of staleLocalTasks) { + logger.info("Resetting stale local worker task from previous process", { + taskId: task.id, + worker: task.assigned_to, + }); + db.raw.prepare( + "UPDATE task_graph SET status = 'pending', assigned_to = NULL, started_at = NULL WHERE id = ?", + ).run(task.id); + } + } + // Set start time if (!db.getKV("start_time")) { db.setKV("start_time", new Date().toISOString()); diff --git a/src/orchestration/orchestrator.ts b/src/orchestration/orchestrator.ts index eae6cc56..cc5913ed 100644 --- a/src/orchestration/orchestrator.ts +++ b/src/orchestration/orchestrator.ts @@ -522,6 +522,12 @@ export class Orchestrator { const assignedTasks = getTasksByGoal(this.params.db, goal.id) .filter((t) => t.status === "assigned" && t.assignedTo); for (const task of assignedTasks) { + // Local workers are in-process async tasks — they either complete + // (calling completeTask/failTask) or die with the process. + // Per-tick recovery only applies to remote sandbox workers. + if (task.assignedTo?.startsWith("local://")) { + continue; + } const alive = this.params.isWorkerAlive(task.assignedTo!); if (!alive) { logger.warn("Recovering stale task from dead worker", { @@ -964,13 +970,26 @@ export class Orchestrator { } const phase = asPhase(parsed.phase); - return { + const state: OrchestratorState = { phase: phase ?? DEFAULT_STATE.phase, goalId: typeof parsed.goalId === "string" ? parsed.goalId : null, replanCount: typeof parsed.replanCount === "number" ? Math.max(0, Math.floor(parsed.replanCount)) : 0, failedTaskId: typeof parsed.failedTaskId === "string" ? parsed.failedTaskId : null, failedError: typeof parsed.failedError === "string" ? parsed.failedError : null, }; + + // Validate goalId still points to a goal the orchestrator should track. + // Reset only for user-initiated terminal states (completed, cancelled) + // or missing goals. "failed" goals are kept because the orchestrator's + // own replan/failure flow puts goals into "failed" status. + if (state.goalId) { + const goal = getGoalById(this.params.db, state.goalId); + if (!goal || (["completed", "cancelled"].includes(goal.status) && state.phase !== "complete")) { + return { ...DEFAULT_STATE }; + } + } + + return state; } private saveState(state: OrchestratorState): void {