AUDIT / src /store /taskStore.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 4)
641b62c verified
Raw
History Blame Contribute Delete
26.7 kB
/**
* 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<TaskAction, "id" | "at">) => 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<TaskStep, "id" | "startedAt">) => 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<void>;
putMany: (tasks: StoredTask[]) => Promise<void>;
loadRecent: (limit?: number) => Promise<StoredTask[]>;
pruneOlderThan: (days: number) => Promise<number>;
clear: () => Promise<void>;
}
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<TasksDbDeps>): 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<TaskStoreState>((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<Record<TaskStatus, AgentRuntimePhase>> = {
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<void> {
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';