Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .papercuts/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
54 changes: 54 additions & 0 deletions renderer/lib/chat-terminal-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
78 changes: 78 additions & 0 deletions renderer/lib/chat-terminal-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export interface ChatSettlementNotification {
workspaceId: string;
}

export interface DetachedLifecycleChatReconciliation extends ChatSettlementNotification {
streamIds: readonly string[];
}

export interface ChatReadReconciliation {
chatId: string;
workspaceId: string;
Expand Down Expand Up @@ -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<string, DetachedLifecycleChatReconciliation>();
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<string>,
): 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 (
Expand Down
19 changes: 19 additions & 0 deletions renderer/main/chat-transition.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
56 changes: 53 additions & 3 deletions renderer/main/root-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down