diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 3d86e417..59e94c45 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -430,6 +430,13 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed. - E2E chat-title expectations assume the deterministic chat-model route. On a Mac where the native Foundation Models helper reports `ready`, automatic titles come from Apple Intelligence instead, so `chat-message-queue` sidebar-title lookups fail locally while passing in CI; probe the helper or move it aside before treating those failures as regressions. - `git add` on the tracked-but-ignored `.papercuts/troubleshooting.md` still needs `-f` after conflict resolution. +## 2026-09-12 — Production provider-failure investigation + +- The 0.40.0 production diagnostic log collapsed a concrete OpenCode Go 400 into duplicate `unknown` generation failures; correlate the Pi journal to recover historical provider causes. PR #110 improves future evidence but cannot reconstruct old redacted logs. +- A renderer exception during final streaming can detach a generation and miss its one-shot terminal payload. The durable run and chat settle correctly, but `chats:settled`/authoritative refetch does not clear the retained detached-stream owner, leaving “Response continues in the background…” and the sidebar activity ring until the renderer restarts. +- A parallel read-only diagnostic command used a stale worktree path and failed before inspection; validate the active checkout path before dispatching concurrent repository reads. +- The repository script is `npm run type-check`, not the common `typecheck` spelling; inspect `package.json` before chaining validation commands so a typo does not skip later linting. + ## 2026-09-13 — GitHub PR checks sidebar dev launch - `npm ci` completed successfully but left `node_modules/electron/dist/Electron.app` absent; restore the macOS payload with `node node_modules/electron/install.js` before running `npm run dev`. diff --git a/renderer/lib/chat-terminal-sync.test.ts b/renderer/lib/chat-terminal-sync.test.ts index 6120e5f3..5314ee9c 100644 --- a/renderer/lib/chat-terminal-sync.test.ts +++ b/renderer/lib/chat-terminal-sync.test.ts @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + captureDetachedLifecycleChat, + clearInactiveDetachedLifecycleChat, detachedLifecycleChatProjection, detachedTextStreamingRemaining, fallbackDetachedLifecycleStream, isDetachedLifecycleChatDraining, parseChatReadResponse, parseChatSettlementNotification, + pendingDetachedLifecycleChats, preferLatestTerminalChat, reconcileChatReadUntilAuthoritative, rememberChatReadReconciliation, @@ -496,6 +499,57 @@ test("a provisional stale read survives a missed settlement event until authorit unsubscribe(); }); +test("an inactive snapshot repairs a missed terminal without clearing active work", () => { + const staleOwner = detached("missed-terminal", "chat-missed-terminal", "workspace-1"); + const activeOwner = detached("still-running", "chat-still-running", "workspace-1"); + rememberDetachedLifecycleStream(staleOwner, { + content: "Durable response that outlived the route", + reasoning: "", + timeline: null, + artifacts: [], + subagents: [], + }); + rememberDetachedLifecycleStream(activeOwner); + + assert.deepEqual(pendingDetachedLifecycleChats(), [ + { chatId: staleOwner.chatId, workspaceId: staleOwner.workspaceId, streamIds: [staleOwner.streamId] }, + { chatId: activeOwner.chatId, workspaceId: activeOwner.workspaceId, streamIds: [activeOwner.streamId] }, + ]); + const staleTarget = captureDetachedLifecycleChat(staleOwner); + const activeTarget = captureDetachedLifecycleChat(activeOwner); + assert.ok(staleTarget); + assert.ok(activeTarget); + const activeChatIds = new Set([activeOwner.chatId]); + assert.equal(clearInactiveDetachedLifecycleChat(activeTarget, activeChatIds), false); + assert.equal(isDetachedLifecycleChatDraining(activeOwner.chatId, activeOwner.workspaceId), true); + assert.equal(clearInactiveDetachedLifecycleChat(staleTarget, activeChatIds), true); + assert.equal(detachedLifecycleChatProjection(staleOwner.chatId, staleOwner.workspaceId), null); + assert.equal(isDetachedLifecycleChatDraining(staleOwner.chatId, staleOwner.workspaceId), false); + assert.equal(clearInactiveDetachedLifecycleChat(activeTarget, new Set()), true); +}); + +test("authoritative recovery is stream-exact when newer work starts in the same chat", () => { + const owner = detached("fallback-missed", "chat-fallback-missed", "workspace-1"); + rememberDetachedLifecycleStream(owner); + assert.equal(fallbackDetachedLifecycleStream(owner.streamId), true); + const captured = captureDetachedLifecycleChat(owner); + assert.ok(captured); + + const newerOwner = detached("newer-detached-work", owner.chatId, owner.workspaceId); + rememberDetachedLifecycleStream(newerOwner); + rememberDetachedLifecycleStream(detached("other-workspace", owner.chatId, "workspace-2")); + assert.equal(clearInactiveDetachedLifecycleChat(captured, new Set()), true); + assert.equal(isDetachedLifecycleChatDraining(owner.chatId, owner.workspaceId), true); + assert.equal(isDetachedLifecycleChatDraining(owner.chatId, "workspace-2"), true); + + const newerTarget = captureDetachedLifecycleChat(newerOwner); + const otherTarget = captureDetachedLifecycleChat({ chatId: owner.chatId, workspaceId: "workspace-2" }); + assert.ok(newerTarget); + assert.ok(otherTarget); + assert.equal(clearInactiveDetachedLifecycleChat(newerTarget, new Set()), true); + assert.equal(clearInactiveDetachedLifecycleChat(otherTarget, new Set()), true); +}); + test("chat read reconciliation metadata is bounded, content-free, and owner-bound", () => { const stale = chat("chat-a", "stale transcript remains only inside chat"); assert.deepEqual( diff --git a/renderer/lib/chat-terminal-sync.ts b/renderer/lib/chat-terminal-sync.ts index 8ba36b41..9760577f 100644 --- a/renderer/lib/chat-terminal-sync.ts +++ b/renderer/lib/chat-terminal-sync.ts @@ -59,6 +59,10 @@ export interface ChatSettlementNotification { workspaceId: string; } +export interface DetachedLifecycleChatReconciliation extends ChatSettlementNotification { + streamIds: readonly string[]; +} + export interface ChatReadReconciliation { chatId: string; workspaceId: string; @@ -349,6 +353,80 @@ export function isDetachedLifecycleChatDraining( ); } +function ownsLifecycleChat( + owner: ChatSettlementNotification, + chatId: string, + workspaceId: string, +): boolean { + return owner.chatId === chatId && owner.workspaceId === workspaceId; +} + +/** Capture exact retained streams so recovery cannot clear newer work in the same chat. */ +export function pendingDetachedLifecycleChats(): DetachedLifecycleChatReconciliation[] { + const pending = new Map(); + for (const [streamId, owner] of [ + ...detachedLifecycleStreams.entries(), + ...fallbackLifecycleStreams.entries(), + ]) { + const key = chatReadReconciliationKey(owner); + const retained = pending.get(key); + if (retained?.streamIds.includes(streamId)) continue; + pending.set(key, { + chatId: owner.chatId, + workspaceId: owner.workspaceId, + streamIds: [...(retained?.streamIds ?? []), streamId], + }); + } + return [...pending.values()]; +} + +export function captureDetachedLifecycleChat( + owner: ChatSettlementNotification, +): DetachedLifecycleChatReconciliation | null { + const workspaceId = persistedChatWorkspaceId(owner.workspaceId); + return ( + pendingDetachedLifecycleChats().find((candidate) => + ownsLifecycleChat(candidate, owner.chatId, workspaceId), + ) ?? null + ); +} + +/** Clear captured ownership only after main's current activity snapshot proves inactivity. */ +export function clearInactiveDetachedLifecycleChat( + owner: DetachedLifecycleChatReconciliation, + activeChatIds: ReadonlySet, +): boolean { + if ( + !isSafeSubagentIdentifier(owner.chatId) || + !isSafeSubagentIdentifier(owner.workspaceId) || + !owner.streamIds.every(isSafeSubagentIdentifier) || + activeChatIds.has(owner.chatId) + ) { + return false; + } + const workspaceId = persistedChatWorkspaceId(owner.workspaceId); + let changed = false; + for (const streamId of owner.streamIds) { + const detachedOwner = detachedLifecycleStreams.get(streamId); + if (detachedOwner && ownsLifecycleChat(detachedOwner, owner.chatId, workspaceId)) { + detachedLifecycleStreams.delete(streamId); + changed = true; + } + const fallbackOwner = fallbackLifecycleStreams.get(streamId); + if (fallbackOwner && ownsLifecycleChat(fallbackOwner, owner.chatId, workspaceId)) { + fallbackLifecycleStreams.delete(streamId); + changed = true; + } + const projection = detachedLifecycleProjections.get(streamId); + if (projection && ownsLifecycleChat(projection, owner.chatId, workspaceId)) { + detachedLifecycleProjections.delete(streamId); + changed = true; + } + } + if (changed) emitRegistryChange(); + return changed; +} + function requestChatReadReconciliation(owner: ChatReadReconciliation): void { const key = chatReadReconciliationKey(owner); if ( diff --git a/renderer/main/chat-transition.test.tsx b/renderer/main/chat-transition.test.tsx index 7a590735..38d8ffe3 100644 --- a/renderer/main/chat-transition.test.tsx +++ b/renderer/main/chat-transition.test.tsx @@ -317,6 +317,25 @@ test("a revisited detached stream restores the responding window from its last t ); }); +test("root recovery reconciles missed detached terminals against authoritative activity", () => { + const root = source("./root-view.tsx"); + const recovery = between( + root, + "const reconcileInactiveOwners = (payload: unknown) => {", + "}, [reconcileDetachedLifecycleChat]);", + ); + assert.match(recovery, /parseChatActivitySnapshot\(payload\)/u); + assert.match(recovery, /pendingDetachedLifecycleChats\(\)/u); + assert.match(recovery, /!activeChatIds\.has\(owner\.chatId\)/u); + assert.ok( + recovery.indexOf('onNotification("chats:activity-changed"') < recovery.indexOf(".activitySnapshot()"), + "Recovery must subscribe before its bootstrap snapshot", + ); + assert.match(root, /captureDetachedLifecycleChat\(owner\)/u); + assert.match(root, /clearInactiveDetachedLifecycleChat\(captured, new Set\(snapshot\.activeChatIds\)\)/u); + assert.match(root, /subscribeChatSettlements[\s\S]{0,180}reconcileDetachedLifecycleChat\(settlement\)/u); +}); + test("every ordinary new-agent entry opens a draft without eager creation", () => { for (const file of ["./chat-layout.tsx", "./root-view.tsx", "./chat-pane.tsx", "../components/chat-sidebar.tsx"]) { const implementation = source(file); diff --git a/renderer/main/root-view.tsx b/renderer/main/root-view.tsx index 553c113a..566f68e9 100644 --- a/renderer/main/root-view.tsx +++ b/renderer/main/root-view.tsx @@ -19,12 +19,17 @@ import { AppCommandPalette } from "../components/command-palette"; import { OnboardingFlow } from "../components/onboarding-flow"; import { workspaceCommandVisibility } from "../lib/command-system-core"; import { + captureDetachedLifecycleChat, + clearInactiveDetachedLifecycleChat, + type DetachedLifecycleChatReconciliation, + pendingDetachedLifecycleChats, preferLatestTerminalChat, reconcileChatReadUntilAuthoritative, subscribeChatReadReconciliations, subscribeChatSettlements, subscribeDetachedTerminalChats, } from "../lib/chat-terminal-sync"; +import { parseChatActivitySnapshot } from "../shared/chat-activity"; import { isChatCacheDeleted } from "../lib/chat-deletion-cache"; import type { Chat } from "../lib/types"; import { useAppendReconciliationRequired } from "../lib/append-reconciliation"; @@ -96,6 +101,26 @@ function RootContent() { }, [queryClient], ); + const reconcileDetachedLifecycleChat = React.useCallback( + ( + owner: + | { chatId: string; workspaceId: string } + | DetachedLifecycleChatReconciliation, + ) => { + const captured = "streamIds" in owner ? owner : captureDetachedLifecycleChat(owner); + return (async () => { + try { + await reconcileChatCacheAfterIdle(owner.chatId); + const snapshot = parseChatActivitySnapshot(await chatsApi.activitySnapshot()); + if (!snapshot || !captured) return; + clearInactiveDetachedLifecycleChat(captured, new Set(snapshot.activeChatIds)); + } catch { + // Failure is not proof of settlement. Retained activity events retry it. + } + })(); + }, + [reconcileChatCacheAfterIdle], + ); useCommandHandler( "terminal.toggle", @@ -255,12 +280,37 @@ function RootContent() { React.useEffect( () => subscribeChatSettlements(onNotification, (settlement) => { - if (isChatCacheDeleted(settlement.chatId)) return; - void reconcileChatCacheAfterIdle(settlement.chatId); + void reconcileDetachedLifecycleChat(settlement); }), - [reconcileChatCacheAfterIdle], + [reconcileDetachedLifecycleChat], ); + React.useEffect(() => { + let disposed = false; + const reconcileInactiveOwners = (payload: unknown) => { + const snapshot = parseChatActivitySnapshot(payload); + if (!snapshot) return; + const activeChatIds = new Set(snapshot.activeChatIds); + for (const owner of pendingDetachedLifecycleChats()) { + if (!activeChatIds.has(owner.chatId)) void reconcileDetachedLifecycleChat(owner); + } + }; + // Subscribe before the bootstrap snapshot so no transition falls in between. + const unsubscribe = onNotification("chats:activity-changed", reconcileInactiveOwners); + void chatsApi + .activitySnapshot() + .then((payload) => { + if (!disposed) reconcileInactiveOwners(payload); + }) + .catch(() => { + // Future activity or settlement events retry recovery. + }); + return () => { + disposed = true; + unsubscribe(); + }; + }, [reconcileDetachedLifecycleChat]); + React.useEffect(() => { // ~/.aiden/config.json was edited outside the app. Only the lists sourced // from the portable file are stale; workspaces and UI settings are stored