diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 06e87bc7d..b6c44e865 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -1,5 +1,8 @@ # Troubleshooting +- OpenCode API message dumps can exceed the CLI's output limit and become truncated JSON; use `GET /api/session/{id}/message?limit=1` for the latest completed review, or bounded pagination, rather than dumping every tool result. Verify the returned assistant model metadata when exact-model reviews are required. +- Provider diagnostics must classify `finalized` inside the Pi harness before `closedFailureMessage` replaces the raw error. Reclassifying `runtimeOutcome.finalMessage` in `llm-client` loses model-unavailable/authentication evidence; assert the emitted production event with a real faux-provider harness test. + - `.papercuts/` is ignored even when its troubleshooting file is present in the PR branch, so persisting a required update needs an explicit `git add -f`. - Layout stabilization must race `animation.finished` against a short timeout because paused or infinite document animations never settle; keep geometry polling as the authoritative E2E readiness check. - Pi 0.80.10 can choose the oldest oversized user turn as `firstKeptEntryId`, leaving both summary inputs empty and producing a no-op checkpoint. When the journal has a newer turn, retry `prepareCompaction` with a minimal retained-tail budget; still refuse the checkpoint if both summary inputs remain empty. diff --git a/docs/plans/logging-and-diagnostics-upgrade-plan.md b/docs/plans/logging-and-diagnostics-upgrade-plan.md index c0341b4ab..bbf9b0ba9 100644 --- a/docs/plans/logging-and-diagnostics-upgrade-plan.md +++ b/docs/plans/logging-and-diagnostics-upgrade-plan.md @@ -27,6 +27,28 @@ failure vocabulary. No upload path was added. ## Verification evidence +### September 2026 production-cause hardening + +- Main diagnostics retain only closed error/cause categories, validated structural + HTTP status, and bounded fingerprints; they never persist raw request/response + text. Cause traversal is bounded, cycle-aware, and excludes proxies/accessors. + Fingerprints group the closed error/cause/status tuple rather than call sites: + V8 stack accessors may execute custom formatting, so stack materialization is + deliberately excluded. Provider terminal failures count as failed health; + they previously contributed to the degraded bucket through the legacy logger. +- Renderer exceptions use `renderer-exception`, with script/promise/React/route + phases; actual renderer process death retains `renderer-crashed`. Abort errors + are counted as cancellation rather than failure. +- Provider failure classification runs in the harness before outcome redaction. + `model_unavailable` is diagnostic-only; the existing portable provider-failure + DTO and native client presentation remain compatible. MCP discovery projects + the caught error structurally instead of interpolating it into a log string. +- Task reads and generations distinguish neutral `todo-storage-disabled` evidence + from `todo-snapshot-invalid`; policy-disabled tracking does not inflate degraded + health counts. Events contain neither chat identifiers nor task content. +- Historical opaque errors remain undiagnosed where the old journal discarded + their causes. These changes improve future evidence, not retrospective certainty. + The implementation has passed the repository's full desktop test command, the focused diagnostic contract/policy suite, TypeScript and E2E type checks, ESLint, the standard Electron E2E matrix, the isolated production-profile diagnostics diff --git a/docs/plans/rpiv-todo-integration-plan.md b/docs/plans/rpiv-todo-integration-plan.md index 23304a4dd..74de36dfe 100644 --- a/docs/plans/rpiv-todo-integration-plan.md +++ b/docs/plans/rpiv-todo-integration-plan.md @@ -14,10 +14,10 @@ The extension is deliberately excluded from Assistant mode, Bots, Telegram/mobil 1. `main/services/rpiv-todo/contract.ts` defines the closed version-1 snapshot and parameter contract, terminal/control-character sanitization, descriptor-safe plain-JSON checks, hard byte/count/depth limits, dependency DAG validation, and the one-`in_progress` invariant. 2. `main/services/rpiv-todo/reducer.ts` owns create, update, list, get, tombstone delete, and clear. Validation failures are successful in-band tool results with an unchanged complete snapshot, so the journal remains replayable. Completed tasks cannot reopen; deleted tasks remain tombstones until clear; dependency references are preserved. -3. `main/services/rpiv-todo/replay.ts` scans the current Pi branch for the newest todo tool result. A malformed newest result fails closed and disables todo for the chat; it never regresses to an older valid state. Compaction entries do not become a second state authority. +3. `main/services/rpiv-todo/replay.ts` scans the entire current Pi branch in oldest-to-newest order, selects the newest non-`isError` todo tool result, and validates only that authoritative full snapshot. Older malformed snapshots are superseded; a malformed newest checkpoint fails closed and disables todo for the chat, never regressing to an older valid state. Dispatch/schema error results are skipped, and branch read or iteration failures propagate unchanged. Compaction entries do not become a second state authority. 4. `main/services/rpiv-todo/extension.ts` contributes a generation-local native Pi tool and guidance. Replay policy is `safe`: mutation exists only in the generation closure until the full result is durably journaled, so a crash retry cannot duplicate durable state. Renderer publication waits for a durable `toolResult` `message_end` runtime event. -5. `main/services/llm-client.ts` opens the private chat session before freezing runtime contributions, replays todo state, requires an explicitly classified chat usage source, publishes the initial projection, and sends later projections only after journal durability. Verified corrupt replay immediately publishes the content-free unavailable projection. -6. `main/handlers/chats.ts` exposes an owner-fenced `chats:todoSnapshot` read. It rechecks the exact renderer document after asynchronous work. Corrupt todo journals return a content-free unavailable state; other storage errors remain errors. +5. `main/services/llm-client.ts` opens the private chat session before freezing runtime contributions and shares `loadDurableTodoSnapshot` with the chat-open read. Todo requires an explicitly classified chat usage source and a durable session: journalless generations omit the tool and publish `storage_not_enabled`, never an ephemeral ready list. Durable generations publish the initial projection and later projections only after journal durability. Verified corrupt replay publishes the content-free `invalid_snapshot` projection. Tool results are validated against the reader contract before generation-local mutation. +6. `main/handlers/chats.ts` exposes an owner-fenced `chats:todoSnapshot` read. It rechecks the exact renderer document after asynchronous work. Storage-disabled chats and invalid snapshots have distinct, closed reasons; other storage errors remain errors. The renderer explains disabled storage without implying corruption, and older unavailable DTOs retain the verification-failure presentation. 7. `renderer/shared/todo.ts` is the only renderer projection. Its strict versioned allowlist contains `id`, `subject`, `status`, `activeForm`, and `blockedBy`. Tool arguments/results, descriptions, ownership, metadata, and journal structure remain private. 8. `renderer/lib/ipc.ts` validates both snapshot reads and stream notifications, and fences notifications by generation stream and chat id. A local live-snapshot revision fence prevents a slow initial read from replacing newer generation state. `renderer/components/todo-panel.tsx` renders a zero-layout-height elevated chip anchored above the footer and a portal-backed, headerless hover/focus task list with semantic Aiden tokens, per-task screen-reader status, bounded polite progress/unavailable announcements, and reduced-motion behavior. `ScrollArea` raises its centered scroll-to-bottom control only while this overlay is visible, so the two controls never share a hit target. Fully completed plans retain only the live-region completion announcement. 9. `main/services/generation-timeline.ts` exposes only the content-free activity label “Update task list.” @@ -37,7 +37,7 @@ The extension is deliberately excluded from Assistant mode, Bots, Telegram/mobil `npm run test:todo` is registered in `pretest` and covers: - strict contract parsing, sanitization, size limits, graph invariants, transitions, tombstones, and unchanged in-band error snapshots; -- branch replay, compaction survival, no-snapshot initialization, and fail-closed newest-result corruption; +- branch replay, compaction survival, no-snapshot initialization, superseded corruption, fail-closed newest-checkpoint corruption, skipped dispatch errors, and propagated branch read/iteration failures; - admission fencing, per-generation state isolation, cancellation, replay policy, and real harness coverage proving publication follows successful durable append and never follows append failure; - closed renderer projection and unavailable-state parsing; - slow-initial-read versus live-snapshot ordering and immediate corrupt-replay unavailability; diff --git a/docs/testing/pi-compaction-phase7-rollout-gates.md b/docs/testing/pi-compaction-phase7-rollout-gates.md index b7c9921ae..8c36a4d26 100644 --- a/docs/testing/pi-compaction-phase7-rollout-gates.md +++ b/docs/testing/pi-compaction-phase7-rollout-gates.md @@ -3,7 +3,7 @@ Status: automated evaluation and signed development-package acceptance pass; installed production and credentialed-provider evidence remains **Pending** until the release owner runs the steps below against the installed candidate. -Generation is never blocked by the rollout gates at any stage: +Valid rollout-stage ineligibility does not block generation: rollout-ineligible chats generate **journalless** over an in-memory session instead of failing (see "Journalless generation" below), so advancing the stage is a durability decision, not an availability one. @@ -48,6 +48,25 @@ npm run pi-upgrade:advance -- migrated_low_risk_chats Repeat only after observing the current stage and completing the next cohort's acceptance. The command cannot skip or regress a stage, validates the evaluation receipt against the complete signed `.app` digest, and requires the installed receipt for `v4_only`. +Every policy read reloads and validates the device document, so an operator's +successful CLI advance is visible to subsequent eligibility checks in the +running app without a restart. A generation already running keeps its selected +session and per-run behavior flags; send a new turn to exercise the new stage. +Reads never advance the stage. A missing policy is initialized only before that +store has observed a valid policy, using atomic, no-overwrite publication; +concurrent creators use the winning document. Malformed or schema-invalid +replacements reject rather than falling back to a cached permissive policy, and +a policy removed after a successful read also rejects. These failures are not +ordinary cohort ineligibility and can block the operation. Restoring a valid +document lets the next read recover without restarting or rewriting that data. + +`activatedAt` is the new-chat cohort cutoff: first policy creation establishes +it for the production `new_chats` default, or an explicit advance into +`new_chats` establishes it when starting from an earlier stage. Later advances +preserve it so a previously eligible new chat does not lose compaction/memory +eligibility merely because it has grown beyond 100 messages. Existing policy +timestamps are used as stored; no historical cutoff is inferred or rewritten. + ## Rollback Set `AIDEN_PI_UPGRADE_BEHAVIOR_ENABLED=0` before app startup and restart Aiden. This disables new v4 journal creation, legacy migration, automatic/manual Pi checkpoint generation, and durable-memory retrieval or writes. Existing v4 journals remain readable and are not downgraded or rewritten. With the journalless safety net, chats that have no existing v4 journal continue to generate in the rollback environment — journalless over an in-memory session — rather than failing; durable compaction, memory, and history recall stay disabled for them until the override is removed and the persisted rollout stage resumes. Remove the override and restart to resume the persisted rollout stage. @@ -73,8 +92,9 @@ It engages exactly when a chat cannot yet hold a durable journal: Semantics of a journalless run: the request path is identical, visible turns persist through the chat store exactly as with journaled runs, but VCC history -recall is omitted (nothing durable to recall), todo replay reads the empty -in-memory journal (the todo panel reports the snapshot unavailable), +recall is omitted (nothing durable to recall), the durable todo tool is omitted +and both chat-open and live snapshots report `storage_not_enabled` (the panel +explains that saved task tracking is not enabled for this chat), automatic and manual Pi checkpoints stay cohort-disabled, effect-recovery boundaries are written only in-process and never acknowledged as durable, and the durable store can never be quarantined by an in-memory failure. diff --git a/main/handlers/chats.test.ts b/main/handlers/chats.test.ts index 3399195d7..6e9a6950f 100644 --- a/main/handlers/chats.test.ts +++ b/main/handlers/chats.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -test("the todo snapshot handler probes rollout eligibility and never mints journals", () => { +test("the todo snapshot handler shares durable admission and preserves the owner fence", () => { const handlers = readFileSync(new URL("./chats.ts", import.meta.url), "utf8"); const snapshot = handlers.slice( handlers.indexOf('ipcMain.handle("chats:todoSnapshot"'), @@ -11,6 +11,6 @@ test("the todo snapshot handler probes rollout eligibility and never mints journ assert.ok(snapshot.includes('ipcMain.handle("chats:todoSnapshot"')); assert.match(snapshot, /openChatIfEligible/u); assert.doesNotMatch(snapshot, /openChat\(/u); - assert.match(snapshot, /unavailableTodoSnapshot\(chatId\)/u); - assert.match(snapshot, /!opened\.session/u); -}); \ No newline at end of file + assert.match(snapshot, /loadDurableTodoSnapshot\(chatId, opened\.session\)/u); + assert.match(snapshot, /await loadDurableTodoSnapshot[\s\S]*owner\.isDestroyed\(\)[\s\S]*return snapshot/u); +}); diff --git a/main/handlers/chats.ts b/main/handlers/chats.ts index a20dcd56d..01d919b02 100644 --- a/main/handlers/chats.ts +++ b/main/handlers/chats.ts @@ -62,11 +62,9 @@ import { import { botApplicationService } from "../services/bot-application-service-main.js"; import { piCompactionSessionStore } from "../services/pi-compaction-session-store.js"; import { memoryStore } from "../services/memory-store-main.js"; -import { isTodoSnapshotFailure, replayTodoState } from "../services/rpiv-todo/replay.js"; -import { - todoSnapshotForRenderer, - unavailableTodoSnapshot, -} from "../../renderer/shared/todo.js"; +import { loadDurableTodoSnapshot } from "../services/rpiv-todo/snapshot.js"; +import { todoSnapshotDiagnostic } from "../services/rpiv-todo/diagnostics.js"; +import { writeDiagnosticEvent } from "../services/diagnostic-journal.js"; function asString(value: unknown, name: string): string { if (typeof value !== "string" || value.length === 0) { @@ -156,20 +154,11 @@ export function registerChatHistoryHandlers(): void { } if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); const opened = await piCompactionSessionStore.openChatIfEligible(chatId, chat); - if (!opened.session) { - // Rollout-ineligible chats have no durable journal to replay, so todo is - // unavailable exactly like a corrupt journal. Never mint a journal here. - return unavailableTodoSnapshot(chatId); - } - try { - const snapshot = todoSnapshotForRenderer(chatId, await replayTodoState(opened.session)); - if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); - return snapshot; - } catch (error) { - if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); - if (!isTodoSnapshotFailure(error)) throw error; - return unavailableTodoSnapshot(chatId); - } + const { snapshot } = await loadDurableTodoSnapshot(chatId, opened.session); + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + const diagnostic = todoSnapshotDiagnostic(snapshot); + if (diagnostic) writeDiagnosticEvent(diagnostic); + return snapshot; }); ipcMain.handle("chats:waitUntilIdle", async (_event, id: unknown) => diff --git a/main/handlers/diagnostics.ts b/main/handlers/diagnostics.ts index 1b71519cb..44d64172c 100644 --- a/main/handlers/diagnostics.ts +++ b/main/handlers/diagnostics.ts @@ -12,7 +12,7 @@ import { import { app, BrowserWindow, dialog, ipcMain, shell } from "../platform.js"; import { currentRuntimeProfile } from "../runtime-profile.js"; import { writeDiagnosticEvent } from "../services/diagnostic-journal.js"; -import type { DiagnosticEventName } from "../services/diagnostics-contract.js"; +import { rendererDiagnosticClassification, type DiagnosticEventName } from "../services/diagnostics-contract.js"; import { rendererDocumentOwner } from "../services/renderer-document-owner.js"; import { createRendererDiagnosticRateLimiter } from "../services/renderer-diagnostic-rate.js"; import { @@ -110,10 +110,12 @@ export function registerDiagnosticHandlers(): void { level: report.suppressed ? "warn" : "error", area: "renderer", event: RENDERER_EVENT_NAMES[report.kind], - outcome: report.suppressed ? "degraded" : "failed", - code: "renderer-crashed", + ...rendererDiagnosticClassification(report.errorType, report.suppressed), fields: { errorType: report.errorType, + failurePhase: report.kind === "global-error" ? "renderer-script" + : report.kind === "unhandled-rejection" ? "renderer-promise" + : report.kind === "route-error" ? "renderer-route" : "renderer-react", rendererContext: report.context, referenceId: durableReferenceId, suppressed: report.suppressed ?? 0, diff --git a/main/services/diagnostic-journal.test.ts b/main/services/diagnostic-journal.test.ts index 416b70ae1..6b81e1081 100644 --- a/main/services/diagnostic-journal.test.ts +++ b/main/services/diagnostic-journal.test.ts @@ -75,6 +75,40 @@ test("production journal strips development-only fields from typed callers", asy }); }); +test("legacy logging safely classifies cancellation and ignores hostile proxy errors", async () => { + for (const profile of ["production", "development"] as const) await withJournal(profile, async () => { + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + assert.doesNotThrow(() => writeLegacyDiagnostic("error", "pi", [revoked.proxy])); + const inheritsProxy = Object.create(revoked.proxy); + assert.doesNotThrow(() => writeLegacyDiagnostic("error", "pi", [inheritsProxy])); + assert.doesNotThrow(() => writeLegacyDiagnostic("error", "pi", [{ + toJSON() { throw new Error("private"); }, + toString() { throw new Error("private"); }, + }])); + const event = writeLegacyDiagnostic("error", "pi", [new DOMException("private", "AbortError")]); + assert.equal(event.code, "cancelled"); + assert.equal(event.outcome, "cancelled"); + assert.equal(writeLegacyDiagnostic("warn", "pi", [{ status: 503 }]).code, undefined); + assert.equal(writeLegacyDiagnostic("warn", "pi", [{ name: "AbortError" }]).code, undefined); + }); +}); + +test("production legacy adapter retains structural SDK causes without serializing envelopes", async () => { + await withJournal("production", async (target) => { + const event = writeLegacyDiagnostic("warn", "mcp", [ + "Skipping private server", { cause: { code: "ECONNREFUSED", status: 503 }, + message: "private-prompt", headers: { authorization: "private-auth" }, url: "https://private-endpoint", task: "private-task" }, + ]); + assert.equal(event.event, "mcp-degraded"); + assert.equal(event.code, "network-failed"); + assert.equal(event.fields?.causeCode, "network-failed"); + assert.equal(event.fields?.httpStatus, 503); + await flushDiagnosticJournal(); + assert.doesNotMatch(await fs.readFile(target, "utf8"), /private-/); + }); +}); + test("journal enforces owner-only modes", async () => { await withJournal("production", async (target, dir) => { await flushDiagnosticJournal(); diff --git a/main/services/diagnostic-journal.ts b/main/services/diagnostic-journal.ts index 2e45e66e5..f0034f231 100644 --- a/main/services/diagnostic-journal.ts +++ b/main/services/diagnostic-journal.ts @@ -13,6 +13,7 @@ import { } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { types as utilTypes } from "node:util"; import { MAX_DIAGNOSTIC_EVENT_BYTES, @@ -625,13 +626,13 @@ const LEGACY_AREA_MAP: Readonly> = { function legacyText(values: unknown[]): string { return sanitizeDiagnosticText( values - .filter((value) => !(value instanceof Error)) + .filter((value) => !utilTypes.isNativeError(value) && !utilTypes.isProxy(value)) .map((value) => { if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { - return String(value); + return "[unavailable]"; } }) .join(" "), @@ -651,13 +652,28 @@ export function writeLegacyDiagnostic( values: unknown[], synchronous = false, ): DiagnosticEventV1 { - const error = values.find((value): value is Error => value instanceof Error); + const error = values.find((value) => utilTypes.isNativeError(value)) ?? values.find((value) => { + if (!value || typeof value !== "object" || utilTypes.isProxy(value)) return false; + try { + if (value instanceof DOMException) return true; + // SDKs also throw plain structural envelopes. Classification never retains + // their message, response body, request, headers, or arbitrary properties. + // An arbitrary context object with a status/code alone is not an error. + const message = Object.getOwnPropertyDescriptor(value, "message"); + return message && "value" in message && typeof message.value === "string" && + projectDiagnosticError(value).code !== "unknown"; + } catch { + // Even a non-proxy object may inherit from a hostile/revoked proxy. + return false; + } + }); const projection = error ? projectDiagnosticError(error) : undefined; const input: DiagnosticEventInput = { level, area: LEGACY_AREA_MAP[scope] ?? "diagnostics", event: runtimeProfile === "production" ? legacyEventName(level, LEGACY_AREA_MAP[scope] ?? "diagnostics") : "legacy-log", - ...(level === "error" ? { outcome: "failed" as const } : level === "warn" ? { outcome: "degraded" as const } : {}), + ...(projection?.code === "cancelled" ? { outcome: "cancelled" as const } + : level === "error" ? { outcome: "failed" as const } : level === "warn" ? { outcome: "degraded" as const } : {}), ...(projection ? { code: projection.code } : {}), fields: { legacyScope: sanitizeDiagnosticText(scope, 64) || "unknown", @@ -666,6 +682,8 @@ export function writeLegacyDiagnostic( : {}), ...(projection ? { errorType: projection.errorType } : {}), ...(projection?.fingerprint ? { fingerprint: projection.fingerprint } : {}), + ...(projection?.causeCode ? { causeCode: projection.causeCode } : {}), + ...(projection?.httpStatus === undefined ? {} : { httpStatus: projection.httpStatus }), }, }; if (runtimeProfile === "production" && (level === "debug" || level === "info")) { diff --git a/main/services/diagnostics-contract.test.ts b/main/services/diagnostics-contract.test.ts index 5426307c2..8545d3405 100644 --- a/main/services/diagnostics-contract.test.ts +++ b/main/services/diagnostics-contract.test.ts @@ -8,11 +8,95 @@ import { MAX_DIAGNOSTIC_EVENT_BYTES, normalizeDiagnosticFields, projectDiagnosticError, + rendererDiagnosticClassification, sanitizeDiagnosticText, } from "./diagnostics-contract.js"; const sessionId = "session-test"; +test("main-generated renderer references survive normalization without admitting arbitrary content", () => { + const referenceId = "RD-e20a0162-266b-49ab-ae0a-078f74efe71c"; + assert.equal(normalizeDiagnosticFields({ referenceId })?.referenceId, referenceId); + assert.equal(createDiagnosticEvent({ level: "error", area: "renderer", event: "renderer-global-error", fields: { referenceId } }, sessionId).fields?.referenceId, referenceId); + for (const value of [`${referenceId}\nprivate`, `https://private/${referenceId}`, `Bearer ${referenceId}`]) { + assert.equal(normalizeDiagnosticFields({ referenceId: value }), undefined); + } +}); + +test("structural causes retain HTTP evidence and cancellation without payloads", () => { + const secret = "private-prompt-auth-endpoint-task-content"; + const error = Object.assign(new Error(secret), { cause: { status: 429, message: secret, request: secret } }); + const projected = projectDiagnosticError(error); + assert.equal(projected.code, "rate-limited"); + assert.equal(projected.causeCode, "rate-limited"); + assert.equal(projected.httpStatus, 429); + assert.doesNotMatch(JSON.stringify(projected), /private-prompt/); + assert.equal(projectDiagnosticError(new Error("HTTP 503 private text")).httpStatus, undefined); + assert.equal(projectDiagnosticError({ status: "503" }).httpStatus, undefined); + assert.equal(projectDiagnosticError({ statusCode: 503 }).code, "service-unavailable"); + const wrappedStatus = projectDiagnosticError({ status: 200, cause: { status: 503 } }); + assert.equal(wrappedStatus.code, "service-unavailable"); + assert.equal(wrappedStatus.httpStatus, 503); + const outerStatus = projectDiagnosticError({ status: 429, cause: { status: 503 } }); + assert.equal(outerStatus.code, "rate-limited"); + assert.equal(outerStatus.httpStatus, 429); + assert.equal(projectDiagnosticError({ code: "ECONNRESET", cause: { status: 503 } }).httpStatus, undefined); + assert.equal(projectDiagnosticError({ status: 404 }).code, "not-found"); + assert.equal(projectDiagnosticError(Object.assign(new Error(secret), { cause: { code: "ABORT_ERR" } })).code, "cancelled"); + assert.equal(projectDiagnosticError(Object.assign(new Error(secret), { name: "AbortError" })).code, "cancelled"); + assert.equal(projectDiagnosticError(new TypeError(secret)).errorType, "TypeError"); + assert.equal(projectDiagnosticError(new (class extends Error {})(secret)).errorType, "Error"); + assert.equal(projectDiagnosticError(new (class extends TypeError {})(secret)).errorType, "TypeError"); +}); + +test("renderer cancellation is counted as cancelled, not a process crash or failure", () => { + for (const suppressed of [0, 5]) { + assert.deepEqual(rendererDiagnosticClassification("AbortError", suppressed), { + code: "cancelled", outcome: "cancelled", + }); + } + assert.deepEqual(rendererDiagnosticClassification("TypeError"), { code: "renderer-exception", outcome: "failed" }); + assert.deepEqual(rendererDiagnosticClassification("UnknownError", 5), { code: "renderer-exception", outcome: "degraded" }); +}); + +test("unknown cyclic and accessor errors stay bounded and content-free", () => { + for (const value of [null, undefined, "private-prompt", 0, {}, { code: "private-code" }]) { + assert.equal(projectDiagnosticError(value).code, "unknown"); + assert.doesNotMatch(JSON.stringify(projectDiagnosticError(value)), /private/); + } + const cycle: { cause?: unknown } = {}; + cycle.cause = cycle; + assert.equal(projectDiagnosticError(cycle).code, "unknown"); + const getters = Object.defineProperties({}, Object.fromEntries( + ["name", "code", "status", "statusCode", "cause", "stack"].map((key) => [key, { get() { throw new Error("private"); } }]), + )); + assert.equal(projectDiagnosticError(getters).code, "unknown"); + let traps = 0; + const hostile = new Proxy({}, { + getOwnPropertyDescriptor() { traps += 1; throw new Error("private trap"); }, + getPrototypeOf() { traps += 1; throw new Error("private prototype"); }, + }); + assert.equal(projectDiagnosticError(hostile).code, "unknown"); + assert.equal(projectDiagnosticError({ cause: hostile }).code, "unknown"); + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + assert.equal(projectDiagnosticError(revoked.proxy).code, "unknown"); + assert.equal(traps, 0); + assert.equal(projectDiagnosticError({ code: "X".repeat(100_000) }).code, "unknown"); + assert.equal(projectDiagnosticError({ code: "private-a" }).fingerprint, projectDiagnosticError({ code: "private-b" }).fingerprint); + function alpha() { return new Error("private-a"); } + function beta() { return new Error("private-b"); } + assert.equal(projectDiagnosticError(alpha()).fingerprint, projectDiagnosticError(beta()).fingerprint); + assert.notEqual(projectDiagnosticError({ status: 429 }).fingerprint, projectDiagnosticError({ status: 503 }).fingerprint); + assert.deepEqual(normalizeDiagnosticFields({ + httpStatus: 429, causeCode: "cancelled", failurePhase: "mcp-tool-discovery", providerCategory: "model_unavailable", + }), { httpStatus: 429, causeCode: "cancelled", failurePhase: "mcp-tool-discovery", providerCategory: "model_unavailable" }); + for (const httpStatus of [99, 600, 429.5, Number.NaN, Infinity, "429"]) { + assert.equal(normalizeDiagnosticFields({ httpStatus }), undefined); + } + assert.equal(normalizeDiagnosticFields({ causeCode: "secret", failurePhase: "secret", providerCategory: "secret" }), undefined); +}); + test("diagnostic events normalize names and allowlisted scalar fields", () => { const event = createDiagnosticEvent( { diff --git a/main/services/diagnostics-contract.ts b/main/services/diagnostics-contract.ts index 1994ecee9..687663d03 100644 --- a/main/services/diagnostics-contract.ts +++ b/main/services/diagnostics-contract.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { types as utilTypes } from "node:util"; import { normalizeDiagnosticErrorType } from "../../renderer/shared/diagnostics.js"; export const DIAGNOSTIC_EVENT_VERSION = 1 as const; @@ -76,6 +77,8 @@ export const DIAGNOSTIC_BASE_EVENT_NAMES = [ "process-exit", "process-signal", "provider-failed", + "todo-storage-disabled", + "todo-snapshot-invalid", "store-write-failed", "skills-discovery-failed", "oversize", @@ -120,6 +123,10 @@ export const DIAGNOSTIC_CODES = [ "provider-failed", "rate-limited", "renderer-crashed", + "renderer-exception", + "authentication-failed", + "invalid-request", + "service-unavailable", "crash-loop", "storage-failed", "timed-out", @@ -151,6 +158,8 @@ export interface DiagnosticEventV1 extends DiagnosticEventInput { export interface DiagnosticErrorProjection { code: DiagnosticCode; errorType: string; + causeCode?: DiagnosticCode; + httpStatus?: number; fingerprint?: string; } @@ -193,6 +202,9 @@ const NUMBER_FIELDS = new Set([ const BOOLEAN_FIELDS = new Set(["isMainFrame", "retryable", "snapshotFailed", "truncated"]); const EXPORT_OMITTED_FIELDS = new Set(["legacyScope", "message"]); const ENUM_STRING_FIELDS: Readonly>> = { + causeCode: new Set(DIAGNOSTIC_CODES), + failurePhase: new Set(["renderer-script", "renderer-promise", "renderer-react", "renderer-route", "mcp-tool-discovery", "provider-request", "provider-compaction"]), + providerCategory: new Set(["authentication", "quota", "rate_limit", "context_window", "context_management", "output_limit", "interrupted", "timeout", "service_unavailable", "model_unavailable", "network", "invalid_request", "unknown"]), arch: new Set(["arm64", "ia32", "universal", "unknown", "x64"]), failureCategory: new Set(["command-failed", "invalid-response", "timed-out"]), origin: new Set(["uncaughtException", "unhandledRejection", "unknown"]), @@ -257,6 +269,9 @@ function normalizedStringField(key: string, value: string): string | undefined { // applied; instead a strict grammar admits only bounded leading-slash paths // of lowercase static segments and `:param` placeholders. if (key === "route") return normalizedDiagnosticRoute(value); + // Main-generated renderer references are opaque UUIDs, not content identifiers. + // Admit the exact UUID grammar before the generic ID redactor removes it. + if (key === "referenceId" && /^RD-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(value)) return value; const sanitized = sanitizeDiagnosticText(value); if (!sanitized) return undefined; const enumerated = ENUM_STRING_FIELDS[key]; @@ -344,6 +359,8 @@ export function normalizeDiagnosticFields(fields: DiagnosticSafeFields | undefin if (typeof value === "string") { const safe = normalizedStringField(key, value); if (safe !== undefined) normalized[key] = safe; + } else if (key === "httpStatus" && validHttpStatus(value)) { + normalized[key] = value; } else if (typeof value === "number" && NUMBER_FIELDS.has(key)) { normalized[key] = boundedNumber(value); } else if (typeof value === "boolean" && BOOLEAN_FIELDS.has(key)) { @@ -418,47 +435,118 @@ export function createDiagnosticEvent( } function errorCode(error: unknown): string | undefined { - if (!error || typeof error !== "object") return undefined; - const candidate = (error as { code?: unknown }).code; - return typeof candidate === "string" ? candidate.toUpperCase() : undefined; + const candidate = diagnosticProperty(error, "code"); + return typeof candidate === "string" && candidate.length <= 64 ? candidate.toUpperCase() : undefined; +} + +/** Never invoke error getters or stringify untrusted error/request payloads. */ +function diagnosticProperty(value: unknown, key: string): unknown { + if (!value || typeof value !== "object" || utilTypes.isProxy(value)) return undefined; + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +} + +function validHttpStatus(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599; +} + +function structuralHttpStatus(error: unknown): number | undefined { + for (const key of ["status", "statusCode"]) { + const value = diagnosticProperty(error, key); + if (validHttpStatus(value)) return value; + } + return undefined; +} + +function diagnosticErrorName(error: unknown): unknown { + if (utilTypes.isProxy(error)) return undefined; + const ownName = diagnosticProperty(error, "name"); + if (ownName !== undefined) return ownName; + try { + if (error instanceof DOMException) { + return Object.getOwnPropertyDescriptor(DOMException.prototype, "name")?.get?.call(error); + } + if (utilTypes.isNativeError(error)) { + let prototype = Object.getPrototypeOf(error); + for (let depth = 0; prototype && depth < 5 && !utilTypes.isProxy(prototype); depth += 1) { + const name = diagnosticProperty(prototype, "name"); + if (name !== undefined) return name; + prototype = Object.getPrototypeOf(prototype); + } + return "Error"; + } + return undefined; + } catch { + return undefined; + } } function diagnosticCodeFor(error: unknown): DiagnosticCode { const code = errorCode(error); + const name = diagnosticErrorName(error); + if (name === "AbortError" || code === "ABORT_ERR") return "cancelled"; + if (name === "TimeoutError") return "timed-out"; if (code === "SUBAGENT_TREE_BUDGET_EXHAUSTED") return "contract-rejected"; if (code === "ENOENT") return "not-found"; if (code === "EACCES" || code === "EPERM") return "permission-denied"; if (code === "ENOSPC" || code === "EDQUOT") return "disk-full"; if (code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT") return "timed-out"; - if (code === "ECONNREFUSED" || code === "ECONNRESET" || code === "ENETUNREACH") { + if (["ECONNREFUSED", "ECONNRESET", "ENETUNREACH", "EHOSTUNREACH", "EAI_AGAIN", "ENOTFOUND", "UND_ERR_SOCKET"].includes(code ?? "")) { return "network-failed"; } - if (error instanceof DOMException && error.name === "AbortError") return "cancelled"; + const status = structuralHttpStatus(error); + if (status === 401 || status === 403) return "authentication-failed"; + if (status === 404) return "not-found"; + if (status === 429) return "rate-limited"; + if (status === 408 || status === 504) return "timed-out"; + if (status !== undefined && status >= 500) return "service-unavailable"; + if (status === 400 || status === 422) return "invalid-request"; return "unknown"; } export function projectDiagnosticError(error: unknown): DiagnosticErrorProjection { - const errorType = normalizeDiagnosticErrorType(error instanceof Error ? error.name : undefined); - const safeFrames = - error instanceof Error - ? (error.stack ?? "") - .split("\n") - .slice(1, 6) - .flatMap((line) => { - const match = /^\s*at\s+([A-Za-z_$][A-Za-z0-9_.$<>-]{0,79})/u.exec(line); - return match?.[1] ? [match[1]] : []; - }) - : []; - const safeSource = [errorType, errorCode(error) ?? "unknown", ...safeFrames].join(":"); + const errorType = normalizeDiagnosticErrorType(diagnosticErrorName(error)); + const chain: unknown[] = []; + let current = error; + while (current && typeof current === "object" && chain.length < 5 && !chain.includes(current)) { + chain.push(current); + current = diagnosticProperty(current, "cause"); + } + const outerCode = diagnosticCodeFor(error); + const classifiedCause = chain.slice(1).find((cause) => diagnosticCodeFor(cause) !== "unknown"); + const causeCode = classifiedCause === undefined ? undefined : diagnosticCodeFor(classifiedCause); + const code = outerCode === "unknown" ? causeCode ?? outerCode : outerCode; + // Keep status evidence on the same envelope as the selected classification. + // A wrapper's unrelated 200 must not accompany its cause's 503 failure. + const source = outerCode === "unknown" && classifiedCause !== undefined ? classifiedCause : error; + const httpStatus = structuralHttpStatus(source); + // Fingerprints group closed causes, not call sites. V8 exposes stack through + // an accessor which may execute custom formatting/name/message getters. + // Never materialize it or hash arbitrary payload-derived data. + const safeSource = [errorType, code, causeCode ?? "unknown", httpStatus ?? "unknown"].join(":"); return { - code: diagnosticCodeFor(error), + code, errorType, + ...(causeCode ? { causeCode } : {}), + ...(httpStatus === undefined ? {} : { httpStatus }), ...(safeSource ? { fingerprint: createHash("sha256").update(safeSource).digest("hex").slice(0, 16) } : {}), }; } +export function rendererDiagnosticClassification( + errorType: string, + suppressed: number = 0, +): Pick { + if (errorType === "AbortError") return { code: "cancelled", outcome: "cancelled" }; + return { code: "renderer-exception", outcome: suppressed ? "degraded" : "failed" }; +} + export function diagnosticEventLine(event: DiagnosticEventV1): string { return `${JSON.stringify(event)}\n`; } diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index bdbc9c326..08dd4093d 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -313,8 +313,10 @@ import { AskUserQuestionCoordinator } from "./ask-user-question-coordinator.js"; import { ASK_USER_QUESTION_TOOL_NAME } from "../../renderer/shared/ask-user-question.js"; import { createTodoExtension, shouldEnableTodoExtension } from "./rpiv-todo/extension.js"; import { TODO_TOOL_NAME } from "./rpiv-todo/contract.js"; -import { isTodoSnapshotFailure, replayTodoState } from "./rpiv-todo/replay.js"; -import { todoSnapshotForRenderer, unavailableTodoSnapshot } from "../../renderer/shared/todo.js"; +import { loadDurableTodoSnapshot } from "./rpiv-todo/snapshot.js"; +import { writeDiagnosticEvent } from "./diagnostic-journal.js"; +import { todoSnapshotDiagnostic } from "./rpiv-todo/diagnostics.js"; +import { todoSnapshotForRenderer } from "../../renderer/shared/todo.js"; subagentRuntimeRegistry.setHealthMetrics(subagentHealthMetrics); subagentRuntimeRegistry.setRuntimeFaultReporter((source) => { @@ -1890,8 +1892,12 @@ export const llmClient = { excluded: options.excludeToolNames?.has(TODO_TOOL_NAME) ?? false, }) ) { - try { - const todoState = await replayTodoState(piSession); + const todo = await loadDurableTodoSnapshot( + params.chatId, + piJournalless ? undefined : piSession, + ); + if (todo.state) { + const todoState = todo.state; const publishTodo = (state: typeof todoState) => { sendGeneration(streamId, "chat:todo", { streamId, @@ -1901,18 +1907,10 @@ export const llmClient = { generationExtensions.push( createTodoExtension(todoState, { onDurableSnapshot: publishTodo }), ); - publishTodo(todoState); - } catch (error) { - if (!isTodoSnapshotFailure(error)) throw error; - // Never log task content or fall back past a corrupt newer snapshot. - // The ordinary chat remains usable, but todo stays unavailable until - // its private journal is repaired or the chat is deleted. - sendGeneration(streamId, "chat:todo", { - streamId, - snapshot: unavailableTodoSnapshot(params.chatId), - }); - logger.warn("pi", `Disabled todo for chat ${params.chatId}: invalid durable snapshot.`); } + const todoDiagnostic = todoSnapshotDiagnostic(todo.snapshot); + if (todoDiagnostic) writeDiagnosticEvent(todoDiagnostic); + sendGeneration(streamId, "chat:todo", { streamId, snapshot: todo.snapshot }); } const runtimeExtensionSnapshot = piAgentRuntimeExtensions.snapshotWithRevision(); // Runtime extensions are not yet represented in the exact Bot catalog. @@ -3125,13 +3123,6 @@ export const llmClient = { : runtimeOutcome.kind === "host_failed" ? "The local agent runtime could not complete this response safely." : null); - if (runtimeOutcome.kind === "provider_failed") { - logger.warn("pi", `Provider generation failed for stream ${streamId}.`, { - category: runtimeOutcome.providerFailure?.category ?? "unknown", - attempts: runtimeOutcome.attempts, - retryExhausted: runtimeOutcome.providerFailure?.retryExhausted ?? false, - }); - } if (finalError) { const finalTimeline = attachClaimCheck(timeline.finish("failed"), full); const persisted = await persistAssistant( diff --git a/main/services/mcp.ts b/main/services/mcp.ts index 611349f36..86cf7ffa6 100644 --- a/main/services/mcp.ts +++ b/main/services/mcp.ts @@ -8,7 +8,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Type } from "@earendil-works/pi-ai"; import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; -import { logger } from "../platform.js"; +import { projectDiagnosticError } from "./diagnostics-contract.js"; +import { writeDiagnosticEvent } from "./diagnostic-journal.js"; import { oauthProviderFor } from "./mcp-oauth.js"; import { mcpApiKeyHeaderValue } from "./mcp-oauth-client-metadata.js"; import { @@ -434,10 +435,21 @@ export async function collectMcpAgentTools( }`, ); } - logger.warn( - "mcp", - `Skipping MCP server "${server.name}": ${error instanceof Error ? error.message : String(error)}`, - ); + const projected = projectDiagnosticError(error); + writeDiagnosticEvent({ + level: "warn", + area: "mcp", + event: "mcp-degraded", + outcome: projected.code === "cancelled" ? "cancelled" : "degraded", + code: projected.code, + fields: { + errorType: projected.errorType, + ...(projected.fingerprint ? { fingerprint: projected.fingerprint } : {}), + ...(projected.causeCode ? { causeCode: projected.causeCode } : {}), + ...(projected.httpStatus === undefined ? {} : { httpStatus: projected.httpStatus }), + failurePhase: "mcp-tool-discovery", + }, + }); } } if (options.strict && servers.length > 0 && all.length === 0) { diff --git a/main/services/pi-agent-runtime-harness.test.ts b/main/services/pi-agent-runtime-harness.test.ts index 41df7400e..c06457751 100644 --- a/main/services/pi-agent-runtime-harness.test.ts +++ b/main/services/pi-agent-runtime-harness.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai"; @@ -34,6 +34,7 @@ import { markPiRuntimePrivateFailure } from "./pi-runtime-failure.js"; import { declarePiRuntimeReplay } from "./pi-runtime-tool.js"; import { createGenerationContextTransform } from "./generation-context.js"; import { createPiSessionPort, type PiSessionPort } from "./pi-session-port.js"; +import { flushDiagnosticJournal, initDiagnosticJournal } from "./diagnostic-journal.js"; function testHarness( responses: Parameters["setResponses"]>[0], @@ -2717,3 +2718,26 @@ test("disclosed browser tools remain executable and budgeted after managed provi const journal = await session.buildContext(); assert.equal(journal.messages.filter((message) => message.role === "toolResult" && message.toolName === "browser_status").length, 2); }); + +test("provider diagnostics classify before outcome redaction without exporting the raw error", async (t) => { + const root = await mkdtemp(join(tmpdir(), "aiden-provider-diagnostics-")); + t.after(async () => { await flushDiagnosticJournal(); await rm(root, { recursive: true, force: true }); }); + const target = join(root, "aiden.log"); + initDiagnosticJournal({ targetPath: target, profile: "production", sessionId: "session-test" }); + const { harness } = await managedTestHarness([ + fauxAssistantMessage("", { stopReason: "error", errorMessage: "model not found PRIVATE_MODEL_CANARY" }), + ]); + const outcome = await harness.runManaged({ kind: "append-and-run", message: { + role: "user", content: "PRIVATE_PROMPT_CANARY", timestamp: 1, + } }); + assert.equal(outcome.kind, "provider_failed"); + assert.doesNotMatch(JSON.stringify(outcome), /PRIVATE_MODEL_CANARY/u); + await flushDiagnosticJournal(); + const bytes = await readFile(target, "utf8"); + const entries = bytes.trim().split("\n").map((line) => JSON.parse(line)); + const failures = entries.filter((entry) => entry.event === "provider-failed"); + assert.equal(failures.length, 1); + assert.equal(failures[0].fields.providerCategory, "model_unavailable"); + assert.equal(failures[0].fields.failurePhase, "provider-request"); + assert.doesNotMatch(bytes, /PRIVATE_MODEL_CANARY|PRIVATE_PROMPT_CANARY|errorMessage/u); +}); diff --git a/main/services/pi-agent-runtime-harness.ts b/main/services/pi-agent-runtime-harness.ts index 7c69a20e1..d5be93c39 100644 --- a/main/services/pi-agent-runtime-harness.ts +++ b/main/services/pi-agent-runtime-harness.ts @@ -46,7 +46,8 @@ import { import type { PiRuntimeEffectStore } from "./pi-runtime-effect-store.js"; import { piRuntimePrivateFailure } from "./pi-runtime-failure.js"; import { piRuntimeReplayPolicy } from "./pi-runtime-tool.js"; -import { providerFailureFromTerminalOutcome } from "./provider-failure.js"; +import { providerFailureDiagnosticFields, providerFailureFromTerminalOutcome } from "./provider-failure.js"; +import { writeDiagnosticEvent } from "./diagnostic-journal.js"; import type { ProviderFailureV1 } from "../../renderer/shared/provider-failure.js"; import { projectNextContextUsage, @@ -1586,6 +1587,15 @@ export class PiAgentRuntimeHarness { finalized.kind === "provider_failed" ? providerFailureFromTerminalOutcome(finalized) : undefined; + if (finalized.kind === "provider_failed") { + // Classify before closing the raw provider message; only closed fields + // reach the journal and all consumers retain the redacted outcome. + writeDiagnosticEvent({ + level: "warn", area: "generation", event: "provider-failed", + outcome: "failed", code: "provider-failed", + fields: providerFailureDiagnosticFields(finalized), + }); + } const closed = finalized.kind === "completed" || !finalized.finalMessage ? providerFailure diff --git a/main/services/pi-upgrade-evaluation.test.ts b/main/services/pi-upgrade-evaluation.test.ts index deaf7ee7e..78938aad6 100644 --- a/main/services/pi-upgrade-evaluation.test.ts +++ b/main/services/pi-upgrade-evaluation.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; @@ -159,6 +159,8 @@ test("two rollout-store instances cannot regress device state from a stale cache "2026-08-31T00:00:00.000Z", ); assert.equal((await first.advance("developer_installs")).revision, 2); + assert.deepEqual(await stale.load(), await first.load()); + assert.equal((await stale.load()).stage, "developer_installs"); await assert.rejects( stale.advance("developer_installs"), /advance exactly one stage from current device state/u, @@ -166,6 +168,148 @@ test("two rollout-store instances cannot regress device state from a stale cache assert.equal(JSON.parse(await readFile(path.join(root, "pi-upgrade-rollout-v1.json"), "utf8")).revision, 2); }); +test("rollout reads reject invalid replacements and recover without rewriting device data", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-rollout-reload-")); + t.after(() => rm(root, { recursive: true, force: true })); + const store = new PiUpgradeRolloutStore({ root: () => root, initialStage: "new_chats", now: () => 100 }); + const initial = await store.load(); + const file = path.join(root, "pi-upgrade-rollout-v1.json"); + const original = await readFile(file, "utf8"); + const replace = async (bytes: string) => { + const staging = path.join(root, "operator-replacement.tmp"); + await writeFile(staging, bytes, { mode: 0o600 }); + await rename(staging, file); + }; + for (const invalid of ["{", JSON.stringify({ ...initial, unexpected: true }), JSON.stringify({ ...initial, revision: 0 })]) { + await replace(invalid); + await assert.rejects(store.load()); + await assert.rejects(store.load()); + assert.equal(await readFile(file, "utf8"), invalid); + await replace(original); + assert.deepEqual(await store.load(), initial); + assert.equal(await readFile(file, "utf8"), original); + } + // Losing a previously observed policy must not silently reinitialize it. + await rm(file); + await assert.rejects(store.load(), { code: "ENOENT" }); + await assert.rejects(readFile(file), { code: "ENOENT" }); + await replace(original); + assert.deepEqual(await store.load(), initial); + + // A failed first read must not poison this instance's subsequent reads either. + await replace("{"); + const recovering = new PiUpgradeRolloutStore({ root: () => root, initialStage: "internal_fixtures" }); + await assert.rejects(recovering.load()); + await replace(original); + assert.deepEqual(await recovering.load(), initial); + assert.deepEqual(await readdir(root), ["pi-upgrade-rollout-v1.json"]); +}); + +test("an overlapping first load cannot recreate a policy deleted after the first read", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-rollout-overlap-")); + t.after(() => rm(root, { recursive: true, force: true })); + let calls = 0; + let release!: () => void; + let arrived!: () => void; + const blocked = new Promise((resolve) => { release = resolve; }); + const secondArrived = new Promise((resolve) => { arrived = resolve; }); + const store = new PiUpgradeRolloutStore({ + root: async () => { + if (++calls === 2) { arrived(); await blocked; } + return root; + }, + initialStage: "new_chats", + }); + const first = store.load(); + const second = store.load(); + const rejected = assert.rejects(second, { code: "ENOENT" }); + await first; + await secondArrived; + await rm(path.join(root, "pi-upgrade-rollout-v1.json")); + release(); + await rejected; + assert.deepEqual(await readdir(root), []); +}); + +test("failed advancement still records observation before a waiting first load can recreate policy", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-observed-")); + t.after(() => rm(root, { recursive: true, force: true })); + const file = path.join(root, "pi-upgrade-rollout-v1.json"); + await writeFile(file, JSON.stringify({ version: 1, stage: "existing_long_chats", activatedAt: 100, revision: 7 })); + let release!: () => void; + let arrived!: () => void; + const blocked = new Promise((resolve) => { release = resolve; }); + const started = new Promise((resolve) => { arrived = resolve; }); + let calls = 0; + const store = new PiUpgradeRolloutStore({ initialStage: "new_chats", root: async () => { + if (++calls === 1) { arrived(); await blocked; } + return root; + } }); + const waiting = store.load(); + const rejection = assert.rejects(waiting, { code: "ENOENT" }); + await started; + await assert.rejects(store.advance("v4_only"), { code: "ENOENT" }); + await rm(file); + release(); + await rejection; + await assert.rejects(store.load(), { code: "ENOENT" }); + assert.deepEqual(await readdir(root), []); +}); + +test("concurrent rollout initialization publishes one complete policy without losing the winner", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-rollout-create-")); + t.after(() => rm(root, { recursive: true, force: true })); + const stores = Array.from({ length: 24 }, (_, index) => new PiUpgradeRolloutStore({ + root: () => root, + initialStage: index % 2 === 0 ? "new_chats" : "internal_fixtures", + now: () => 100 + index, + })); + const results = await Promise.all(stores.flatMap((store) => [store.load(), store.load()])); + const file = path.join(root, "pi-upgrade-rollout-v1.json"); + const bytes = await readFile(file, "utf8"); + const winner = JSON.parse(bytes); + for (const result of results) assert.deepEqual(result, winner); + assert.equal(winner.revision, 1); + assert.equal((await stat(file)).mode & 0o777, 0o600); + for (const store of stores) assert.deepEqual(await store.load(), winner); + assert.equal(await readFile(file, "utf8"), bytes); + assert.deepEqual(await readdir(root), ["pi-upgrade-rollout-v1.json"]); +}); + +test("external rollout advancement preserves the activated new-chat cohort on subsequent reads", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-rollout-cutoff-")); + t.after(() => rm(root, { recursive: true, force: true })); + let now = 100; + const options = { root: () => root, initialStage: "developer_installs" as const, now: () => now }; + const app = new PiUpgradeRolloutStore(options); + const operator = new PiUpgradeRolloutStore(options); + await app.load(); + await writePiUpgradeEvaluationReceipt(root, await passingMeasurements(), { + packageSha256: "d".repeat(64), buildId: "cohort-test", + }); + now = 200; + await operator.advance("new_chats"); + const newChats = await app.load(); + assert.equal(newChats.activatedAt, 200); + const longChat = { createdAt: 250, messages: Array.from({ length: 150 }, () => ({})) } as never; + const oldLongChat = { createdAt: 150, messages: Array.from({ length: 150 }, () => ({})) } as never; + assert.equal(piUpgradeMemoryEligible(newChats, longChat, { development: false }), true); + now = 300; + await operator.advance("migrated_low_risk_chats"); + const migrated = await app.load(); + assert.equal(migrated.stage, "migrated_low_risk_chats"); + assert.equal(migrated.revision, 3); + assert.equal(migrated.activatedAt, 200); + assert.equal(piUpgradeMemoryEligible(migrated, longChat, { development: false }), true); + assert.equal(piUpgradeMemoryEligible(migrated, oldLongChat, { development: false }), false); + now = 400; + await app.advance("existing_long_chats"); + const latest = await operator.load(); + assert.equal(latest.revision, 4); + assert.equal(latest.activatedAt, 200); + assert.equal(piUpgradeMemoryEligible(latest, oldLongChat, { development: false }), true); +}); + test("crashed rollout locks recover and installed identity includes app resources", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "aiden-pi-rollout-stale-lock-")); t.after(() => rm(root, { recursive: true, force: true })); diff --git a/main/services/pi-upgrade-rollout.ts b/main/services/pi-upgrade-rollout.ts index 1e430465b..48d86474e 100644 --- a/main/services/pi-upgrade-rollout.ts +++ b/main/services/pi-upgrade-rollout.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { createReadStream } from "node:fs"; -import { chmod, lstat, mkdir, open, readFile, readdir, readlink, rename, rm, unlink } from "node:fs/promises"; +import { chmod, link, lstat, mkdir, open, readFile, readdir, readlink, rename, rm, unlink } from "node:fs/promises"; import path from "node:path"; import type { Chat } from "./types.js"; import { @@ -173,7 +173,7 @@ async function privateJson(file: string): Promise { } finally { await handle.close(); } } -async function atomicPrivateJson(root: string, file: string, value: unknown): Promise { +async function atomicPrivateJson(root: string, file: string, value: unknown, exclusive = false): Promise { const staging = path.join(root, `.pi-upgrade.${randomUUID()}.tmp`); try { const handle = await open(staging, "wx", 0o600); @@ -181,7 +181,13 @@ async function atomicPrivateJson(root: string, file: string, value: unknown): Pr await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8"); await handle.sync(); } finally { await handle.close(); } - await rename(staging, file); + if (exclusive) { + // Publish complete bytes without replacing a competing creator's policy. + await link(staging, file); + await unlink(staging); + } else { + await rename(staging, file); + } await chmod(file, 0o600); const directory = await open(root, "r"); try { await directory.sync(); } finally { await directory.close(); } @@ -282,7 +288,8 @@ export async function installedApplicationIdentity( } export class PiUpgradeRolloutStore { - private loaded?: Promise; + private hasLoaded = false; + private loadTail: Promise = Promise.resolve(); constructor(private readonly options: { root(): string | Promise; initialStage: PiUpgradeRolloutStage; @@ -306,28 +313,33 @@ export class PiUpgradeRolloutStore { try { const parsed = parseDocument(await privateJson(paths.policy)); if (!parsed) throw new Error("The Pi upgrade rollout document is invalid."); + this.hasLoaded = true; return parsed; } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !create) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !create || this.hasLoaded) throw error; const initial = { version: 1 as const, stage: this.options.initialStage, activatedAt: this.now(), revision: 1 }; - let handle; try { - handle = await open(paths.policy, "wx", 0o600); + await atomicPrivateJson(paths.root, paths.policy, initial, true); } catch (createError) { if ((createError as NodeJS.ErrnoException).code === "EEXIST") return this.readCurrent(false); throw createError; } - try { - await handle.writeFile(`${JSON.stringify(initial, null, 2)}\n`, "utf8"); - await handle.sync(); - } finally { await handle.close(); } return initial; } } - async load(): Promise { - this.loaded ??= this.readCurrent(true); - return this.loaded; + load(): Promise { + // Policy is operator-controlled: every read must observe and validate disk, + // including replacements after an earlier successful read or failed read. + // Serialize first reads so an overlapping load cannot retain creation + // permission after another load has observed the policy successfully. + const pending = this.loadTail.then(async () => { + const current = await this.readCurrent(!this.hasLoaded); + this.hasLoaded = true; + return current; + }); + this.loadTail = pending.then(() => undefined, () => undefined); + return pending; } async advance(target: PiUpgradeRolloutStage): Promise { @@ -355,9 +367,14 @@ export class PiUpgradeRolloutStore { installed.buildId !== identity.buildId || installed.evaluationSha256 !== evaluationSha256 ) throw new Error("V4-only rollout requires receipts bound to this installed build and evaluation."); } - const next = { version: 1 as const, stage: target, activatedAt: this.now(), revision: current.revision + 1 }; + const next = { + version: 1 as const, stage: target, + // Establish the new-chat cohort once; later stages only widen it. + activatedAt: target === "new_chats" ? this.now() : current.activatedAt, + revision: current.revision + 1, + }; await atomicPrivateJson(paths.root, paths.policy, next); - this.loaded = Promise.resolve(next); + this.hasLoaded = true; return next; } finally { await releaseLock(); } } diff --git a/main/services/provider-failure.test.ts b/main/services/provider-failure.test.ts index c1654927f..a6019584b 100644 --- a/main/services/provider-failure.test.ts +++ b/main/services/provider-failure.test.ts @@ -5,6 +5,7 @@ import { providerFailureFromTerminalOutcome, providerFailureFromLegacyPiMessage, providerFailureChatMetadata, + providerFailureDiagnosticFields, type ProviderFailureReason, } from "./provider-failure.js"; import { @@ -14,6 +15,25 @@ import { const PRIVATE_CANARY = "PRIVATE_PROVIDER_DETAIL_7dbfe9"; +test("main-only provider diagnostics distinguish model availability without persisting provider text", () => { + const outcome = { kind: "provider_failed" as const, reason: "request-failed" as const, attempts: 2, + finalMessage: { errorMessage: `404 model private-model not found ${PRIVATE_CANARY} Bearer private-auth https://private-endpoint prompt=private-task` } }; + const fields = providerFailureDiagnosticFields(outcome); + assert.equal(fields.providerCategory, "model_unavailable"); + assert.equal(fields.failurePhase, "provider-request"); + assert.equal(fields.httpStatus, undefined); + assert.equal(providerFailureFromTerminalOutcome(outcome).category, "invalid_request"); + assert.doesNotMatch(JSON.stringify(fields), /PRIVATE_PROVIDER|private-/); + const changedText = providerFailureDiagnosticFields({ ...outcome, finalMessage: { errorMessage: "model another-model not found" } }); + assert.equal(changedText.fingerprint, fields.fingerprint); + const invalid = providerFailureDiagnosticFields({ ...outcome, finalMessage: { errorMessage: "400 invalid_request private" } }); + assert.equal(invalid.providerCategory, "invalid_request"); + assert.notEqual(invalid.fingerprint, fields.fingerprint); + assert.equal(providerFailureDiagnosticFields({ ...outcome, finalMessage: undefined }).providerCategory, "unknown"); + assert.equal(providerFailureDiagnosticFields({ ...outcome, reason: "interrupted" }).providerCategory, "interrupted"); + assert.equal(providerFailureDiagnosticFields({ ...outcome, reason: "compaction-failed" }).failurePhase, "provider-compaction"); +}); + function classify( reason: ProviderFailureReason, errorMessage?: string, diff --git a/main/services/provider-failure.ts b/main/services/provider-failure.ts index 6f09e0e67..aff7e33f7 100644 --- a/main/services/provider-failure.ts +++ b/main/services/provider-failure.ts @@ -3,6 +3,8 @@ import { PROVIDER_FAILURE_VERSION, type ProviderFailureV1, } from "../../renderer/shared/provider-failure.js"; +import { createHash } from "node:crypto"; +import type { DiagnosticSafeFields } from "./diagnostics-contract.js"; export type ProviderFailureReason = | "request-failed" @@ -98,6 +100,25 @@ export function providerFailureChatMetadata( return { providerFailure: providerFailureFromTerminalOutcome(outcome) }; } +/** Main-only: call before outcome redaction. Message-derived categories are hints, never HTTP evidence. */ +export function providerFailureDiagnosticFields( + outcome: ProviderFailedTerminalOutcome, +): DiagnosticSafeFields { + const failure = providerFailureFromTerminalOutcome(outcome); + const message = outcome.finalMessage?.errorMessage; + const providerCategory = failure.category === "invalid_request" && + typeof message === "string" && MODEL_UNAVAILABLE.test(message) + ? "model_unavailable" : failure.category; + const failurePhase = outcome.reason === "compaction-failed" ? "provider-compaction" : "provider-request"; + // Fingerprint only closed metadata, never low-entropy provider/request text. + return { + providerCategory, + failurePhase, + attempts: failure.attempts, + fingerprint: createHash("sha256").update(`${failurePhase}:${providerCategory}`).digest("hex").slice(0, 16), + }; +} + const CLOSED_NON_PROVIDER_ERRORS = new Set([ "The app cancelled the model operation.", "The local agent runtime failed.", diff --git a/main/services/rpiv-todo/diagnostics.test.ts b/main/services/rpiv-todo/diagnostics.test.ts new file mode 100644 index 000000000..9428ac519 --- /dev/null +++ b/main/services/rpiv-todo/diagnostics.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { todoSnapshotDiagnostic } from "./diagnostics.js"; +import { createDiagnosticEvent } from "../diagnostics-contract.js"; + +test("task diagnostics distinguish storage policy from invalid snapshots without content", () => { + for (const reason of ["storage_not_enabled", "invalid_snapshot"] as const) { + const diagnostic = todoSnapshotDiagnostic({ + version: 1, chatId: "private-chat", availability: "unavailable", + unavailableReason: reason, tasks: [], + }); + assert.ok(diagnostic); + const event = createDiagnosticEvent(diagnostic, "session-test"); + assert.equal(event.event, reason === "storage_not_enabled" ? "todo-storage-disabled" : "todo-snapshot-invalid"); + assert.equal(event.outcome, reason === "storage_not_enabled" ? undefined : "failed"); + assert.doesNotMatch(JSON.stringify(event), /private-chat|tasks|chatId/u); + } + assert.equal(todoSnapshotDiagnostic({ + version: 1, chatId: "private-chat", availability: "ready", tasks: [], + }), undefined); + assert.equal(todoSnapshotDiagnostic({ + version: 1, chatId: "private-chat", availability: "unavailable", tasks: [], + }), undefined); +}); diff --git a/main/services/rpiv-todo/diagnostics.ts b/main/services/rpiv-todo/diagnostics.ts new file mode 100644 index 000000000..9f425fdc9 --- /dev/null +++ b/main/services/rpiv-todo/diagnostics.ts @@ -0,0 +1,12 @@ +import type { TodoSnapshotViewV1 } from "../../../renderer/shared/todo.js"; +import type { DiagnosticEventInput } from "../diagnostics-contract.js"; + +/** Closed availability evidence shared by reads and generations, without chat or task data. */ +export function todoSnapshotDiagnostic(snapshot: TodoSnapshotViewV1): DiagnosticEventInput | undefined { + if (snapshot.availability !== "unavailable") return undefined; + if (snapshot.unavailableReason === "storage_not_enabled") { + return { level: "info", area: "generation", event: "todo-storage-disabled" }; + } + if (snapshot.unavailableReason !== "invalid_snapshot") return undefined; + return { level: "warn", area: "generation", event: "todo-snapshot-invalid", outcome: "failed", code: "corrupt-data" }; +} diff --git a/main/services/rpiv-todo/extension.ts b/main/services/rpiv-todo/extension.ts index 0992370a1..6a8f5aede 100644 --- a/main/services/rpiv-todo/extension.ts +++ b/main/services/rpiv-todo/extension.ts @@ -11,6 +11,7 @@ import { TODO_EXTENSION_ID, TODO_TOOL_NAME, cloneTodoState, + parseTodoToolDetails, type TodoParams, type TodoState, type TodoToolDetailsV1, @@ -104,8 +105,10 @@ export function createTodoExtensionRuntime( ): Promise> => { if (signal?.aborted) throw new Error("Todo operation was cancelled."); const result = applyTodo(state, parameters as TodoParams); + // Enforce the reader contract before state changes or a successful result is journaled. + const details = parseTodoToolDetails(result.details); state = result.state; - return { content: [{ type: "text", text: result.content }], details: result.details }; + return { content: [{ type: "text", text: result.content }], details }; }, }, // State exists only inside this generation until the result is journaled. diff --git a/main/services/rpiv-todo/llm-integration.test.ts b/main/services/rpiv-todo/llm-integration.test.ts index 9ebb8ac9a..dabac73d0 100644 --- a/main/services/rpiv-todo/llm-integration.test.ts +++ b/main/services/rpiv-todo/llm-integration.test.ts @@ -12,9 +12,11 @@ test("llm todo admission fails closed without an explicit chat usage source", () assert.doesNotMatch(admission, /\?\?\s*["']chat["']/u); }); -test("corrupt todo replay immediately publishes only a content-free unavailable projection", () => { +test("todo generation uses only durable sessions and publishes the same snapshot as chat-open", () => { assert.match( source, - /if \(!isTodoSnapshotFailure\(error\)\) throw error;[\s\S]*?sendGeneration\(streamId, "chat:todo", \{[\s\S]*?snapshot: unavailableTodoSnapshot\(params\.chatId\),[\s\S]*?\}\);/u, + /loadDurableTodoSnapshot\([\s\S]*?piJournalless \? undefined : piSession/u, ); + assert.match(source, /if \(todo\.state\) \{[\s\S]*?createTodoExtension/u); + assert.match(source, /sendGeneration\(streamId, "chat:todo", \{ streamId, snapshot: todo\.snapshot \}\)/u); }); diff --git a/main/services/rpiv-todo/replay.test.ts b/main/services/rpiv-todo/replay.test.ts index 07a2eb9a9..531c47c52 100644 --- a/main/services/rpiv-todo/replay.test.ts +++ b/main/services/rpiv-todo/replay.test.ts @@ -83,3 +83,77 @@ test("replay uses only the session-provided current branch", async () => { assert.equal(state.tasks[0]?.subject, "Current"); assert.notDeepEqual(state.tasks, (abandoned.message.details as TodoToolDetailsV1).tasks); }); + +test("only the last checkpoint is authoritative across adversarial snapshot orderings", async () => { + const valid = result(details("Older", 8)); + const invalid = result({ tasks: "corrupt" }); + const newest = result(details("Authoritative", 3)); + const error = { ...invalid, message: { ...invalid.message, isError: true } }; + const unrelated = { + type: "message", + message: { role: "toolResult", toolName: "other", details: {} }, + }; + for (const branch of [ + [invalid, newest], + [valid, invalid, newest], + [invalid, valid, invalid, newest], + [invalid, error, unrelated, valid, invalid, newest, error, unrelated], + ]) { + const state = await replayTodoState({ getBranch: async () => branch }); + assert.deepEqual(state, { + tasks: [{ id: 2, subject: "Authoritative", status: "pending" }], + nextId: 3, + }); + } + for (const branch of [ + [invalid], + [invalid, valid, invalid], + [valid, invalid, error, unrelated], + [valid, result(undefined), error], + [valid, result(null), unrelated], + ]) { + await assert.rejects(replayTodoState({ getBranch: async () => branch }), TodoSnapshotError); + } +}); + +test("a newer full empty checkpoint supersedes corrupt and populated snapshots", async () => { + const empty = { ...details("Unused"), action: "clear", tasks: [], nextId: 1 }; + assert.deepEqual(await replayTodoState({ + getBranch: async () => [result(details("Old")), result({}), result(empty)], + }), { tasks: [], nextId: 1 }); +}); + +test("error-only and non-checkpoint branches initialize empty state", async () => { + const checkpoint = result(details("Ignored")); + assert.deepEqual(await replayTodoState({ + getBranch: async () => [ + null, + [], + { type: "compaction", message: checkpoint.message }, + { type: "message", message: { ...checkpoint.message, role: "assistant" } }, + { type: "message", message: { ...checkpoint.message, isError: true } }, + { type: "message", message: { ...checkpoint.message, isError: true, details: {} } }, + ], + }), { tasks: [], nextId: 1 }); +}); + +test("branch read failures propagate unchanged", async () => { + const failure = new Error("journal read failed"); + await assert.rejects(replayTodoState({ + getBranch: async () => { throw failure; }, + }), (error) => error === failure); +}); + +test("iterator failures propagate before interpreting any candidate checkpoint", async () => { + for (const candidate of [undefined, result(details("Valid")), result({})]) { + const failure = new Error("journal iteration failed"); + await assert.rejects(replayTodoState({ + getBranch: async () => ({ + *[Symbol.iterator]() { + if (candidate) yield candidate; + throw failure; + }, + }), + }), (error) => error === failure); + } +}); diff --git a/main/services/rpiv-todo/replay.ts b/main/services/rpiv-todo/replay.ts index 00383bf29..3421b6168 100644 --- a/main/services/rpiv-todo/replay.ts +++ b/main/services/rpiv-todo/replay.ts @@ -17,12 +17,13 @@ function record(value: unknown): Record | undefined { } /** - * Rebuild from the current Pi branch only. Once a todo tool result is found it - * must be a fully valid Aiden snapshot; a malformed newer result never falls - * back to older state because that could silently regress completed work. + * Rebuild from the current Pi branch only, in oldest-to-newest branch order. + * Every checkpoint is a full snapshot, so validate only the newest non-error + * todo result. A malformed newest result never falls back to older state + * because that could silently regress completed work. */ export async function replayTodoState(session: TodoReplaySession): Promise { - let latest: TodoState | undefined; + let latest: Record | undefined; for (const entryValue of await session.getBranch()) { const entry = record(entryValue); if (entry?.type !== "message") continue; @@ -32,10 +33,11 @@ export async function replayTodoState(session: TodoReplaySession): Promise { + for (let turn = 0; turn < 2; turn += 1) { + const result = await loadDurableTodoSnapshot("chat", undefined); + assert.equal(result.state, undefined); + assert.deepEqual(result.snapshot, { + version: 1, chatId: "chat", availability: "unavailable", + unavailableReason: "storage_not_enabled", tasks: [], + }); + } +}); + +test("durable empty storage can admit tasks and replay them on the next turn", async () => { + const branch: unknown[] = []; + const session = { getBranch: async () => branch }; + const initial = await loadDurableTodoSnapshot("chat", session); + assert.equal(initial.snapshot.availability, "ready"); + assert.ok(initial.state); + const tool = createTodoExtension(initial.state).tools![0]!; + const result = await tool.execute("call", { action: "create", subject: "Persist work" }); + branch.push({ type: "message", message: { role: "toolResult", toolName: "todo", details: result.details } }); + const resumed = await loadDurableTodoSnapshot("chat", session); + assert.equal(resumed.snapshot.tasks[0]?.subject, "Persist work"); + assert.equal(resumed.state?.nextId, 2); +}); + +test("invalid newest snapshots stay empty and distinct from missing storage", async () => { + const result = await loadDurableTodoSnapshot("chat", { + getBranch: async () => [{ type: "message", message: { + role: "toolResult", toolName: "todo", details: { private: "never export" }, + } }], + }); + assert.equal(result.state, undefined); + assert.equal(result.snapshot.unavailableReason, "invalid_snapshot"); + assert.deepEqual(result.snapshot.tasks, []); + assert.doesNotMatch(JSON.stringify(result), /private|never export/u); +}); + +test("storage read errors propagate rather than being reported as snapshot corruption", async () => { + const error = new Error("read failure"); + await assert.rejects(loadDurableTodoSnapshot("chat", { + getBranch: async () => { throw error; }, + }), (actual) => actual === error); +}); diff --git a/main/services/rpiv-todo/snapshot.ts b/main/services/rpiv-todo/snapshot.ts new file mode 100644 index 000000000..ac1a5d881 --- /dev/null +++ b/main/services/rpiv-todo/snapshot.ts @@ -0,0 +1,24 @@ +import { + todoSnapshotForRenderer, + unavailableTodoSnapshot, + type TodoSnapshotViewV1, +} from "../../../renderer/shared/todo.js"; +import type { TodoState } from "./contract.js"; +import { isTodoSnapshotFailure, replayTodoState, type TodoReplaySession } from "./replay.js"; + +/** Shared by chat-open reads and generation admission; in-memory sessions are not durable. */ +export async function loadDurableTodoSnapshot( + chatId: string, + durableSession: TodoReplaySession | undefined, +): Promise<{ snapshot: TodoSnapshotViewV1; state?: TodoState }> { + if (!durableSession) { + return { snapshot: unavailableTodoSnapshot(chatId, "storage_not_enabled") }; + } + try { + const state = await replayTodoState(durableSession); + return { state, snapshot: todoSnapshotForRenderer(chatId, state) }; + } catch (error) { + if (!isTodoSnapshotFailure(error)) throw error; + return { snapshot: unavailableTodoSnapshot(chatId, "invalid_snapshot") }; + } +} diff --git a/renderer/components/settings/diagnostics-settings.test.tsx b/renderer/components/settings/diagnostics-settings.test.tsx index 8a552d337..04f7dfabf 100644 --- a/renderer/components/settings/diagnostics-settings.test.tsx +++ b/renderer/components/settings/diagnostics-settings.test.tsx @@ -40,5 +40,9 @@ test("diagnostic IPC surface does not retain the arbitrary devlog writer", () => const index = source("main/handlers/index.ts"); assert.match(handlers, /parseRendererReport/u); assert.match(handlers, /uploadToServer: false/u); + assert.doesNotMatch(handlers, /renderer-crashed/u); + assert.match(handlers, /rendererDiagnosticClassification\(report\.errorType, report\.suppressed\)/u); + assert.match(handlers, /failurePhase:/u); + assert.match(source("main/index.ts"), /code: "renderer-crashed"/u); assert.doesNotMatch(index, /devlog:write|String\(message\)/u); }); diff --git a/renderer/components/todo-panel.test.tsx b/renderer/components/todo-panel.test.tsx index bcfab157f..a0840862e 100644 --- a/renderer/components/todo-panel.test.tsx +++ b/renderer/components/todo-panel.test.tsx @@ -5,6 +5,16 @@ import { renderToStaticMarkup } from "react-dom/server"; import { TodoPanel } from "./todo-panel.js"; const source = readFileSync(new URL("./todo-panel.tsx", import.meta.url), "utf8"); + +test("storage-disabled tracking explains availability without suggesting corrupt history", () => { + const html = renderToStaticMarkup(); + assert.match(html, /Task tracking not enabled/u); + assert.match(html, /cannot save or update a task list here/u); + assert.doesNotMatch(html, /could not verify|older snapshot|Tasks unavailable/u); +}); const chatPaneSource = readFileSync(new URL("../main/chat-pane.tsx", import.meta.url), "utf8"); const uiSource = readFileSync(new URL("./ui.tsx", import.meta.url), "utf8"); diff --git a/renderer/components/todo-panel.tsx b/renderer/components/todo-panel.tsx index 2c922550b..ca47d3607 100644 --- a/renderer/components/todo-panel.tsx +++ b/renderer/components/todo-panel.tsx @@ -1,4 +1,4 @@ -import { Check, Circle, ListChecks, LoaderCircle, LockKeyhole } from "lucide-react"; +import { Check, Circle, Info, ListChecks, LoaderCircle, LockKeyhole } from "lucide-react"; import type { TodoSnapshotViewV1, TodoTaskViewV1 } from "../shared/todo"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui"; @@ -51,27 +51,35 @@ function floatingAnchor(children: React.ReactNode) { export function TodoPanel({ snapshot }: { snapshot: TodoSnapshotViewV1 | null }) { if (!snapshot) return null; if (snapshot.availability === "unavailable") { + const storageNotEnabled = snapshot.unavailableReason === "storage_not_enabled"; + const title = storageNotEnabled ? "Task tracking not enabled" : "Task tracking unavailable"; + const explanation = storageNotEnabled + ? "Saved task tracking is not enabled for this chat on this Mac. You can continue chatting, but Aiden cannot save or update a task list here." + : "Aiden could not verify this chat’s private task state, so it will not display or update an older snapshot."; return floatingAnchor( <>

- Task tracking unavailable. Aiden could not verify this chat’s private task state. + {title}. {explanation}

-

Task tracking unavailable

+

{title}

- Aiden could not verify this chat’s private task state, so it will not display or - update an older snapshot. + {explanation}

diff --git a/renderer/shared/todo.test.ts b/renderer/shared/todo.test.ts index da263292d..b56139a69 100644 --- a/renderer/shared/todo.test.ts +++ b/renderer/shared/todo.test.ts @@ -5,6 +5,7 @@ import { parseTodoSnapshotView, TodoSnapshotReadFence, todoSnapshotForRenderer, + unavailableTodoSnapshot, type TodoSnapshotViewV1, } from "./todo.js"; @@ -53,6 +54,22 @@ test("renderer parser rejects dangling dependencies and malformed unavailable st ); }); +test("unavailable reasons are closed, content-free and backward compatible", () => { + for (const reason of [undefined, "storage_not_enabled", "invalid_snapshot"] as const) { + const snapshot = unavailableTodoSnapshot("chat", reason); + assert.deepEqual(parseTodoSnapshotView(snapshot), snapshot); + } + for (const reason of ["private error text", null, 42]) { + assert.equal(parseTodoSnapshotView({ + ...unavailableTodoSnapshot("chat"), unavailableReason: reason, + }), undefined); + } + assert.equal(parseTodoSnapshotView({ + version: 1, chatId: "chat", availability: "ready", tasks: [], + unavailableReason: "storage_not_enabled", + }), undefined); +}); + test("a slow initial read cannot overwrite a newer live snapshot", async () => { const fence = new TodoSnapshotReadFence(); fence.reset("chat-1"); diff --git a/renderer/shared/todo.ts b/renderer/shared/todo.ts index 6e3a83e16..821243ac6 100644 --- a/renderer/shared/todo.ts +++ b/renderer/shared/todo.ts @@ -15,6 +15,7 @@ export interface TodoSnapshotViewV1 { version: typeof TODO_VIEW_VERSION; chatId: string; availability: "ready" | "unavailable"; + unavailableReason?: "storage_not_enabled" | "invalid_snapshot"; tasks: TodoTaskViewV1[]; } @@ -72,6 +73,10 @@ export function parseTodoSnapshotView(value: unknown): TodoSnapshotViewV1 | unde snapshot.chatId.length < 1 || snapshot.chatId.length > 200 || (snapshot.availability !== "ready" && snapshot.availability !== "unavailable") || + (snapshot.unavailableReason !== undefined && + (snapshot.availability !== "unavailable" || + (snapshot.unavailableReason !== "storage_not_enabled" && + snapshot.unavailableReason !== "invalid_snapshot"))) || !Array.isArray(snapshot.tasks) || snapshot.tasks.length > MAX_TODO_VIEW_TASKS ) { @@ -115,6 +120,9 @@ export function parseTodoSnapshotView(value: unknown): TodoSnapshotViewV1 | unde version: TODO_VIEW_VERSION, chatId: snapshot.chatId, availability: snapshot.availability, + ...(snapshot.unavailableReason !== undefined + ? { unavailableReason: snapshot.unavailableReason as TodoSnapshotViewV1["unavailableReason"] } + : {}), tasks, }; } @@ -147,6 +155,12 @@ export function todoSnapshotForRenderer( return parsed; } -export function unavailableTodoSnapshot(chatId: string): TodoSnapshotViewV1 { - return { version: TODO_VIEW_VERSION, chatId, availability: "unavailable", tasks: [] }; +export function unavailableTodoSnapshot( + chatId: string, + unavailableReason?: TodoSnapshotViewV1["unavailableReason"], +): TodoSnapshotViewV1 { + return { + version: TODO_VIEW_VERSION, chatId, availability: "unavailable", tasks: [], + ...(unavailableReason ? { unavailableReason } : {}), + }; }