/** * taskStore.ts — Zustand store per task execution state * * Traccia task agent con: id, status, steps, logs, artifacts, rollback. * Persistente su Dexie/IndexedDB (FASE 2.1 — sopravvive a reload della tab). * * Checklist 7.1 — unified state architecture (taskStore) * Checklist 4.2 — structured task execution * Checklist 2.1 — Task state persistent su Dexie ✅ * FASE 3.3 — Queue scheduler: QUEUED→RUNNING via DOM event; retry con backoff ✅ * Replit-like action tracking: TaskAction[] ✅ * * S36 — Task Persistence Layer: * + TaskStep type: per-step status con label/status/output * + steps: TaskStep[] su Task (persiste su Dexie come steps: unknown[]) * + addStep / completeStep / failStep actions * + Hydration intelligente: task < 30min → RESUME candidato; > 30min → CANCELLED * + subscribeToEventRuntime(): bridge EventRuntime → taskStore (opzionale) * + DI: _setTestTasksDb / _resetTasksDb per test senza Dexie * * RC-FIX — Race Condition fixes (Sprint S-RC-STORES): * RC-1: updateStatus — setTimeout fuori dal set() callback (StrictMode double-invoke) * RC-2: cancelTask — setTimeout fuori dal set() callback (StrictMode double-invoke) * RC-3: retryTask — guard atomica dentro set() + setTimeout fuori (stale-read race) * RC-4: subscribeToEventRuntime — flag _aborted per cleanup pre-import (memory leak) */ import { create } from "zustand"; import { vfsDb, tasksDb, type StoredTask } from "@/lib/vfsDb"; // ─── Task status ────────────────────────────────────────────────────────────── export type TaskStatus = | "IDLE" | "QUEUED" | "RUNNING" | "SUCCESS" | "ERROR" | "TIMEOUT" | "CANCELLED"; // ─── AgentRuntime Phase (S744) ─────────────────────────────────────────────── // Macchina a stati sub-RUNNING dell'agente per run attiva. // Granularità più fine di TaskStatus.RUNNING — espone la fase corrente al frontend. // IDLE — nessun task attivo (default / stato iniziale) // PLANNING — classificazione task + pianificazione subtask (planTask) // EXECUTING — main for-loop in corso (tool calls, LLM iterations) // VERIFYING — candidato risposta trovato, goal verifier in esecuzione // DONE — risposta emessa, loop terminato con successo // ERROR — loop terminato per errore / timeout / abort export type AgentRuntimePhase = 'IDLE' | 'PLANNING' | 'EXECUTING' | 'VERIFYING' | 'DONE' | 'ERROR'; // ─── Task step (S36) ───────────────────────────────────────────────────────── export type TaskStepStatus = "pending" | "running" | "done" | "failed"; export interface TaskStep { id: string; label: string; // descrizione step (e.g. "Write tests for auth.ts") status: TaskStepStatus; startedAt: number; // ms timestamp finishedAt?: number; output?: string; // snippet output opzionale (≤ 500 chars) error?: string; // messaggio errore se failed } // ─── Task log entry ─────────────────────────────────────────────────────────── export interface TaskLog { at: number; level: "info" | "warn" | "error"; message: string; } // ─── Task artifact ──────────────────────────────────────────────────────────── export interface TaskArtifact { id: string; type: "file" | "code" | "data" | "image"; name: string; content: string; mimeType?: string; } // ─── Phase breakdown (Sprint 5 ITEM 14) ────────────────────────────────────── export interface PhaseBreakdown { classify_ms: number; plan_ms: number; coder_ms: number; verifier_ms: number; browser_ms: number; } // ─── Task action (Replit-style action card) ──────────────────────────────────── export type TaskActionType = | "file_open" | "file_write" | "file_read" | "file_delete" | "search_web" | "code_run" | "tool_call" | "api_call" | "thinking" | "plan" | "memory"; export interface TaskAction { id: string; type: TaskActionType; label: string; // e.g. "Opened api.ts" detail?: string; // e.g. file path or URL at: number; // ms timestamp durationMs?: number; // se completato done: boolean; // S396-NARR: narrative fields dal backend on_step explanation?: string; // frase narrativa (e.g. "Recupero dati meteo per Milano…") toolName?: string; // nome backend originale (e.g. "run_python", "get_weather") } // ─── Task ──────────────────────────────────────────────────────────────────── export interface Task { id: string; status: TaskStatus; goal: string; plan?: string[]; steps: TaskStep[]; // S36: per-step tracking currentStep: number; logs: TaskLog[]; actions: TaskAction[]; // action card stile Replit artifacts: TaskArtifact[]; startedAt: number; finishedAt?: number; error?: string; retryCount: number; maxRetries: number; phaseBreakdown?: PhaseBreakdown; // Sprint 5 ITEM 14 runtimePhase?: AgentRuntimePhase; // S744: fase sub-RUNNING dell'agente proofBadge?: { status: "ok" | "warn"; label: string }; // Visual Proof Badge } // ─── Store ──────────────────────────────────────────────────────────────────── interface TaskStoreState { tasks: Task[]; activeTaskId: string | null; hydrated: boolean; startTask: (goal: string, plan?: string[]) => string; updateStatus: (id: string, status: TaskStatus, error?: string) => void; addLog: (id: string, level: TaskLog["level"], message: string) => void; addAction: (id: string, action: Omit) => void; completeAction: (id: string, actionId: string, durationMs?: number) => void; addArtifact: (id: string, artifact: TaskArtifact) => void; advanceStep: (id: string) => void; cancelTask: (id: string) => void; retryTask: (id: string) => void; clearTasks: () => void; setActiveTask: (id: string | null) => void; setPhaseBreakdown: (id: string, bd: PhaseBreakdown) => void; // Sprint 5 ITEM 14 // S36: step actions addStep: (id: string, step: Omit) => string; completeStep: (id: string, stepId: string, output?: string) => void; failStep: (id: string, stepId: string, error: string) => void; setRuntimePhase: (id: string, phase: AgentRuntimePhase) => void; // S744 setProofBadge: (id: string, badge: { status: "ok" | "warn"; label: string }) => void; _setHydrated: (tasks: Task[]) => void; } // ─── ID generation ──────────────────────────────────────────────────────────── let _idCounter = 0; function genId(): string { return `task-${Date.now()}-${++_idCounter}`; } function genActionId(): string { return `act-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; } function genStepId(): string { return `step-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; } // ─── S36: Dependency Injection per test ────────────────────────────────────── // Pattern: _setTestTasksDb(mockDb) sostituisce le chiamate Dexie con mock. // Identico a skillPersistence._setTestDb(). export interface TasksDbDeps { put: (task: StoredTask) => Promise; putMany: (tasks: StoredTask[]) => Promise; loadRecent: (limit?: number) => Promise; pruneOlderThan: (days: number) => Promise; clear: () => Promise; } const _realDb: TasksDbDeps = { put: (t) => tasksDb.put(t), putMany: (ts) => tasksDb.putMany(ts), loadRecent: (n) => tasksDb.loadRecent(n), pruneOlderThan: (d) => tasksDb.pruneOlderThan(d), clear: () => vfsDb.tasks.clear().then(() => {}), }; let _db: TasksDbDeps = { ..._realDb }; /** Solo per test — sostituisce Dexie con un mock in-memory. */ export function _setTestTasksDb(mock: Partial): void { _db = { ..._realDb, ...mock }; } /** Solo per test — ripristina l'implementazione reale. */ export function _resetTasksDb(): void { _db = { ..._realDb }; } // ─── Helper: Task → StoredTask ──────────────────────────────────────────────── function toStored(t: Task): StoredTask { return { id: t.id, status: t.status, goal: t.goal, plan: t.plan ?? [], currentStep: t.currentStep, logs: t.logs.slice(-50), artifacts: t.artifacts, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, retryCount: t.retryCount, maxRetries: t.maxRetries, steps: t.steps as unknown[], }; } // ─── Concurrency cap ────────────────────────────────────────────────────────── const MAX_CONCURRENT_RUNNING = 2; // ─── Terminal statuses ──────────────────────────────────────────────────────── const TERMINAL_STATUSES: TaskStatus[] = ["SUCCESS", "ERROR", "TIMEOUT", "CANCELLED"]; // ─── S36: Resume window — task RUNNING < 30min al boot → candidato resume ──── // Se l'app è ripartita entro 30min, segna il task QUEUED per eventuale resume. // Se > 30min, segna CANCELLED (presumibilmente orphan). // GAP-TASKHYD-30MIN fix: esteso da 30min a 24h. // Il backend scheduler ha watchdog autonomo (STUCK_TIMEOUT_S=300s). Il cutoff da // 30min cancellava task ancora attivi sul backend — il risultato SSE arrivava ma // non trovava il task corrispondente e veniva scartato silenziosamente. export const RESUME_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 ore /** S36: Logica di hydration intelligente. Pure function — testabile. */ export function resolveHydratedStatus( task: { status: string; startedAt: number }, now = Date.now(), ): TaskStatus { if (task.status !== "RUNNING" && task.status !== "QUEUED") { return task.status as TaskStatus; } const age = now - task.startedAt; // Task giovane → QUEUED (candidato resume manuale o automatico) // Task vecchio → CANCELLED (orphan presumibile) return age < RESUME_WINDOW_MS ? "QUEUED" : "CANCELLED"; } // S337-F4-FIX: timestamp di avvio sessione corrente. // Usato in _tryPromoteQueued per distinguere task avviati in questa sessione // da task hydratati da sessioni precedenti (bug Manus: "Nuova chat → pianificazione autonoma"). // Task hydratati hanno startedAt dalla sessione precedente → vengono scartati. // Margine di 5s copre il gap tra module load e il primo startTask dell'utente. const _SESSION_START = Date.now(); // ─── Queue promotion via DOM event (zero circular import) ──────────────────── function _tryPromoteQueued(tasks: Task[]): void { if (typeof window === "undefined") return; const running = tasks.filter(t => t.status === "RUNNING").length; if (running >= MAX_CONCURRENT_RUNNING) return; const next = tasks.find(t => t.status === "QUEUED"); if (!next) return; // S337-F4: previene auto-promozione di task hydratati da sessioni precedenti. // Root cause del bug "Nuova chat → pianificazione non richiesta": // hydrateTaskStore() imposta RUNNING/QUEUED → QUEUED per task < 30min. // Al primo updateStatus() terminale → _tryPromoteQueued() → dispatch evento. // Task da hydration hanno startedAt < _SESSION_START → ignorati. // Task avviati in questa sessione hanno startedAt ≥ _SESSION_START - 5s → promossi. if (next.startedAt < _SESSION_START - 5_000) return; window.dispatchEvent( new CustomEvent("agent:promote-queued", { detail: { taskId: next.id, goal: next.goal, plan: next.plan ?? [] }, }), ); } export const useTaskStore = create((set, get) => ({ tasks: [], activeTaskId: null, hydrated: false, startTask: (goal, plan) => { const id = genId(); const task: Task = { id, status: "QUEUED", goal, plan: plan ?? [], steps: [], currentStep: 0, logs: [], actions: [], artifacts: [], startedAt: Date.now(), retryCount: 0, maxRetries: 3, }; set(s => ({ tasks: [task, ...s.tasks].slice(0, 20), activeTaskId: id })); _db.put(toStored(task)).catch(() => {}); return id; }, // RC-FIX-1: setTimeout spostato FUORI dal set() callback. // Ragione: Zustand set() deve essere una pure function che ritorna il nuovo stato. // In React StrictMode (dev) il callback è invocato 2× (il primo risultato è scartato). // Con setTimeout DENTRO set(), in StrictMode viene schedulato 2× → // _tryPromoteQueued() fires 2× → doppio CustomEvent "agent:promote-queued" → // il task parte due volte in parallelo (bug critico in dev, intermittente in prod). updateStatus: (id, status, error) => { set(s => { // S744: auto-map terminal TaskStatus → AgentRuntimePhase const _s744PhaseMap: Partial> = { SUCCESS: 'DONE', ERROR: 'ERROR', TIMEOUT: 'ERROR', CANCELLED: 'ERROR', }; const _s744AutoPhase = _s744PhaseMap[status as keyof typeof _s744PhaseMap]; const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, status, error, finishedAt: TERMINAL_STATUSES.includes(status) ? Date.now() : t.finishedAt, ...(_s744AutoPhase ? { runtimePhase: _s744AutoPhase } : {}), }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); return { tasks }; }); // RC-FIX-1: setTimeout DOPO set() — eseguito una volta sola, fuori dal ciclo Zustand if (TERMINAL_STATUSES.includes(status)) { setTimeout(() => _tryPromoteQueued(useTaskStore.getState().tasks), 50); } }, addLog: (id, level, message) => set(s => { const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, logs: [...t.logs, { at: Date.now(), level, message }].slice(-100), }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); return { tasks }; }), addAction: (id, actionData) => set(s => { const action: TaskAction = { ...actionData, id: genActionId(), at: Date.now(), }; const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, actions: [...t.actions, action].slice(-100), // AUDIT-FE-1: allineato con logs slice(-100) }); return { tasks }; }), completeAction: (id, actionId, durationMs) => set(s => { const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, actions: t.actions.map(a => a.id !== actionId ? a : { ...a, done: true, durationMs, }), }); return { tasks }; }), addArtifact: (id, artifact) => set(s => { const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, artifacts: [...t.artifacts, artifact], }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); return { tasks }; }), advanceStep: (id) => set(s => { const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, currentStep: t.currentStep + 1, }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); return { tasks }; }), // S36: step actions ────────────────────────────────────────────────────────── addStep: (id, stepData) => { const stepId = genStepId(); const step: TaskStep = { ...stepData, id: stepId, startedAt: Date.now(), }; set(s => { const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, steps: [...t.steps, step].slice(-200), }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); return { tasks }; }); return stepId; }, completeStep: (id, stepId, output) => set(s => { const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, steps: t.steps.map(st => st.id !== stepId ? st : { ...st, status: "done" as TaskStepStatus, finishedAt: Date.now(), output: output?.slice(0, 500), }), }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); return { tasks }; }), failStep: (id, stepId, error) => set(s => { const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, steps: t.steps.map(st => st.id !== stepId ? st : { ...st, status: "failed" as TaskStepStatus, finishedAt: Date.now(), error, }), }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); return { tasks }; }), // RC-FIX-2: setTimeout spostato FUORI dal set() callback (stesso motivo di RC-FIX-1). cancelTask: (id) => { set(s => { const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, status: "CANCELLED" as TaskStatus, finishedAt: Date.now(), runtimePhase: 'IDLE' as AgentRuntimePhase, // GAP-AGENTRUNTIME-PHASE-LEAK }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); return { tasks, activeTaskId: s.activeTaskId === id ? null : s.activeTaskId, }; }); // RC-FIX-2: setTimeout DOPO set() setTimeout(() => _tryPromoteQueued(useTaskStore.getState().tasks), 50); }, // RC-FIX-3: doppio fix — // (a) Guard `retryCount >= maxRetries` spostata DENTRO set() per essere atomica: // tra get() e set() un'altra call potrebbe incrementare retryCount (stale-read race). // (b) setTimeout spostato FUORI dal set() callback (stesso motivo di RC-FIX-1/2). // Flag _shouldPromote: indica se set() ha effettivamente aggiornato il task // (falso se guard ha bloccato l'update) → setTimeout schedulato solo se necessario. retryTask: (id) => { let _shouldPromote = false; set(s => { // RC-FIX-3a: legge da s (stato corrente al momento di set()) — atomico, no stale read const task = s.tasks.find(t => t.id === id); if (!task || task.retryCount >= task.maxRetries) return s; // no change const tasks = s.tasks.map(t => t.id !== id ? t : { ...t, status: "QUEUED" as TaskStatus, error: undefined, finishedAt: undefined, retryCount: t.retryCount + 1, actions: [], steps: [], logs: [ ...t.logs, { at: Date.now(), level: "info" as const, message: `Retry ${t.retryCount + 1}/${t.maxRetries}` }, ], }); const updated = tasks.find(t => t.id === id); if (updated) _db.put(toStored(updated)).catch(() => {}); _shouldPromote = true; return { tasks }; }); // RC-FIX-3b: setTimeout DOPO set(), solo se l'update è avvenuto if (_shouldPromote) setTimeout(() => _tryPromoteQueued(useTaskStore.getState().tasks), 50); }, clearTasks: () => { _db.clear().catch(() => {}); set({ tasks: [], activeTaskId: null }); }, setActiveTask: (id) => set({ activeTaskId: id }), setPhaseBreakdown: (id, bd) => set(s => ({ // Sprint 5 ITEM 14 tasks: s.tasks.map(t => t.id !== id ? t : { ...t, phaseBreakdown: bd }), })), setRuntimePhase: (id, phase) => set(s => ({ // S744: aggiorna solo runtimePhase — nessuna scrittura Dexie (effimero per sessione) tasks: s.tasks.map(t => t.id !== id ? t : { ...t, runtimePhase: phase }), })), setProofBadge: (id, badge) => set(s => ({ // Visual Proof Badge — effimero per sessione, no Dexie tasks: s.tasks.map(t => t.id !== id ? t : { ...t, proofBadge: badge }), })), _setHydrated: (tasks) => set({ tasks, hydrated: true }), })); // ─── Hydrate from Dexie on app init (S36: resume logic) ───────────────────── export async function hydrateTaskStore(): Promise { try { const stored = await _db.loadRecent(20); if (stored.length === 0) { useTaskStore.getState()._setHydrated([]); return; } const now = Date.now(); const tasks: Task[] = stored.map(s => ({ id: s.id, status: resolveHydratedStatus(s, now), // S36: smart resume goal: s.goal, plan: s.plan, steps: (s.steps ?? []) as TaskStep[], currentStep: s.currentStep, logs: s.logs as TaskLog[], actions: [], // actions non persistono (effimere per sessione) artifacts: s.artifacts as TaskArtifact[], startedAt: s.startedAt, finishedAt: s.finishedAt, error: s.error, retryCount: s.retryCount, maxRetries: s.maxRetries, })); useTaskStore.getState()._setHydrated(tasks); await _db.putMany(tasks.map(toStored)); await _db.pruneOlderThan(7); } catch { useTaskStore.getState()._setHydrated([]); } } // ─── S36: EventRuntime bridge (opzionale — chiama on app init se vuoi) ─────── /** * Collega EventRuntime → taskStore. * Da chiamare una volta all'init (agentSSE.ts o App.tsx). * Ritorna unsub per cleanup. * * NOTA: agentSSE.ts aggiorna già taskStore direttamente — questo bridge * è per componenti che vogliono reagire agli eventi senza importare agentSSE. * * RC-FIX-4: flag _aborted per gestire il caso in cui il cleanup venga chiamato * prima che il dynamic import risolva. Senza il flag, u1/u2/u3 vengono sottoscritte * DOPO che unsub() è già stato invocato → leak permanente: nessun modo di * de-sottoscriverle perché unsub non è ancora valorizzato al momento del cleanup. */ export function subscribeToEventRuntime(): () => void { let unsub: (() => void) | null = null; let _aborted = false; // RC-FIX-4: segnala cleanup anticipato import("@/core/events/EventRuntime").then(({ eventRuntime }) => { // RC-FIX-4: import risolto dopo cleanup → non sottoscrivere (evita leak) if (_aborted) return; const u1 = eventRuntime.on("task_complete", (e) => { const state = useTaskStore.getState(); const task = state.tasks.find(t => t.id === e.taskId); if (task && task.status === "RUNNING") { state.updateStatus(e.taskId, "SUCCESS"); } }); const u2 = eventRuntime.on("task_error", (e) => { const state = useTaskStore.getState(); const task = state.tasks.find(t => t.id === e.taskId); if (task && task.status === "RUNNING") { state.updateStatus(e.taskId, "ERROR", e.message); } }); const u3 = eventRuntime.on("task_cancel", (e) => { const state = useTaskStore.getState(); const task = state.tasks.find(t => t.id === e.taskId); if (task && (task.status === "RUNNING" || task.status === "QUEUED")) { state.cancelTask(e.taskId); } }); unsub = () => { u1(); u2(); u3(); }; }).catch(() => {}); return () => { _aborted = true; // RC-FIX-4: blocca sottoscrizioni se import non ancora risolto unsub?.(); }; } // ─── Selectors ──────────────────────────────────────────────────────────────── export const selectActiveTask = (s: TaskStoreState) => s.tasks.find(t => t.id === s.activeTaskId) ?? null; export const selectRunningTasks = (s: TaskStoreState) => s.tasks.filter(t => t.status === "RUNNING" || t.status === "QUEUED"); export const selectQueuedTasks = (s: TaskStoreState) => s.tasks.filter(t => t.status === "QUEUED"); export const selectTaskById = (id: string) => (s: TaskStoreState) => s.tasks.find(t => t.id === id) ?? null; export const selectQueueLength = (s: TaskStoreState) => s.tasks.filter(t => t.status === "QUEUED").length; export const selectTaskSteps = (id: string) => (s: TaskStoreState) => s.tasks.find(t => t.id === id)?.steps ?? []; // ─── Primitive selectors — React#185 safe ───────────────────────────────────── // Usare questi invece di selectRunningTasks/selectQueuedTasks nei componenti. // selectRunningTasks usa .filter() → nuovo array ad ogni call → loop infinito // con useSyncExternalStore (Zustand v4 + React 18 concurrent mode). export const selectRunningTaskCount = (s: TaskStoreState) => s.tasks.filter(t => t.status === "RUNNING" || t.status === "QUEUED").length; export const selectFirstRunningId = (s: TaskStoreState) => s.tasks.find(t => t.status === "RUNNING" || t.status === "QUEUED")?.id ?? null; export const selectIsRunning = (s: TaskStoreState) => s.tasks.some(t => t.status === "RUNNING" || t.status === "QUEUED"); // S744: selettore fase sub-RUNNING per un task specifico // Usare con shallow compare: useTaskStore(selectRuntimePhase(id), shallow) export const selectRuntimePhase = (id: string) => (s: TaskStoreState): AgentRuntimePhase => s.tasks.find(t => t.id === id)?.runtimePhase ?? 'IDLE';