UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

195 lines • 10.3 kB
/** * #1574 / #2057 — when the tray event and `download_model action:"status"` disagree, say so. * * The completion event is built from a PROGRESS ROW read off disk each tick. `status` answers * from the JOB RECORD. Two stores, and the event consults only one — the same split #1545 * documented from the other side. * * #1574: the tray raised `transfer completed` for an 11.46GB download while status reported * it still streaming. The file landed AFTER the event. * * #2057: the tray raised `Model download FAILED` (and claimed nothing transferred) for a * 19.53GB HuggingFace fetch while status — same id — still said **downloading** with * advancing bytes (0.14 → 0.22 GB). An agent that trusts FAILED re-issues into a live * writer, which is the corruption hazard status itself warns about. #1150 only hedges * when a LIVE TRAY ROW still shows that filename downloading; here the tray row itself * was the error, so that hedge never fired. The job record is the same authority status * reads, so a failure has to be checked against it the same way a completion is. * * ## Why this ANNOTATES and never suppresses * * The first version of the completion guard dropped the contradicted event. Review killed * it, correctly: a terminal record may legitimately still read `downloading` until the ~15s * persistence heartbeat retries (see `persistDownloadJob`'s return value, #1545). The * debounce bucket is deleted before filtering and never requeued, so suppressing on a * lagging record would PERMANENTLY lose the notification. * * That trades a confusing message for a missing one, which is worse: the user is waiting on * that event. So the event always fires, and a disagreement is disclosed on it. For a * completion the harm is a CONFIDENT false success; for a failure it is a CONFIDENT false * FAILED — both removed by hedging, not by silence. * * ## Identity * * The two stores do not share an id. A progress row's `id` is the progress/tray identity; * the job's `id` is its public status handle (`6226e26ba97f8527` in the #1574 report, * against tray `93015fbfa0fa9933`; `07d14bb0c73bc007` against tray `31bba4a9cdb04e28` in * #2057). The row is matched against the job's `progressId ?? trayId`, which is what * writes those rows. An earlier version compared row id to job id and was therefore * INERT — it never matched anything, and its unit tests missed that because they fed * synthetic rows carrying whatever id the assertion wanted. */ /** The identity a PROGRESS ROW is written under. */ function progressIdentityOf(job) { const progress = typeof job.progressId === "string" ? job.progressId : null; const tray = typeof job.trayId === "string" ? job.trayId : null; return progress ?? tray; } /** * The job record for this progress row, or null when nothing matches. * * (id, target) — the SAME key the supersession logic uses, and for the same reason: a * concurrent LOCAL and POD transfer of one URL shares an id but is two transfers with two * outcomes. Matching on id alone could annotate the wrong terminal, or miss a real * disagreement by finding the other one first (review). * * Target identity is production data, not a test-only discriminator. A row or record * without it is ambiguous when local and pod transfers share an id, so legacy targetless * records fail closed and never suppress or promote a terminal event. */ function matchingRecord(row, jobs) { if (!row || typeof row !== "object") return null; const id = typeof row.id === "string" ? row.id : null; if (!id) return null; if (!Array.isArray(jobs)) return null; const target = typeof row.target === "string" && row.target.length > 0 ? row.target : null; if (!target) return null; const candidates = jobs.filter((j) => j && typeof j === "object" && progressIdentityOf(j) === id && typeof j.target === "string" && j.target.length > 0 && j.target === target); if (!candidates.length) return null; // A reconnect can leave both an older terminal snapshot and the newer live record in // the persisted store. `download_model action:"status"` resolves that ambiguity in // favour of the live transfer; the tray guard must make the same choice or it can // announce FAILED while status is still advancing. // `download_model action:"status"` treats stale persisted rows as historical // diagnostics and resolves the terminal record instead. Prefer a fresh live record // when both snapshots are present, but keep a stale one available for error-detail // lookup and explicit non-live handling below. return (candidates.find((j) => j.status === "downloading" && j.staleInflight !== true) ?? candidates.find((j) => j.status !== "downloading") ?? candidates.find((j) => j.status === "downloading") ?? candidates[0] ?? null); } function recordStillDownloading(row, jobs) { const record = matchingRecord(row, jobs); // This is deliberately the same freshness boundary as the status action. A stale // persisted heartbeat can remain on disk for six hours, but status resolves it as a // terminal/no-live answer; it must not suppress a real FAILED tray notification. return record != null && record.status === "downloading" && record.staleInflight !== true; } /** * Does the job record disagree with announcing this row as completed? * * True ONLY on a positive contradiction: a record exists for this exact progress identity and * still says `downloading`. A missing record is not evidence — the record store resets on an * orchestrator respawn, which is exactly the reported session. */ export function completionDisagreesWithRecord(row, jobs) { if (!row || typeof row !== "object") return false; // Completions only. Failures have their own check (#2057); #1150's live-tray hedge // stays a separate question (a different id writing the same filename). if (row.status !== "done") return false; return recordStillDownloading(row, jobs); } /** Classify a terminal tray row against the exact production job identity. */ export function failureRecordDisposition(row, jobs) { if (!row || typeof row !== "object" || row.status !== "error") { return { disposition: "none", record: null }; } const record = matchingRecord(row, jobs); if (!record || record.status !== "downloading") { return { disposition: "none", record: null }; } if (record.staleInflight === true) return { disposition: "stale", record }; return { disposition: row.progressAdvanced === true ? "advancing" : "stalled", record }; } /** * Does the job record disagree with announcing this row as FAILED? * * Same identity and same positive-contradiction rule as {@link completionDisagreesWithRecord}. * #1150 only looks at live TRAY rows of the same filename; this looks at the job RECORD * `download_model action:"status"` reads. The reported case had no live tray row — the tray * itself was the error — while status still showed the same id streaming and advancing. */ export function failureDisagreesWithRecord(row, jobs) { if (!row || typeof row !== "object") return false; if (row.status !== "error") return false; // A fresh heartbeat says only that persistence happened. The failure hedge requires // an actual byte increase observed by the poller, or a stalled stream could suppress // a genuine FAILED notification indefinitely. Stalled/stale records are reconciled to // terminal state by the production poll seam instead of being treated as live. return failureRecordDisposition(row, jobs).disposition === "advancing"; } /** Bound one error string before it reaches the tray/agent event. */ export function boundedDownloadError(value) { if (typeof value !== "string") return undefined; const compact = value.replace(/\s+/g, " ").trim(); return compact ? compact.slice(0, 400) : undefined; } /** Return the bounded error detail from the authoritative terminal record, when present. */ export function failureErrorDetail(row, jobs) { if (!row || typeof row !== "object" || row.status !== "error") return undefined; const rowError = boundedDownloadError(row.error); const record = matchingRecord(row, jobs); return (rowError ?? (record?.status !== "downloading" ? boundedDownloadError(record?.error) : undefined)); } /** * Reconcile the failure half of one production download_done batch. * * The poll loop calls this after it has read the tray rows and authoritative job records, * before injecting the batch into the panel agent. Keeping the mutation here makes that * production seam directly testable without treating PanelAgentManager injection as the * producer-side proof. */ export function reconcileDownloadDoneFailures(rows, jobs) { const disagreeing = []; for (const row of rows) { if (failureDisagreesWithRecord(row, jobs)) { row.recordDisagrees = true; disagreeing.push(row); } if (row.status === "error") row.error = failureErrorDetail(row, jobs); } return disagreeing; } /** The disclosure appended to a completion the record disagrees with. Deliberately states * BOTH readings and what to do, rather than picking a winner this cannot establish. */ export const COMPLETION_DISAGREEMENT_NOTE = "CAVEAT: `download_model action:\"status\"` still reports this transfer as downloading. " + "The two are read from different stores and the record can lag a real completion by a few " + "seconds, so this may simply be that — but a completion event has also been observed to " + "arrive minutes before the file existed (#1574). Check `download_model action:\"status\"` " + "and that the file is present before loading it."; /** Instruction for a FAILED tray row whose job record is still downloading (#2057). * Must not contain the word FAILED: that headline is what made the agent re-issue. */ export const FAILURE_DISAGREEMENT_NOTE = "`download_model action:\"status\"` still reports this transfer as downloading — " + "do NOT report it as failed"; //# sourceMappingURL=download-done-guard.js.map