Compare commits

...
2 Commits
Author SHA1 Message Date
github-actions[bot] 8fc6882c22 docs(release): prepare v1.24.2 notes (#8640)
Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.

Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-12 23:40:54 +08:00
f53edb599a fix(session): fold recovery lineages into one ordinary list row / 恢复谱系折叠为单行逻辑会话 (#8599)
Fixes the 1.24.2 Catalog regression that re-projected every recovery JSONL
as its own ordinary sidebar topic after upgrade.

- Disposable session-catalog/v4.sqlite with logical projection
  (logical_topic_id, ordinary_visible, recovery roles/canonical)
- Re-anchor cross-topic recovery members onto the root logical topic;
  ordinary list stays one row per conversation
- Content-based covering/preferred selection; filename fallback for
  legacy *-recovery-<16hex>.jsonl
- Disable automatic physical recovery GC; keep explicit UI preview
  cleanup and `reasonix sessions cleanup --apply`
- History "Other saved versions" for inspection, default choice, and
  dry-run cleanup; externally removed open sessions move to one stable
  recovery lane without recreating the original path
- Bounded catalog metadata sync (30s) and sidebar reads (10s); live tabs
  stay visible while the projection lags, deduped by catalog projection;
  connection-pool regression guards (adopted from #8594)
- CLI sessions diagnose/cleanup and doctor sessions lineage metrics

Co-authored-by: yingziisme <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-08-12 21:36:31 +08:00
49 changed files with 3627 additions and 430 deletions

No files matched your search

@@ -75,9 +75,11 @@ if (localeChunks.length !== 2) {
for (const path of localeChunks) {
const name = basename(path);
// Task Monitor, billing, indexed history, Task Center, Extension UI, and
// runtime controls plus execution-setting receipts add localized copy. Keep
// both dictionaries bounded.
const budget = name.startsWith("zh-TW-") ? 55.5 * 1024 : 54.5 * 1024;
// runtime controls plus execution-setting receipts add localized copy. The
// recovery "other saved versions" dialog adds ~0.1 KiB gzip to zh (54.6 over
// the old 54.5 gate, +0.18%); both on-demand dictionaries stay bounded with
// small headroom.
const budget = name.startsWith("zh-TW-") ? 55.5 * 1024 : 54.75 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}
@@ -201,22 +201,22 @@ console.log("\nuse controller meta");
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
"legacy recovery branch notice can be normalized without exposing internal branch id",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
"recovery copy notice can be normalized",
);
eq(
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
"Repeated save conflicts were detected, so the current version was saved separately.",
"legacy repeated recovery conflict notice can be normalized",
);
eq(
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
"Repeated save conflicts were detected, so the current version was saved separately.",
"repeated recovery conflict notice can be normalized",
);
eq(
@@ -264,7 +264,7 @@ console.log("\nuse controller meta");
);
eq(
localizedNoticeText("reworded recovery copy", "session_recovery_forked"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"The session changed on disk, so the unsaved local transcript was kept as another saved version.",
"session recovery fork localization uses its stable notice code",
);
eq(
@@ -274,7 +274,7 @@ console.log("\nuse controller meta");
);
eq(
localizedNoticeText("reworded depth cap", "session_recovery_depth_cap"),
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
"Repeated save conflicts were detected, so the current version was saved separately.",
"session recovery depth-cap localization uses its stable notice code",
);
eq(
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { Archive, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
import { Archive, GitBranch, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
import { app } from "../lib/bridge";
import { t, useT } from "../lib/i18n";
import { historySessionDisplayTitle, sessionActivityTime } from "../lib/session";
import type { HistoryMessage, HistorySearchContextLine, HistorySearchHit, SessionMeta } from "../lib/types";
import type { HistoryMessage, HistorySearchContextLine, HistorySearchHit, RecoveryLineageView, SessionMeta } from "../lib/types";
import { historyMessagesToItems, type Item } from "../lib/useController";
import { useHistoryCatalog } from "../lib/useHistoryCatalog";
import { Transcript } from "./Transcript";
@@ -12,6 +12,8 @@ import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type Cont
import { useDeferredClose } from "../lib/useMountTransition";
import { ModalCloseButton } from "./ModalCloseButton";
import { HistoryFilterSelect } from "./HistoryFilterSelect";
import { RecoveryLineageDialog } from "./RecoveryLineageDialog";
import { useToast } from "../lib/toast";
type HistoryScopeFilter = "all" | "project" | "global";
type HistoryStatusFilter = "all" | "current" | "open";
@@ -50,9 +52,14 @@ export function HistoryPanel({
onClose: () => void;
}) {
const tr = useT();
const { showToast } = useToast();
const isTrash = kind === "trash";
// Play the modal exit animation, then let the parent unmount us.
const { status, requestClose } = useDeferredClose(onClose, 240);
const [savedVersions, setSavedVersions] = useState<{
topic: { scope: string; workspaceRoot?: string; topicId: string };
view: RecoveryLineageView;
} | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
@@ -321,6 +328,16 @@ export function HistoryPanel({
startRename(target);
},
},
...(menuSession.topicId
? [
{
key: "other-saved-versions",
icon: <GitBranch size={13} />,
label: tr("recovery.inspectLineage"),
onSelect: () => void inspectOtherSavedVersions(menuSession),
} as ContextMenuItem,
]
: []),
...(menuSession.current
? []
: [
@@ -396,6 +413,21 @@ export function HistoryPanel({
if (!selectedSession || running || isTrash) return;
onResume(selectedSession);
};
const inspectOtherSavedVersions = async (session: SessionMeta) => {
if (isTrash || !session.topicId) return;
closeHistoryMenus();
try {
const topic = {
scope: session.scope || "global",
workspaceRoot: session.workspaceRoot || undefined,
topicId: session.topicId,
};
const view = await app.GetRecoveryLineage(topic);
setSavedVersions({ topic, view });
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
}
};
const renameSelected = () => {
if (!selectedSession || running || isTrash) return;
closeHistoryMenus();
@@ -684,6 +716,16 @@ export function HistoryPanel({
<button className="btn btn--small" type="button" disabled={!selectedSession || running} onClick={renameSelected}>
{tr("history.rename")}
</button>
{selectedSession?.topicId && (
<button
className="btn btn--small"
type="button"
disabled={!selectedSession}
onClick={() => void inspectOtherSavedVersions(selectedSession)}
>
<GitBranch size={13} /> {tr("recovery.inspectLineage")}
</button>
)}
<button
className="btn btn--small btn--danger"
type="button"
@@ -727,6 +769,35 @@ export function HistoryPanel({
ariaLabel={tr("history.trashActions")}
onClose={closeHistoryMenus}
/>
{savedVersions && (
<RecoveryLineageDialog
topic={savedVersions.topic}
initial={savedVersions.view}
onClose={() => setSavedVersions(null)}
onChanged={async () => {
const view = await app.GetRecoveryLineage(savedVersions.topic);
setSavedVersions({ topic: savedVersions.topic, view });
}}
onOpenVersion={async (path) => {
const session = sessions.find((item) => item.path === path) ?? {
path,
preview: "",
turns: 0,
turnsState: "unknown",
createdAt: 0,
lastActivityAt: 0,
modTime: 0,
current: false,
open: false,
scope: savedVersions.topic.scope,
workspaceRoot: savedVersions.topic.workspaceRoot,
topicId: savedVersions.topic.topicId,
recovered: true,
};
onResume(session);
}}
/>
)}
</div>
</section>
</div>
@@ -1172,7 +1172,7 @@ export function ProjectTree({
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
const conflictCopyLabel = isSessionNode && node.recovered ? t("recovery.badge") : "";
// Ordinary list never advertises recovery copies; History owns that view.
const activityAt = node.lastActivityAt || node.createdAt || 0;
// Every variant is a single-line row with the activity time on the right;
// classic moved there too so turns and the exact date live in the hover
@@ -1198,8 +1198,7 @@ export function ProjectTree({
const imSourceLabel = imSource?.label || "";
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
const conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
const title = [label, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
const pinned = Boolean(node.pinned);
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
@@ -1323,7 +1322,7 @@ export function ProjectTree({
>
<span className="project-tree__topic-copy">
<span className="project-tree__topic-heading">
<span className="project-tree__topic-label">{conflictCopyLabel ? `${label} · ${conflictCopyLabel}` : label}</span>
<span className="project-tree__topic-label">{label}</span>
{imSource && (
<span
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
@@ -0,0 +1,144 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import { GitBranch, Trash2, X } from "lucide-react";
import { app } from "../lib/bridge";
import { asArray } from "../lib/array";
import type { ProjectTopicKey } from "../lib/sessionCatalogTypes";
import type { RecoveryCleanupResult, RecoveryLineageView } from "../lib/types";
import { useT } from "../lib/i18n";
interface RecoveryLineageDialogProps {
topic: ProjectTopicKey;
initial: RecoveryLineageView;
onClose: () => void;
onChanged: () => Promise<void> | void;
onOpenVersion?: (path: string) => Promise<void> | void;
}
function fileName(path: string): string {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] || path;
}
function roleKey(role: string): "recovery.role.covered_copy" | "recovery.role.adopted" | "recovery.role.preferred" | "recovery.role.diverged" | "recovery.role.normal" {
if (role === "covered_copy") return "recovery.role.covered_copy";
if (role === "adopted") return "recovery.role.adopted";
if (role === "preferred") return "recovery.role.preferred";
if (role === "diverged") return "recovery.role.diverged";
return "recovery.role.normal";
}
export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOpenVersion }: RecoveryLineageDialogProps) {
const t = useT();
const [view, setView] = useState(initial);
const [preview, setPreview] = useState<RecoveryCleanupResult | null>(null);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<RecoveryCleanupResult | null>(null);
const choose = async (path: string) => {
if (busy) return;
setBusy(true);
try {
await app.ChooseRecoveryBranch({ ...topic, path });
setView(await app.GetRecoveryLineage(topic));
await onChanged();
} finally {
setBusy(false);
}
};
const openVersion = async (path: string) => {
if (busy || !onOpenVersion) return;
setBusy(true);
try {
await onOpenVersion(path);
onClose();
} finally {
setBusy(false);
}
};
const clean = async (apply: boolean) => {
if (busy) return;
setBusy(true);
try {
const next = await app.CleanRecoveryLineage({ ...topic, apply });
if (!apply) {
setPreview(next);
return;
}
setResult(next);
setPreview(null);
setView(await app.GetRecoveryLineage(topic));
await onChanged();
} finally {
setBusy(false);
}
};
return createPortal(
<div className="management-modal-backdrop recovery-lineage-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<section className="management-modal recovery-lineage-dialog" role="dialog" aria-modal="true" aria-labelledby="recovery-lineage-title">
<header className="management-modal__head">
<div>
<div className="management-modal__title" id="recovery-lineage-title">
<GitBranch size={17} aria-hidden="true" /> {t("recovery.lineageTitle")}
</div>
<div className="management-modal__summary">
{t("recovery.lineageSummary", { branches: view.branchCount, unresolved: view.unresolved })}
</div>
</div>
<button type="button" className="icon-btn" onClick={onClose} aria-label={t("common.close")}><X size={16} /></button>
</header>
<div className="recovery-lineage-dialog__body">
{asArray(view.members).map((member) => (
<div className="recovery-lineage-dialog__member" key={member.path}>
<div className="recovery-lineage-dialog__member-name" title={member.path}>{fileName(member.path)}</div>
<div className="recovery-lineage-dialog__member-meta">
<span>{t(roleKey(member.role))}</span>
<span>{t(member.turns === 1 ? "history.turnOne" : "history.turnOther", { n: member.turns })}</span>
{(member.open || member.running) && <span>{t("recovery.inUse")}</span>}
{member.canonical && <span>{t("recovery.role.preferred")}</span>}
{onOpenVersion && (
<button type="button" className="recovery-lineage-dialog__choose" disabled={busy} onClick={() => void openVersion(member.path)}>
{t("recovery.openVersion")}
</button>
)}
{member.role !== "covered_copy" && !member.canonical && (
<button type="button" className="recovery-lineage-dialog__choose" disabled={busy} onClick={() => void choose(member.path)}>
{t("recovery.chooseBranch")}
</button>
)}
</div>
</div>
))}
{view.members.length === 0 && <div className="management-modal__summary">{t("recovery.lineageEmpty")}</div>}
{preview && (
<div className="recovery-lineage-dialog__notice" role="status">
{t("recovery.cleanupConfirm", { count: preview.eligible })}
</div>
)}
{result && (
<div className="recovery-lineage-dialog__notice" role="status">
{t("recovery.cleanupResult", { moved: result.moved, busy: result.busy, kept: result.kept })}
</div>
)}
</div>
<footer className="modal__actions recovery-lineage-dialog__actions">
<button type="button" className="btn" onClick={onClose}>{t("common.close")}</button>
{view.cleanupEligible > 0 && !preview && (
<button type="button" className="btn" disabled={busy} onClick={() => void clean(false)}>
<Trash2 size={14} /> {t("recovery.previewCleanup")}
</button>
)}
{preview && preview.eligible > 0 && (
<button type="button" className="btn btn--danger" disabled={busy} onClick={() => void clean(true)}>
<Trash2 size={14} /> {t("recovery.applyCleanup")}
</button>
)}
</footer>
</section>
</div>,
document.body,
);
}
+24
View File
@@ -84,6 +84,9 @@ import type {
PluginInstallOptions,
PluginView,
ProjectNode,
RecoveryLineageView,
RecoveryCleanupRequest,
RecoveryCleanupResult,
SessionCatalogBindings,
PromptHistoryEntry,
PromptHistoryResult,
@@ -315,6 +318,9 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
PreviewSession(path: string): Promise<HistoryMessage[]>;
DeleteSession(path: string): Promise<void>;
DeleteRecoveryCopy(path: string): Promise<void>;
GetRecoveryLineage(key: { scope: string; workspaceRoot?: string; topicId: string }): Promise<RecoveryLineageView>;
ChooseRecoveryBranch(request: import("./types").RecoveryPreferenceRequest): Promise<void>;
CleanRecoveryLineage(request: RecoveryCleanupRequest): Promise<RecoveryCleanupResult>;
RestoreSession(path: string): Promise<void>;
PurgeTrashedSession(path: string): Promise<void>;
PurgeRecoveryCopy(path: string): Promise<void>;
@@ -3324,6 +3330,24 @@ function makeMockApp(): AppBindings {
async DeleteRecoveryCopy(path: string) {
return this.DeleteSession(path);
},
async GetRecoveryLineage(key) {
const topic = findMockTopic(key.topicId);
return {
groupId: key.topicId,
state: topic?.recoveryState ?? "normal",
branchCount: topic?.recoveryBranchCount ?? 0,
unresolved: topic?.recoveryUnresolvedCount ?? 0,
cleanupEligible: topic?.recoveryCleanupEligibleCount ?? 0,
members: [],
};
},
async ChooseRecoveryBranch() {},
async CleanRecoveryLineage(request) {
const topic = findMockTopic(request.topicId);
const eligible = topic?.recoveryCleanupEligibleCount ?? 0;
if (request.apply && topic) topic.recoveryCleanupEligibleCount = 0;
return { eligible, moved: request.apply ? eligible : 0, busy: 0, kept: 0, dryRun: !request.apply, items: [] };
},
async RestoreSession(path: string) {
const i = trashedSessions.findIndex((s) => s.path === path);
if (i >= 0) {
+3 -4
View File
@@ -180,13 +180,12 @@ export function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" {
}
export function topicStatus(node: ProjectNode): ProjectTopicStatus | "" {
// Active runtime states take priority over the informational diverged-recovery
// marker so a topic that is simultaneously running and has unresolved branches
// surfaces the live state first.
// Ordinary list never surfaces recovery-branch status. Active runtime states
// only: thinking/streaming/waiting/etc. History owns other saved versions.
const live = node.running ? "streaming" : "";
const stored = normalizeTopicStatus(node.status);
if (stored && stored !== "diverged_recovery") return stored;
return live || stored;
return live;
}
export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean {
+51
View File
@@ -512,10 +512,61 @@ export interface ProjectNode {
recoveryReason?: string;
recoveryDigest?: string;
recoveryParentId?: string;
recoveryState?: "normal" | "repairing" | "adopted" | "preferred" | "diverged" | "recovery_only" | string;
recoveryBranchCount?: number;
recoveryUnresolvedCount?: number;
recoveryCleanupEligibleCount?: number;
isolatedWorktree?: boolean;
children?: ProjectNode[];
}
export interface RecoveryLineageMember {
path: string;
role: "normal" | "covered_copy" | "adopted" | "preferred" | "diverged" | string;
canonical: boolean;
turns: number;
open: boolean;
running: boolean;
}
export interface RecoveryLineageView {
groupId: string;
state: string;
branchCount: number;
unresolved: number;
cleanupEligible: number;
members: RecoveryLineageMember[];
}
export interface RecoveryCleanupRequest {
scope: string;
workspaceRoot?: string;
topicId: string;
apply: boolean;
}
export interface RecoveryPreferenceRequest {
scope: string;
workspaceRoot?: string;
topicId: string;
path: string;
}
export interface RecoveryCleanupItem {
path: string;
status: "eligible" | "moved" | "busy" | "kept" | string;
error?: string;
}
export interface RecoveryCleanupResult {
eligible: number;
moved: number;
busy: number;
kept: number;
dryRun: boolean;
items: RecoveryCleanupItem[];
}
export interface DeliveryWorktreeAvailability {
available: boolean;
reason?: string;
+21 -5
View File
@@ -1243,13 +1243,29 @@ export const en = {
"history.historySessionActions": "History session actions",
"history.trashActions": "Trash actions",
// session recovery
"recovery.branch": "Conflict copy",
"recovery.badge": "Copy",
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
// session recovery (advanced History language — avoid exposing internal terms)
"recovery.branch": "Saved version",
"recovery.badge": "Version",
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as another saved version.",
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current version was saved separately.",
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
"recovery.inspectLineage": "Other saved versions",
"recovery.lineageTitle": "Other saved versions",
"recovery.lineageSummary": "{branches} saved versions · {unresolved} with unique content",
"recovery.lineageEmpty": "No other saved versions.",
"recovery.previewCleanup": "Review safe cleanup",
"recovery.applyCleanup": "Move covered versions to Trash",
"recovery.cleanupConfirm": "{count} covered versions can be moved to Trash. Versions with unique content or that are in use will be kept.",
"recovery.cleanupResult": "Moved {moved}; busy {busy}; kept {kept}.",
"recovery.inUse": "in use",
"recovery.chooseBranch": "Set as default version",
"recovery.openVersion": "Open this version",
"recovery.role.covered_copy": "covered by a fuller version",
"recovery.role.adopted": "full version",
"recovery.role.preferred": "default version",
"recovery.role.diverged": "unique content",
"recovery.role.normal": "original",
// project tree
"projectTree.workspaceTitle": "Projects",
+21 -5
View File
@@ -987,13 +987,29 @@ export const zhTW: Record<DictKey, string> = {
"history.historySessionActions": "歷史會話操作",
"history.trashActions": "回收站操作",
// 會話恢復
"recovery.branch": "衝突副本",
"recovery.badge": "副本",
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為衝突副本。",
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前衝突副本已繼續儲存。",
// 會話恢復(歷史進階入口用詞,避免暴露內部術語)
"recovery.branch": "其他儲存版本",
"recovery.badge": "版本",
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為其他儲存版本。",
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前版本已單獨儲存。",
"recovery.noticeAdopted": "偵測到會話在磁碟上發生變化,已採用較新的記錄。",
"recovery.noticeAdoptedCovered": "偵測到會話在磁碟上發生變化,已採用較新的記錄;本機改動已包含其中。",
"recovery.inspectLineage": "其他儲存版本",
"recovery.lineageTitle": "其他儲存版本",
"recovery.lineageSummary": "{branches} 個儲存版本 · {unresolved} 個含獨有內容",
"recovery.lineageEmpty": "沒有其他儲存版本。",
"recovery.previewCleanup": "檢查可安全清理項",
"recovery.applyCleanup": "將已覆蓋版本移到垃圾桶",
"recovery.cleanupConfirm": "{count} 個已被完整版本覆蓋的副本可以移到垃圾桶;含獨有內容或正在使用的版本會保留。",
"recovery.cleanupResult": "已移動 {moved} 個;佔用 {busy} 個;保留 {kept} 個。",
"recovery.inUse": "正在使用",
"recovery.chooseBranch": "設為預設版本",
"recovery.openVersion": "開啟此版本",
"recovery.role.covered_copy": "已被更完整版本覆蓋",
"recovery.role.adopted": "完整版本",
"recovery.role.preferred": "預設版本",
"recovery.role.diverged": "含獨有內容",
"recovery.role.normal": "原始會話",
// 專案樹
"projectTree.workspaceTitle": "專案",
+20 -4
View File
@@ -1245,12 +1245,28 @@ export const zh: Record<DictKey, string> = {
"history.trashActions": "回收站操作",
// 会话恢复
"recovery.branch": "冲突副本",
"recovery.badge": "副本",
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为冲突副本。",
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前冲突副本已继续保存。",
"recovery.branch": "其他保存版本",
"recovery.badge": "版本",
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为其他保存版本。",
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前版本已单独保存。",
"recovery.noticeAdopted": "检测到会话在磁盘上发生变化,已采用较新的记录。",
"recovery.noticeAdoptedCovered": "检测到会话在磁盘上发生变化,已采用较新的记录;本地改动已包含其中。",
"recovery.inspectLineage": "其他保存版本",
"recovery.lineageTitle": "其他保存版本",
"recovery.lineageSummary": "{branches} 个保存版本 · {unresolved} 个含独有内容",
"recovery.lineageEmpty": "没有其他保存版本。",
"recovery.previewCleanup": "检查可安全清理项",
"recovery.applyCleanup": "将已覆盖版本移到回收站",
"recovery.cleanupConfirm": "{count} 个已被完整版本覆盖的副本可以移到回收站;含独有内容或正在使用的版本会保留。",
"recovery.cleanupResult": "已移动 {moved} 个;占用 {busy} 个;保留 {kept} 个。",
"recovery.inUse": "正在使用",
"recovery.chooseBranch": "设为默认版本",
"recovery.openVersion": "打开此版本",
"recovery.role.covered_copy": "已被更完整版本覆盖",
"recovery.role.adopted": "完整版本",
"recovery.role.preferred": "默认版本",
"recovery.role.diverged": "含独有内容",
"recovery.role.normal": "原始会话",
// 项目树
"projectTree.workspaceTitle": "项目",
+56
View File
@@ -14877,6 +14877,62 @@ body > .mermaid-diagram--fullscreen {
gap: 8px;
flex-shrink: 0;
}
.recovery-lineage-dialog {
width: min(680px, calc(100vw - 48px));
height: auto;
max-height: min(720px, calc(100vh - 64px));
}
.recovery-lineage-dialog .management-modal__title {
display: flex;
align-items: center;
gap: 7px;
}
.recovery-lineage-dialog__body {
min-height: 120px;
overflow: auto;
padding: 12px 18px;
}
.recovery-lineage-dialog__member {
padding: 10px 2px;
border-bottom: 1px solid var(--border);
}
.recovery-lineage-dialog__member-name {
color: var(--fg);
font-size: var(--text-sm);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recovery-lineage-dialog__member-meta {
display: flex;
gap: 10px;
margin-top: 4px;
color: var(--fg-faint);
font-size: var(--text-xs);
}
.recovery-lineage-dialog__choose {
border: 0;
padding: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.recovery-lineage-dialog__choose:disabled { opacity: 0.5; cursor: default; }
.recovery-lineage-dialog__notice {
margin-top: 12px;
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-elev);
color: var(--fg-muted);
font-size: var(--text-sm);
}
.recovery-lineage-dialog__actions {
padding: 12px 18px;
border-top: 1px solid var(--border);
}
@keyframes modal-in {
0% {
transform: translate3d(0, 10px, 0) scale(0.982);
+66 -89
View File
@@ -2,67 +2,21 @@ package main
import (
"log/slog"
"os"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/sessioncatalog"
)
// recoveryGCInterval bounds how often the background sweep repeats after the
// startup run. Covered copies are also reclaimed by the shorter startup grace.
const recoveryGCInterval = 6 * time.Hour
// recoveryGCFollowUpDelay re-runs the short-grace sweep once after startup so
// branches that just crossed the 15-minute idle line are not left until the
// next 6-hour tick.
const recoveryGCFollowUpDelay = 20 * time.Minute
// startRecoveryGC waits for tab restore, runs a short-grace startup sweep (and
// one follow-up), then repeats on the long interval until cancelled. The wait
// is load-bearing: a pre-restore sweep sees every saved tab as closed, letting
// DeleteSession overwrite desktop-tabs.json with an empty snapshot.
func (a *App) startRecoveryGC() {
a.goSafe("recoveryGC", func() {
select {
case <-a.tabsRestoredSignal():
case <-a.ctx.Done():
return
}
startup := time.NewTimer(agent.RecoveryGCStartupGracePeriod)
if !waitRecoveryGCStartup(a.ctx.Done(), startup.C) {
if !startup.Stop() {
select {
case <-startup.C:
default:
}
}
return
}
// Protect every upgraded user for a full startup grace before reclaiming.
a.sweepReclaimableRecoveryBranchesWithGrace(agent.RecoveryGCStartupGracePeriod)
followUp := time.NewTimer(recoveryGCFollowUpDelay)
ticker := time.NewTicker(recoveryGCInterval)
defer ticker.Stop()
for {
select {
case <-a.ctx.Done():
if !followUp.Stop() {
select {
case <-followUp.C:
default:
}
}
return
case <-followUp.C:
// Second short-grace pass, then fall through to long-interval only.
a.sweepReclaimableRecoveryBranchesWithGrace(agent.RecoveryGCStartupGracePeriod)
followUp.C = nil
case <-ticker.C:
a.sweepReclaimableRecoveryBranches()
}
}
})
}
// startRecoveryGC is intentionally a no-op for physical moves.
//
// Catalog v4 folds recovery lineages into one ordinary list row. Automatic
// startup/upgrade/timer GC must not move JSONL/meta into trash — only explicit
// CleanRecoveryLineage (UI, with preview) and `reasonix sessions cleanup
// --apply` may reclaim covered copies. reclaimRecoveryBranchesIn remains for
// those explicit entry points and focused tests.
func (a *App) startRecoveryGC() {}
func waitRecoveryGCStartup(done <-chan struct{}, elapsed <-chan time.Time) bool {
select {
@@ -73,31 +27,35 @@ func waitRecoveryGCStartup(done <-chan struct{}, elapsed <-chan time.Time) bool
}
}
// sweepReclaimableRecoveryBranches trashes conflict-recovery branches that
// preserve nothing unique (their content is covered by a still-present parent
// session), were never continued on, sat idle past the grace period, and are
// not held by any runtime. Trashing — never hard deletion — keeps every swept
// branch recoverable from the session trash. Returns how many were reclaimed.
func (a *App) sweepReclaimableRecoveryBranches() int {
return a.sweepReclaimableRecoveryBranchesWithGrace(agent.RecoveryGCGracePeriod)
}
func (a *App) sweepReclaimableRecoveryBranchesWithGrace(grace time.Duration) int {
return a.reclaimRecoveryBranchesIn(recoveryGCDirs(), time.Now(), grace)
}
func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time, grace time.Duration) int {
if grace <= 0 {
grace = agent.RecoveryGCGracePeriod
}
reclaimed := 0
for _, dir := range dirs {
removed := map[string]bool{}
if catalog := a.sessionCatalog.Load(); catalog != nil {
groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
if err != nil {
slog.Warn("desktop: list recovery lineages for GC", "dir", dir, "err", err)
} else {
for _, group := range groups {
for _, path := range a.reclaimAdoptedRecoveryGroup(group, now, grace) {
removed[sessionRuntimeKey(path)] = true
reclaimed++
}
}
}
}
reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, grace)
if err != nil {
slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err)
continue
}
for _, path := range reclaimable {
if removed[sessionRuntimeKey(path)] {
continue
}
// Re-check liveness right before disposal: the scan is a snapshot,
// and the user may have opened the branch since.
if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) {
@@ -120,29 +78,48 @@ func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time, grace time
return reclaimed
}
// recoveryGCDirs returns every session directory the desktop lists sessions
// from: the global desktop and legacy shared dirs plus each saved project's
// session dirs, deduplicated.
func recoveryGCDirs() []string {
seen := map[string]bool{}
var dirs []string
add := func(dir string) {
key := projectRootKey(dir)
if dir == "" || seen[key] {
return
}
seen[key] = true
dirs = append(dirs, dir)
// reclaimAdoptedRecoveryGroup compacts an entire legacy recovery chain once a
// canonical leaf has proved it covers every member. Every candidate is still
// revalidated under removal guards immediately before it is moved to trash.
func (a *App) reclaimAdoptedRecoveryGroup(group sessioncatalog.RecoveryGroup, now time.Time, grace time.Duration) []string {
if group.State != "adopted" || group.CanonicalPath == "" || group.ID == "" {
return nil
}
add(desktopSessionDir(globalWorkspaceRoot()))
add(config.SessionDir())
for _, project := range loadProjectsFile().Projects {
if root := normalizeProjectRoot(project.Root); root != "" {
add(desktopSessionDir(root))
add(config.ProjectSessionDir(root))
candidates := []string{}
for _, member := range group.Members {
if member.Path == group.CanonicalPath || member.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
continue
}
info, err := os.Stat(member.Path)
if err != nil || now.Sub(info.ModTime()) < grace {
continue
}
candidates = append(candidates, member.Path)
}
return dirs
if len(candidates) == 0 {
return nil
}
defer a.lockRuntimeMutation("gc-recovery-lineage")()
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
if a.sessionOpenInAnyTab(group.CanonicalPath) || agent.SessionLeaseHeld(group.CanonicalPath) {
return nil
}
if err := agent.ReparentRecoveryCanonical(group.CanonicalPath, group.ID, group.Directory); err != nil {
return nil
}
moved := []string{}
for _, path := range candidates {
if a.sessionOpenInAnyTab(path) || agent.SessionLeaseHeld(path) {
continue
}
if err := agent.TrashRecoveryBranchCoveredBy(path, group.CanonicalPath, group.Directory); err != nil {
continue
}
moved = append(moved, path)
a.removeSessionCatalogPath(path, "recovery_lineage_gc")
}
return moved
}
// sessionOpenInAnyTab reports whether any tab's current session is path.
+262
View File
@@ -0,0 +1,262 @@
package main
import (
"errors"
"path/filepath"
"reasonix/internal/agent"
"reasonix/internal/sessioncatalog"
)
type RecoveryLineageMember struct {
Path string `json:"path"`
Role string `json:"role"`
Canonical bool `json:"canonical"`
Turns int `json:"turns"`
Open bool `json:"open"`
Running bool `json:"running"`
}
type RecoveryLineageView struct {
GroupID string `json:"groupId"`
State string `json:"state"`
BranchCount int `json:"branchCount"`
Unresolved int `json:"unresolved"`
CleanupEligible int `json:"cleanupEligible"`
Members []RecoveryLineageMember `json:"members"`
}
type RecoveryCleanupRequest struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId"`
Apply bool `json:"apply"`
}
type RecoveryPreferenceRequest struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId"`
Path string `json:"path"`
}
type RecoveryCleanupItem struct {
Path string `json:"path"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type RecoveryCleanupResult struct {
Eligible int `json:"eligible"`
Moved int `json:"moved"`
Busy int `json:"busy"`
Kept int `json:"kept"`
DryRun bool `json:"dryRun"`
Items []RecoveryCleanupItem `json:"items"`
}
func (a *App) GetRecoveryLineage(key ProjectTopicKey) RecoveryLineageView {
out := RecoveryLineageView{Members: []RecoveryLineageMember{}}
catalog := a.sessionCatalog.Load()
if catalog == nil {
return out
}
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID})
if err != nil || !ok {
return out
}
groupID, directory := "", ""
for _, record := range topic.Sessions {
if record.Recovered && record.RecoveryGroupID != "" {
groupID, directory = record.RecoveryGroupID, filepath.Dir(record.Path)
break
}
}
if groupID == "" || directory == "" {
return out
}
groups, err := catalog.ListRecoveryGroups(a.bootContext(), directory)
if err != nil {
return out
}
members := []sessioncatalog.SessionRecord{}
for _, group := range groups {
if group.ID == groupID {
members = group.Members
out.State = group.State
break
}
}
out.GroupID = groupID
_, overlays := a.catalogRuntimeOverlays()
for _, record := range members {
overlay := overlays[sessionRuntimeKey(record.Path)]
out.Members = append(out.Members, RecoveryLineageMember{Path: record.Path, Role: record.RecoveryRole,
Canonical: record.RecoveryCanonical, Turns: record.Turns, Open: overlay.open, Running: overlay.running})
out.BranchCount++
if record.RecoveryRole == sessioncatalog.RecoveryRoleDiverged {
out.Unresolved++
}
if record.RecoveryRole == sessioncatalog.RecoveryRoleCoveredCopy {
out.CleanupEligible++
}
}
if out.State == "" {
out.State = topic.RecoveryState
}
if out.State == "preferred" {
out.Unresolved = 0
}
return out
}
// ChooseRecoveryBranch changes only the default open target. Diverged content
// remains on disk and is never made cleanup-eligible by this choice.
func (a *App) ChooseRecoveryBranch(req RecoveryPreferenceRequest) error {
catalog := a.sessionCatalog.Load()
if catalog == nil {
return errors.New("session catalog is unavailable")
}
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID})
if err != nil || !ok {
return errors.New("recovery lineage is unavailable")
}
groupID, dir := "", ""
for _, record := range topic.Sessions {
if record.Recovered && record.RecoveryGroupID != "" {
groupID, dir = record.RecoveryGroupID, filepath.Dir(record.Path)
break
}
}
groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
if err != nil {
return errors.New("recovery lineage is unavailable")
}
paths := []string{}
chosen := ""
for _, group := range groups {
if group.ID != groupID {
continue
}
for _, member := range group.Members {
paths = append(paths, member.Path)
if sessionRuntimeKey(member.Path) == sessionRuntimeKey(req.Path) && member.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
chosen = member.Path
}
}
}
if chosen == "" {
return errors.New("selected branch is outside the recovery lineage")
}
defer a.lockRuntimeMutation("choose-recovery-branch")()
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
if err := agent.SetRecoveryPreferred(paths, chosen); err != nil {
return errors.New("could not save the recovery branch choice")
}
if err := catalog.ReconcileDirectory(a.bootContext(), sessioncatalog.DirectoryTarget{Path: dir, Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot}); err != nil {
return errors.New("the branch choice was saved but the session catalog could not refresh")
}
a.emitProjectTreeChangedForSessionDirs(dir)
return nil
}
// CleanRecoveryLineage performs one backend-owned, revalidated cleanup batch.
// It never purges and never moves diverged content.
func (a *App) CleanRecoveryLineage(req RecoveryCleanupRequest) RecoveryCleanupResult {
result := RecoveryCleanupResult{DryRun: !req.Apply, Items: []RecoveryCleanupItem{}}
catalog := a.sessionCatalog.Load()
if catalog == nil {
return result
}
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID})
if err != nil || !ok {
return result
}
canonical := ""
rootID := ""
for _, record := range topic.Sessions {
if record.RecoveryCanonical && (record.RecoveryRole == sessioncatalog.RecoveryRoleAdopted || record.RecoveryRole == sessioncatalog.RecoveryRolePreferred) {
canonical = record.Path
rootID = record.RecoveryGroupID
break
}
}
if canonical == "" || rootID == "" {
return result
}
dir := filepath.Dir(canonical)
groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
if err != nil {
return result
}
members := []sessioncatalog.SessionRecord{}
for _, group := range groups {
if group.ID == rootID {
members = group.Members
break
}
}
candidates := []sessioncatalog.SessionRecord{}
for _, record := range members {
if record.Path == canonical || record.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
continue
}
result.Eligible++
candidates = append(candidates, record)
result.Items = append(result.Items, RecoveryCleanupItem{Path: record.Path, Status: "eligible"})
}
if !req.Apply || len(candidates) == 0 {
return result
}
defer a.lockRuntimeMutation("clean-recovery-lineage")()
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
if a.sessionOpenInAnyTab(canonical) || agent.SessionLeaseHeld(canonical) {
for index := range result.Items {
result.Items[index].Status = "busy"
result.Busy++
}
return result
}
if err := agent.ReparentRecoveryCanonical(canonical, rootID, dir); err != nil {
for index := range result.Items {
if errors.Is(err, agent.ErrSessionLeaseHeld) {
result.Items[index].Status = "busy"
result.Busy++
} else {
result.Items[index].Status = "kept"
result.Items[index].Error = "recovery branch changed and was kept"
result.Kept++
}
}
return result
}
for index, record := range candidates {
item := &result.Items[index]
if a.sessionOpenInAnyTab(record.Path) || agent.SessionLeaseHeld(record.Path) {
item.Status = "busy"
result.Busy++
continue
}
if err := agent.TrashRecoveryBranchCoveredBy(record.Path, canonical, dir); err != nil {
item.Status = "kept"
if errors.Is(err, agent.ErrSessionLeaseHeld) {
item.Status = "busy"
result.Busy++
} else {
item.Error = "recovery branch changed and was kept"
result.Kept++
}
} else {
item.Status = "moved"
result.Moved++
a.removeSessionCatalogPath(record.Path, "recovery_lineage_cleaned")
}
}
if result.Moved > 0 {
a.emitProjectTreeChangedForSessionDirs(dir)
a.invalidatePromptHistoryCache()
}
return result
}
+4 -14
View File
@@ -26,24 +26,14 @@ func (a *App) resolveCanonicalSessionPath(path string) string {
}
if rec.TopicID == "" {
// Group by recovery group when topic is unset.
if rec.RecoveryCanonical && rec.RecoveryRole == sessioncatalog.RecoveryRoleAdopted {
if rec.RecoveryCanonical && (rec.RecoveryRole == sessioncatalog.RecoveryRoleAdopted || rec.RecoveryRole == sessioncatalog.RecoveryRolePreferred) {
return ""
}
return ""
}
page, err := catalog.ListSessions(ctx, sessioncatalog.SessionPageRequest{
Scope: rec.Scope,
WorkspaceRoot: rec.WorkspaceRoot,
Limit: sessioncatalog.MaxLimit,
})
if err != nil {
topic, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{Scope: rec.Scope, WorkspaceRoot: rec.WorkspaceRoot, TopicID: rec.TopicID})
if err != nil || !ok {
return ""
}
var sameTopic []sessioncatalog.SessionRecord
for _, s := range page.Items {
if s.TopicID == rec.TopicID && s.Scope == rec.Scope && s.WorkspaceRoot == rec.WorkspaceRoot {
sameTopic = append(sameTopic, s)
}
}
return sessioncatalog.CanonicalSessionPathForTopic(sameTopic, path)
return sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, path)
}
+17 -4
View File
@@ -16,6 +16,8 @@ import (
"reasonix/internal/taskcatalog"
)
const sessionCatalogMetadataSyncTimeout = 30 * time.Second
type SessionCatalogStatus struct {
State string `json:"state"`
Mode string `json:"mode"`
@@ -155,7 +157,7 @@ func (a *App) startSessionCatalog(rebuild bool) {
return
}
a.sessionCatalog.Store(catalog)
if err := a.syncSessionCatalogMetadata(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
if err := a.syncSessionCatalogMetadataBounded(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
slog.Warn("desktop: sync session catalog metadata", "err", err)
}
select {
@@ -171,7 +173,7 @@ func (a *App) startSessionCatalog(rebuild bool) {
// Legacy assignment is deliberately background-only. It can scan and
// repair old metadata, but no project-tree or controller request waits.
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
_ = a.syncSessionCatalogMetadata(ctx, catalog)
_ = a.syncSessionCatalogMetadataBounded(ctx, catalog)
}
if err := catalog.ReconcileDirectory(ctx, target); err != nil && !errors.Is(err, context.Canceled) {
slog.Debug("desktop: reconcile session catalog directory", "dir", target.Path, "err", err)
@@ -182,12 +184,12 @@ func (a *App) startSessionCatalog(rebuild bool) {
for {
select {
case <-ticker.C:
if err := a.syncSessionCatalogMetadata(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
if err := a.syncSessionCatalogMetadataBounded(ctx, catalog); err != nil && !errors.Is(err, context.Canceled) {
slog.Debug("desktop: refresh session catalog metadata", "err", err)
}
for _, target := range a.sessionCatalogTargets() {
if migrated := migrateLegacySessionsIntoGlobalTopics(target.Path); len(migrated) > 0 {
_ = a.syncSessionCatalogMetadata(ctx, catalog)
_ = a.syncSessionCatalogMetadataBounded(ctx, catalog)
}
catalog.RequestReconcile(target)
}
@@ -322,6 +324,17 @@ func (a *App) indexRestoredSessionPaths(ctx context.Context, catalog *sessioncat
}
}
// syncSessionCatalogMetadataBounded is the only form the long-lived catalog
// goroutine may use. SyncMetadata runs under the catalog's single-writer mutex,
// so one transaction that never returns silently wedges every later index,
// reconcile, and revision bump — and the sidebar then stops updating for the
// rest of the process lifetime instead of failing loudly.
func (a *App) syncSessionCatalogMetadataBounded(ctx context.Context, catalog *sessioncatalog.Catalog) error {
ctx, cancel := context.WithTimeout(ctx, sessionCatalogMetadataSyncTimeout)
defer cancel()
return a.syncSessionCatalogMetadata(ctx, catalog)
}
func (a *App) syncSessionCatalogMetadata(ctx context.Context, catalog *sessioncatalog.Catalog) error {
f := loadProjectsFile()
deleted := map[string]bool{}
+209
View File
@@ -4,9 +4,12 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/sessioncatalog"
)
@@ -116,3 +119,209 @@ func TestProjectTreeShellSurvivesCatalogRevisionRace(t *testing.T) {
t.Fatalf("snapshot projects = %#v, want Shell Race", snapshot.Projects)
}
}
func TestUpgradeLegacyRecoveryChainNeedsNoUserActionForOneRowSidebar(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
save := func(path, topic string, messages ...provider.Message) {
t.Helper()
session := agent.NewSession("sys")
for _, message := range messages {
session.Add(message)
}
if err := session.Save(path); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
ID: agent.BranchID(path), Scope: "global", TopicID: topic,
TopicTitle: "Upgraded conversation",
}); err != nil {
t.Fatal(err)
}
}
q := provider.Message{Role: provider.RoleUser, Content: "question"}
a := provider.Message{Role: provider.RoleAssistant, Content: "answer"}
next := provider.Message{Role: provider.RoleUser, Content: "continue"}
done := provider.Message{Role: provider.RoleAssistant, Content: "done"}
root := filepath.Join(dir, "root.jsonl")
copy := filepath.Join(dir, "copy.jsonl")
leaf := filepath.Join(dir, "leaf.jsonl")
save(root, "conversation", q, a)
save(copy, "legacy-copy-topic", q, a)
save(leaf, "legacy-leaf-topic", q, a, next, done)
for path, meta := range map[string]agent.BranchMeta{
copy: {ID: "copy", Scope: "global", TopicID: "legacy-copy-topic", Recovered: true, ParentID: "root", RecoveryDepth: 1},
leaf: {ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic", Recovered: true, ParentID: "copy", RecoveryDepth: 2},
} {
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
t.Fatal(err)
}
}
app := NewApp()
installSessionCatalogForTest(t, app, dir, "global", "")
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].TopicID != "conversation" || len(page.Items[0].Children) != 0 {
t.Fatalf("zero-touch upgraded sidebar = %+v, want one ordinary conversation row", page.Items)
}
if page.Items[0].RecoveryBranchCount != 0 || page.Items[0].RecoveryUnresolvedCount != 0 ||
page.Items[0].Status == topicStatusDivergedRecovery {
// Recovery counts and conflict status belong only to History advanced
// views; the ordinary root row must stay a plain conversation line.
t.Fatalf("ordinary row leaked recovery decoration: %+v", page.Items[0])
}
}
func TestListProjectTopicsSurfacesLiveTabWhileCatalogLags(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if err := addProject(root, "Lagging Catalog"); err != nil {
t.Fatal(err)
}
sessionDir := desktopSessionDir(root)
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatal(err)
}
app := NewApp()
installSessionCatalogForTest(t, app, sessionDir, "project", root)
app.tabs["tab-1"] = &WorkspaceTab{
ID: "tab-1", Scope: "project", WorkspaceRoot: root,
TopicID: "topic_20260812-082637_live", TopicTitle: "Ownership Hub",
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].TopicID != "topic_20260812-082637_live" {
t.Fatalf("page items = %#v, want the live tab's topic", page.Items)
}
if page.Items[0].CreatedAt <= 0 {
t.Fatalf("createdAt = %d, want the topic's creation time", page.Items[0].CreatedAt)
}
}
func TestListProjectTopicsDoesNotDuplicateIndexedLiveTab(t *testing.T) {
isolateDesktopUserDirs(t)
root := t.TempDir()
if err := addProject(root, "Indexed"); err != nil {
t.Fatal(err)
}
sessionDir := desktopSessionDir(root)
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
t.Fatal(err)
}
sessionPath := filepath.Join(sessionDir, "live.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := agent.UpdateBranchMeta(sessionPath, false, func(meta *agent.BranchMeta) error {
meta.TopicID = "topic-indexed"
meta.TopicTitle = "Indexed Topic"
meta.WorkspaceRoot = root
meta.Scope = "project"
return nil
}); err != nil {
t.Fatal(err)
}
app := NewApp()
installSessionCatalogForTest(t, app, sessionDir, "project", root)
app.tabs["tab-1"] = &WorkspaceTab{
ID: "tab-1", Scope: "project", WorkspaceRoot: root,
TopicID: "topic-indexed", TopicTitle: "Indexed Topic", SessionPath: sessionPath,
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 {
t.Fatalf("page items = %#v, want the catalog row only", page.Items)
}
}
func TestListProjectTopicsFoldsRestoredLegacyTopicTabIntoOneRow(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
save := func(path, topic string) {
t.Helper()
session := agent.NewSession("sys")
session.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
session.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
if err := session.Save(path); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{
ID: agent.BranchID(path), Scope: "global", TopicID: topic,
TopicTitle: "Upgraded conversation",
}); err != nil {
t.Fatal(err)
}
}
root := filepath.Join(dir, "root.jsonl")
copyPath := filepath.Join(dir, "copy.jsonl")
save(root, "conversation")
save(copyPath, "legacy-copy-topic")
if err := agent.SaveBranchMetaPreserveUpdated(copyPath, agent.BranchMeta{
ID: "copy", Scope: "global", TopicID: "legacy-copy-topic",
Recovered: true, ParentID: "root", RecoveryDepth: 1,
}); err != nil {
t.Fatal(err)
}
app := NewApp()
installSessionCatalogForTest(t, app, dir, "global", "")
// A restored pre-upgrade tab still carries the legacy topic ID for the
// recovery copy that the catalog re-anchored onto the root logical topic.
// withLiveTopics must dedupe by the projected topic, not the tab's one,
// so the sidebar keeps exactly one row for the conversation.
app.tabs["tab-1"] = &WorkspaceTab{
ID: "tab-1", Scope: "global",
TopicID: "legacy-copy-topic", TopicTitle: "Upgraded conversation",
SessionPath: copyPath,
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].TopicID != "conversation" {
t.Fatalf("page items = %#v, want one folded conversation row", page.Items)
}
if !page.Items[0].Open {
t.Fatal("open recovery tab must aggregate onto the logical row")
}
}
// The catalog goroutine outlives every request, so an unbounded metadata sync
// there can hold the catalog's single-writer mutex forever and freeze the whole
// sidebar. Guard the call shape, not just today's behaviour.
func TestSessionCatalogGoroutineOnlySyncsMetadataWithATimeout(t *testing.T) {
source, err := os.ReadFile("session_catalog.go")
if err != nil {
t.Fatal(err)
}
body := string(source)
start := strings.Index(body, "func (a *App) startSessionCatalog(")
if start < 0 {
t.Fatal("startSessionCatalog not found")
}
end := strings.Index(body[start+1:], "\nfunc ")
if end < 0 {
t.Fatal("end of startSessionCatalog not found")
}
for line := range strings.SplitSeq(body[start:start+1+end], "\n") {
if !strings.Contains(line, "syncSessionCatalogMetadata(") {
continue
}
if !strings.Contains(line, "syncSessionCatalogMetadataBounded(") {
t.Fatalf("unbounded metadata sync in startSessionCatalog: %s", strings.TrimSpace(line))
}
}
}
+48 -11
View File
@@ -41,18 +41,16 @@ func TestProjectNodeFromCatalogTopicFiltersIdleRecoveryCopies(t *testing.T) {
sessionOverlays := map[string]catalogRuntimeOverlay{
sessionRuntimeKey("/s/open-copy.jsonl"): {open: true},
}
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, sessionOverlays)
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, sessionOverlays, nil)
if !ok {
t.Fatal("mixed topic should stay visible")
}
// Parent + open copy remain; idle copy is filtered. Two sessions => children.
if len(node.Children) != 2 {
t.Fatalf("children = %d, want parent + open recovery copy", len(node.Children))
// Ordinary list is always one logical row; open recovery only flips Open.
if len(node.Children) != 0 {
t.Fatalf("children = %d, want collapsed logical row", len(node.Children))
}
for _, child := range node.Children {
if child.SessionPath == "/s/copy.jsonl" {
t.Fatal("idle recovery copy must not appear in project tree children")
}
if !node.Open {
t.Fatal("open recovery member must aggregate onto the logical row")
}
}
@@ -64,7 +62,7 @@ func TestProjectNodeFromCatalogTopicHidesRecoveryOnlyIdleTopic(t *testing.T) {
{Path: "/s/only.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
},
}
if _, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}); ok {
if _, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil); ok {
t.Fatal("idle recovery-only topic must be hidden from the ordinary tree")
}
}
@@ -77,7 +75,7 @@ func TestProjectNodeFromCatalogTopicKeepsPinnedRecoveryOnlyTopic(t *testing.T) {
{Path: "/s/pinned.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
},
}
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{})
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
if !ok {
t.Fatal("pinned recovery-only topic must remain visible")
}
@@ -96,7 +94,7 @@ func TestProjectNodeFromCatalogTopicCollapsesWhenOnlyOneVisible(t *testing.T) {
{Path: "/s/copy.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK},
},
}
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{})
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
if !ok {
t.Fatal("topic with parent should stay visible")
}
@@ -105,6 +103,45 @@ func TestProjectNodeFromCatalogTopicCollapsesWhenOnlyOneVisible(t *testing.T) {
}
}
func TestProjectNodeFromCatalogTopicCollapsesDivergedForksToOne(t *testing.T) {
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
topic := sessioncatalog.TopicRecord{
Scope: "global", TopicID: "forks", Title: "WebView", Turns: 5,
Sessions: []sessioncatalog.SessionRecord{
{Path: "/s/root.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 10},
{Path: "/s/fork-a.jsonl", Turns: 5, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root", LastActivityAt: 30},
{Path: "/s/fork-b.jsonl", Turns: 4, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root", LastActivityAt: 40},
},
}
node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil)
if !ok {
t.Fatal("topic with parent should stay visible")
}
// Parent remains; idle diverged forks hide while the normal root exists.
if len(node.Children) != 0 {
t.Fatalf("children = %d, want collapsed parent-only row, children=%+v", len(node.Children), node.Children)
}
}
func TestProjectNodeFromCatalogTopicHidesOrphanForkTopicsWhenPreferredElsewhere(t *testing.T) {
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
// Separate topic that only holds a non-preferred diverged fork.
topic := sessioncatalog.TopicRecord{
Scope: "global", TopicID: "orphan-fork", Title: "WebView",
Sessions: []sessioncatalog.SessionRecord{
{Path: "/s/fork-only.jsonl", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK,
Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root"},
},
}
// Workspace preference already chose the parent path for this lineage.
preferred := map[string]struct{}{"/s/root.jsonl": {}}
if _, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, preferred); ok {
t.Fatal("orphan non-preferred recovery topic must stay out of the ordinary tree")
}
}
func TestListProjectTopicsSkipsRecoveryOnlyPages(t *testing.T) {
ctx := context.Background()
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{
+180 -51
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"errors"
"path/filepath"
"sort"
@@ -11,6 +12,15 @@ import (
"reasonix/internal/sessioncatalog"
)
// Sidebar reads are bound so a starved connection pool or a slow projection
// degrades into a stale page the next revision repairs, instead of a Wails call
// that never returns and a tree that never moves again.
const sessionCatalogReadTimeout = 10 * time.Second
func (a *App) catalogReadContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(a.bootContext(), sessionCatalogReadTimeout)
}
type catalogRuntimeSnapshot struct {
scope string
workspaceRoot string
@@ -172,6 +182,15 @@ func (a *App) metadataProjectTopics(scope, workspaceRoot string) []ProjectNode {
}
func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNode {
nodes, _ := a.runtimeOnlyProjectTopicsWithSessions(scope, workspaceRoot)
return nodes
}
// runtimeOnlyProjectTopicsWithSessions also reports each runtime topic's known
// session paths so callers can resolve the topics those sessions project onto
// in the catalog (a restored tab may carry a legacy topic ID for a re-anchored
// recovery lineage).
func (a *App) runtimeOnlyProjectTopicsWithSessions(scope, workspaceRoot string) ([]ProjectNode, map[string][]string) {
a.mu.RLock()
snapshots := []catalogRuntimeSnapshot{}
collect := func(tab *WorkspaceTab, open bool) {
@@ -199,11 +218,15 @@ func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNod
}
a.mu.RUnlock()
byTopic := map[string][]catalogRuntimeSnapshot{}
sessionsByTopic := map[string][]string{}
for _, snapshot := range snapshots {
if snapshot.sessionPath == "" && snapshot.ctrl != nil {
snapshot.sessionPath = snapshot.ctrl.SessionPath()
}
byTopic[snapshot.topicID] = append(byTopic[snapshot.topicID], snapshot)
if path := strings.TrimSpace(snapshot.sessionPath); path != "" {
sessionsByTopic[snapshot.topicID] = append(sessionsByTopic[snapshot.topicID], path)
}
}
topicIDs := make([]string, 0, len(byTopic))
for topicID := range byTopic {
@@ -256,7 +279,7 @@ func (a *App) runtimeOnlyProjectTopics(scope, workspaceRoot string) []ProjectNod
}
out = append(out, node)
}
return out
return out, sessionsByTopic
}
func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage {
@@ -295,7 +318,7 @@ func (a *App) metadataTopicPage(req ProjectTopicPageRequest) ProjectTopicPage {
return page
}
func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay) (ProjectNode, bool) {
func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay, preferred map[string]struct{}) (ProjectNode, bool) {
kind := "topic"
if topic.Scope == "global" {
kind = "global_topic"
@@ -307,15 +330,35 @@ func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topi
TurnsState: string(topic.TurnsState), Health: string(topic.Health),
CreatedAt: topic.CreatedAt, LastActivityAt: topic.LastActivityAt,
Pinned: topic.Pinned, Open: overlay.open, Running: overlay.running, Status: overlay.status,
// Ordinary tree is zero-config: never surface recovery counts, badges,
// or forced-handling status. History "other saved versions" owns that.
Children: []ProjectNode{},
}
// Fall back to topic-local preference when the workspace map is unavailable
// so multi-fork topics still collapse instead of listing every replica.
localPreferred := preferred
if localPreferred == nil {
localPreferred = sessioncatalog.PreferredOrdinarySessionPaths(topic.Sessions)
}
visible := make([]sessioncatalog.SessionRecord, 0, len(topic.Sessions))
runtimeSessions := make([]runtimeSessionStatus, 0, len(topic.Sessions))
for _, session := range topic.Sessions {
sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)]
// Idle covered recovery copies stay out of the ordinary tree. Open or
// running copies remain reachable so the user can still inspect them.
if session.RecoveryCopy && !sessionOverlay.open && !sessionOverlay.running {
// Aggregate open/running state from every physical member onto the
// single logical row — never expand recovery runtimes as children.
if sessionOverlay.open {
node.Open = true
}
if sessionOverlay.running {
node.Running = true
if node.Status == "" {
node.Status = sessionOverlay.status
}
}
// 1.23 ordinary-list contract: hide idle covered copies and non-
// preferred conflict forks. Open/running recovery is still not a
// second row — status is already aggregated above.
if !sessioncatalog.OrdinaryTreeSession(session, false, false, localPreferred) {
continue
}
visible = append(visible, session)
@@ -325,50 +368,27 @@ func (a *App) projectNodeFromCatalogTopic(topic sessioncatalog.TopicRecord, topi
}
summary := topicSummaryFromCatalogTopic(topic, visible)
if topicHiddenAsRecoveryOnly(summary, topic.Pinned, append(runtimeSessions, runtimeSessionStatus{
open: overlay.open, running: overlay.running,
open: overlay.open || node.Open, running: overlay.running || node.Running,
})) {
return ProjectNode{Children: []ProjectNode{}}, false
}
// A single effective session collapses to a normal topic row.
if len(visible) <= 1 {
return node, true
// After filtering non-preferred recovery forks, a topic may have nothing
// left. Keep pinned/open shells; otherwise drop the empty row.
if len(visible) == 0 {
if topic.Pinned || overlay.open || overlay.running || node.Open || node.Running {
return node, true
}
return ProjectNode{Children: []ProjectNode{}}, false
}
diverged := 0
for _, session := range visible {
if session.RecoveryRole == sessioncatalog.RecoveryRoleDiverged ||
(session.Recovered && !session.RecoveryCopy && session.RecoveryRole != sessioncatalog.RecoveryRoleAdopted) {
diverged++
}
}
if diverged >= 2 {
// Non-destructive choice prompt. The frontend renders the label from
// this status so it stays translated.
node.Status = topicStatusDivergedRecovery
}
for _, session := range visible {
sessionKind := "session"
if topic.Scope == "global" {
sessionKind = "global_session"
}
sessionOverlay := sessionOverlays[sessionRuntimeKey(session.Path)]
label := strings.TrimSpace(session.CustomTitle)
if label == "" {
label = strings.TrimSpace(session.Preview)
}
if label == "" {
label = filepath.Base(session.Path)
}
node.Children = append(node.Children, ProjectNode{
Key: projectSessionNodeKey(topic.Scope, session.Path), Kind: sessionKind,
Label: label, Root: topic.WorkspaceRoot, TopicID: topic.TopicID,
SessionPath: session.Path, Turns: session.Turns,
TurnsState: string(session.TurnsState), Health: string(session.Health),
CreatedAt: session.CreatedAt, LastActivityAt: session.LastActivityAt,
Open: sessionOverlay.open, Running: sessionOverlay.running, Status: sessionOverlay.status,
Recovered: session.Recovered, RecoveryReason: session.RecoveryReason,
RecoveryDigest: session.RecoveryDigest, RecoveryParentID: session.ParentID,
Children: []ProjectNode{},
})
// Ordinary list is always one logical row. Multiple normal non-recovery
// sessions under one topic also collapse: open/running already aggregated.
// History "other saved versions" is the only place physical forks appear.
if rep := strings.TrimSpace(topic.RepresentativePath); rep != "" {
node.SessionPath = rep
} else if path := sessioncatalog.CanonicalSessionPathForTopic(visible, ""); path != "" {
node.SessionPath = path
} else if len(visible) == 1 {
node.SessionPath = visible[0].Path
}
return node, true
}
@@ -403,11 +423,100 @@ func topicSummaryFromCatalogTopic(topic sessioncatalog.TopicRecord, visible []se
}
func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage, error) {
out := ProjectTopicPage{Items: []ProjectNode{}}
catalog := a.sessionCatalog.Load()
if catalog == nil {
// Catalog unavailable: use project metadata shells only. Never scan
// every recovery JSONL into the ordinary tree (1.23 contract).
// While opening/rebuilding with a live catalog, ListTopics already
// skips non-ordinary recovery shells so empty pages beat a replica wall.
return a.metadataTopicPage(req), nil
}
page, err := a.catalogTopicPage(catalog, req)
if err != nil {
return page, err
}
return a.withLiveTopics(catalog, req, page), nil
}
// withLiveTopics restores topics the catalog does not (yet) carry. A tab is
// authoritative for its own existence, while the catalog is a projection that
// can lag a fresh session, fall behind a stalled writer, or run degraded — and
// the sidebar must never hide a conversation this app is running. Only an
// uncursored page merges, so keyset pagination past it stays the catalog's.
func (a *App) withLiveTopics(catalog *sessioncatalog.Catalog, req ProjectTopicPageRequest, page ProjectTopicPage) ProjectTopicPage {
if strings.TrimSpace(req.Cursor) != "" {
return page
}
indexed := make(map[string]bool, len(page.Items))
for _, item := range page.Items {
indexed[item.TopicID] = true
}
query := strings.ToLower(strings.TrimSpace(req.Query))
live := []ProjectNode{}
runtimeNodes, sessionsByTopic := a.runtimeOnlyProjectTopicsWithSessions(req.Scope, req.WorkspaceRoot)
ctx, cancel := a.catalogReadContext()
defer cancel()
for _, node := range runtimeNodes {
if indexed[node.TopicID] {
continue
}
// A restored tab may still carry a legacy topic ID for a recovery
// session the catalog re-anchored onto the root logical topic. That
// logical row already represents the conversation, so a second
// runtime-only row would break the one-row ordinary-list contract.
if liveTopicProjectedOnPage(ctx, catalog, sessionsByTopic[node.TopicID], indexed) {
continue
}
if query != "" && !strings.Contains(strings.ToLower(node.Label), query) {
continue
}
live = append(live, node)
}
if len(live) == 0 {
return page
}
f := loadProjectsFile()
deleted := map[string]bool{}
for _, topicID := range f.DeletedTopics {
deleted[topicID] = true
}
created := loadTopicCreatedAts(topicTitleRoot(req.Scope, req.WorkspaceRoot))
kept := page.Items[:0:0]
for _, node := range live {
if deleted[node.TopicID] {
continue
}
node.CreatedAt = topicCreatedAtForTree(created, node.TopicID)
node.LastActivityAt = node.CreatedAt
kept = append(kept, node)
}
page.Items = append(kept, page.Items...)
return page
}
// liveTopicProjectedOnPage reports whether every catalog-known session of a
// runtime-only topic already projects onto a topic on this page. Any session
// the catalog has not indexed yet keeps the live row (that is the lag case
// withLiveTopics exists for), and an off-page projection also keeps it so an
// open conversation never disappears from the first page.
func liveTopicProjectedOnPage(ctx context.Context, catalog *sessioncatalog.Catalog, paths []string, indexed map[string]bool) bool {
if catalog == nil || len(paths) == 0 {
return false
}
for _, path := range paths {
record, ok, err := catalog.GetSession(ctx, path)
if err != nil || !ok {
return false
}
if !indexed[record.TopicID] {
return false
}
}
return true
}
func (a *App) catalogTopicPage(catalog *sessioncatalog.Catalog, req ProjectTopicPageRequest) (ProjectTopicPage, error) {
out := ProjectTopicPage{Items: []ProjectNode{}}
limit := req.Limit
if limit <= 0 {
limit = sessioncatalog.DefaultLimit
@@ -416,12 +525,20 @@ func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage,
limit = sessioncatalog.MaxLimit
}
topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
ctx, cancel := a.catalogReadContext()
defer cancel()
// Workspace-wide preference collapses cross-topic recovery replicas that
// share a lineage but were indexed as separate topic rows.
preferred, prefErr := catalog.PreferredOrdinarySessionPaths(ctx, req.Scope, req.WorkspaceRoot)
if prefErr != nil {
preferred = nil
}
cursor := req.Cursor
// Keep scanning past pages that are entirely idle recovery copies so the
// sidebar never shows an empty "no sessions" state when later pages still
// have ordinary topics.
for {
page, err := catalog.ListTopics(a.bootContext(), sessioncatalog.TopicPageRequest{
page, err := catalog.ListTopics(ctx, sessioncatalog.TopicPageRequest{
Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, Cursor: cursor,
Limit: limit, Query: req.Query, TimeFilter: req.TimeFilter,
})
@@ -430,7 +547,7 @@ func (a *App) ListProjectTopics(req ProjectTopicPageRequest) (ProjectTopicPage,
}
out.Revision = page.Revision
for i, topic := range page.Items {
node, ok := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays)
node, ok := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays, preferred)
if !ok {
continue
}
@@ -462,7 +579,9 @@ func encodeProjectTopicCursor(topic sessioncatalog.TopicRecord) string {
func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) {
if catalog := a.sessionCatalog.Load(); catalog != nil {
topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{
ctx, cancel := a.catalogReadContext()
defer cancel()
topic, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{
Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID,
})
if err != nil {
@@ -470,7 +589,8 @@ func (a *App) GetTopicSummary(key ProjectTopicKey) (ProjectNode, error) {
}
if ok {
topicOverlays, sessionOverlays := a.catalogRuntimeOverlays()
if node, visible := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays); visible {
preferred, _ := catalog.PreferredOrdinarySessionPaths(ctx, key.Scope, key.WorkspaceRoot)
if node, visible := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays, preferred); visible {
return node, nil
}
return ProjectNode{Children: []ProjectNode{}}, nil
@@ -572,8 +692,17 @@ func (a *App) catalogSessionPathForTopic(scope, workspaceRoot, topicID string) s
if err != nil || !ok || len(topic.Sessions) == 0 {
return ""
}
if canonical := sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, ""); canonical != "" {
return canonical
}
preferred := sessioncatalog.PreferredOrdinarySessionPaths(topic.Sessions)
sort.SliceStable(topic.Sessions, func(i, j int) bool {
// Prefer real conversations over idle covered recovery copies.
// Prefer ordinary-tree survivors, then real conversations over copies.
iPref := sessioncatalog.OrdinaryTreeSession(topic.Sessions[i], false, false, preferred)
jPref := sessioncatalog.OrdinaryTreeSession(topic.Sessions[j], false, false, preferred)
if iPref != jPref {
return iPref
}
if topic.Sessions[i].RecoveryCopy != topic.Sessions[j].RecoveryCopy {
return !topic.Sessions[i].RecoveryCopy
}
+26 -22
View File
@@ -6443,28 +6443,32 @@ func loadTelemetry(path string) tabTelemetrySnapshot {
// ProjectNode is one node in the sidebar project tree (a project folder or a
// topic leaf).
type ProjectNode struct {
Key string `json:"key"` // stable key for React
Kind string `json:"kind"` // "project" | "topic" | "session" | "global_folder" | "global_topic" | "global_session"
Label string `json:"label"`
Root string `json:"root,omitempty"` // project workspace root
TopicID string `json:"topicId,omitempty"`
SessionPath string `json:"sessionPath,omitempty"`
ProjectColor string `json:"projectColor,omitempty"`
Turns int `json:"turns,omitempty"`
TurnsState string `json:"turnsState,omitempty"`
Health string `json:"health,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
Open bool `json:"open,omitempty"`
Running bool `json:"running,omitempty"`
Status string `json:"status,omitempty"`
Pinned bool `json:"pinned,omitempty"`
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recoveryReason,omitempty"`
RecoveryDigest string `json:"recoveryDigest,omitempty"`
RecoveryParentID string `json:"recoveryParentId,omitempty"`
IsolatedWorktree bool `json:"isolatedWorktree,omitempty"`
Children []ProjectNode `json:"children,omitempty"`
Key string `json:"key"` // stable key for React
Kind string `json:"kind"` // "project" | "topic" | "session" | "global_folder" | "global_topic" | "global_session"
Label string `json:"label"`
Root string `json:"root,omitempty"` // project workspace root
TopicID string `json:"topicId,omitempty"`
SessionPath string `json:"sessionPath,omitempty"`
ProjectColor string `json:"projectColor,omitempty"`
Turns int `json:"turns,omitempty"`
TurnsState string `json:"turnsState,omitempty"`
Health string `json:"health,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
Open bool `json:"open,omitempty"`
Running bool `json:"running,omitempty"`
Status string `json:"status,omitempty"`
Pinned bool `json:"pinned,omitempty"`
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recoveryReason,omitempty"`
RecoveryDigest string `json:"recoveryDigest,omitempty"`
RecoveryParentID string `json:"recoveryParentId,omitempty"`
RecoveryState string `json:"recoveryState,omitempty"`
RecoveryBranchCount int `json:"recoveryBranchCount,omitempty"`
RecoveryUnresolvedCount int `json:"recoveryUnresolvedCount,omitempty"`
RecoveryCleanupEligibleCount int `json:"recoveryCleanupEligibleCount,omitempty"`
IsolatedWorktree bool `json:"isolatedWorktree,omitempty"`
Children []ProjectNode `json:"children,omitempty"`
}
func normalizeTopicStatus(status string) string {
+23 -12
View File
@@ -763,24 +763,35 @@ func TestAmbiguousLegacyRecoverySessionsMigrateIntoTopics(t *testing.T) {
}
app := NewApp()
nodes := waitForCatalogTopic(t, app, "global", "", legacySessionTopicID(recovery))
// Filename recovery folds into the root ordinary row; History keeps the
// physical recovery file reachable as another saved version.
app.startSessionCatalog(false)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
nodes := waitForCatalogTreeCondition(t, app, "filename recovery folded into one ordinary row", func(nodes []ProjectNode) bool {
for _, folder := range nodes {
if folder.Kind != "global_folder" {
continue
}
if len(folder.Children) != 1 {
return false
}
return folder.Children[0].TopicID == legacySessionTopicID(normal)
}
return false
})
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
t.Fatalf("project tree = %#v, want global folder", nodes)
}
if got := len(nodes[0].Children); got != 2 {
t.Fatalf("global migrated topics = %d, want both sessions preserved: %#v", got, nodes[0].Children)
if got := len(nodes[0].Children); got != 1 {
t.Fatalf("global ordinary topics = %d, want 1 folded conversation: %#v", got, nodes[0].Children)
}
wantTopics := map[string]bool{legacySessionTopicID(normal): true, legacySessionTopicID(recovery): true}
for _, node := range nodes[0].Children {
delete(wantTopics, node.TopicID)
if _, err := os.Stat(recovery); err != nil {
t.Fatalf("physical recovery file must remain on disk: %v", err)
}
if len(wantTopics) != 0 {
t.Fatalf("migrated topics missing %v: %#v", wantTopics, nodes[0].Children)
}
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil || !ok {
if meta, ok, err := agent.LoadBranchMeta(recovery); err != nil {
t.Fatalf("load recovery meta: %v", err)
} else if strings.TrimSpace(meta.TopicID) == "" {
t.Fatal("ambiguous legacy recovery branch was not migrated into a visible topic")
} else if ok && strings.TrimSpace(meta.TopicID) == "" && !agent.LooksLikeRecoveryFilename(recovery) {
t.Fatal("legacy recovery branch lost both meta topic and filename lineage")
}
}
+1 -16
View File
@@ -365,22 +365,7 @@ func sessionInfoIsUnmodifiedRecoveryCopy(info agent.SessionInfo, parentDir strin
}
func isAutomaticRecoverySessionPath(path string) bool {
id := agent.BranchID(path)
idx := strings.LastIndex(id, "-recovery-")
if idx <= 0 {
return false
}
suffix := id[idx+len("-recovery-"):]
if len(suffix) != 16 {
return false
}
for _, r := range suffix {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
continue
}
return false
}
return true
return agent.LooksLikeRecoveryFilename(path)
}
func legacyMigrationTargetForDir(dir string) (scope, workspaceRoot, topicTitleRoot string, ok bool) {
+9 -4
View File
@@ -48,10 +48,15 @@ type BranchMeta struct {
// refuses to fork past SessionRecoveryMaxDepth so a conflict loop cannot
// spawn unbounded nested recovery chains (#5993 reached 8 levels). Legacy
// recovery metas without the field are treated as depth 1.
RecoveryDepth int `json:"recovery_depth,omitempty"`
Revision int64 `json:"revision,omitempty"`
ContentDigest string `json:"content_digest,omitempty"`
WriterID string `json:"writer_id,omitempty"`
RecoveryDepth int `json:"recovery_depth,omitempty"`
// RecoveryPreferred is a user's explicit choice among genuinely diverged
// recovery leaves. It changes the default open target, but never authorizes
// deletion and is cleared automatically if that leaf is no longer valid.
RecoveryPreferred bool `json:"recovery_preferred,omitempty"`
RecoveryPreferredDigest string `json:"recovery_preferred_digest,omitempty"`
Revision int64 `json:"revision,omitempty"`
ContentDigest string `json:"content_digest,omitempty"`
WriterID string `json:"writer_id,omitempty"`
// SchemaVersion records the BranchMeta version that last wrote the listing
// fields (Turns/Preview) FROM the session's content. It is stamped only by the
// writers that actually derive those counts — Controller.snapshot's
+80
View File
@@ -0,0 +1,80 @@
package agent
import (
"path/filepath"
"strings"
)
// RecoveryFilenameParentID recognizes legacy automatic recovery files named
// <stem>-recovery-<16 hex>.jsonl when meta is missing or incomplete. The parent
// ID is the stem before the final -recovery-<hex> suffix (not a content proof).
func RecoveryFilenameParentID(path string) (string, bool) {
id := BranchID(path)
parent, ok := peelRecoveryFilenameSuffix(id)
if !ok {
return "", false
}
return parent, true
}
// RecoveryFilenameRootID peels nested -recovery-<16 hex> suffixes to the
// non-recovery stem used as a stable recovery-group key for old files.
func RecoveryFilenameRootID(path string) (string, bool) {
id := BranchID(path)
root, ok := peelRecoveryFilenameSuffix(id)
if !ok {
return "", false
}
for {
next, nested := peelRecoveryFilenameSuffix(root)
if !nested {
break
}
root = next
}
if strings.TrimSpace(root) == "" {
return "", false
}
return root, true
}
// LooksLikeRecoveryFilename reports whether the path follows the automatic
// recovery naming convention used before meta always carried Recovered=true.
func LooksLikeRecoveryFilename(path string) bool {
_, ok := peelRecoveryFilenameSuffix(BranchID(path))
return ok
}
func peelRecoveryFilenameSuffix(id string) (string, bool) {
id = strings.TrimSpace(id)
const marker = "-recovery-"
index := strings.LastIndex(id, marker)
if index <= 0 {
return "", false
}
suffix := id[index+len(marker):]
if len(suffix) != 16 || !isLowerHex(suffix) {
return "", false
}
parent := strings.TrimSpace(id[:index])
if parent == "" || parent == "." || filepath.Base(parent) != parent {
return "", false
}
return parent, true
}
func isLowerHex(value string) bool {
if value == "" {
return false
}
for _, r := range value {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
case r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
+21
View File
@@ -0,0 +1,21 @@
package agent
import "testing"
func TestRecoveryFilenameParentAndRoot(t *testing.T) {
t.Parallel()
parent, ok := RecoveryFilenameParentID("/tmp/session-recovery-aabbccddeeff0011.jsonl")
if !ok || parent != "session" {
t.Fatalf("parent = %q ok=%v", parent, ok)
}
root, ok := RecoveryFilenameRootID("/tmp/session-recovery-aabbccddeeff0011.jsonl")
if !ok || root != "session" {
t.Fatalf("root = %q ok=%v", root, ok)
}
if LooksLikeRecoveryFilename("/tmp/session.jsonl") {
t.Fatal("plain session should not match")
}
if !LooksLikeRecoveryFilename("/tmp/session-recovery-AABBCCDDEEFF0011.jsonl") {
t.Fatal("uppercase hex should match")
}
}
+196 -8
View File
@@ -7,10 +7,12 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/fileutil"
"reasonix/internal/provider"
"reasonix/internal/store"
)
@@ -88,18 +90,109 @@ func RecoveryBranchCoveredByParent(path, parentDir string) bool {
// history stored in covered. It is intentionally conservative for catalog
// canonical promotion: repaired or damaged loads cannot authorize a redirect.
func SessionContentCovers(coveringPath, coveredPath string) bool {
covering, err := LoadSession(coveringPath)
if err != nil || covering == nil || covering.normalizedDirty || covering.eventLogDamaged {
covering, ok := LoadSessionContentSnapshot(coveringPath)
if !ok {
return false
}
covered, err := LoadSession(coveredPath)
if err != nil || covered == nil || covered.normalizedDirty || covered.eventLogDamaged {
covered, ok := LoadSessionContentSnapshot(coveredPath)
return ok && covering.Covers(covered)
}
// SetRecoveryPreferred records exactly one explicit preferred leaf. It clears
// old choices first, so interruption can only fall back to an unresolved group;
// it can never leave two canonical choices.
func SetRecoveryPreferred(paths []string, chosenPath string) error {
chosenPath = canonicalSessionSavePath(chosenPath)
if chosenPath == "" {
return fmt.Errorf("empty preferred recovery path")
}
unique := map[string]struct{}{}
for _, path := range paths {
path = canonicalSessionSavePath(path)
if path != "" {
unique[path] = struct{}{}
}
}
if _, ok := unique[chosenPath]; !ok {
return fmt.Errorf("preferred recovery path is outside the lineage")
}
ordered := make([]string, 0, len(unique))
for path := range unique {
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok || !meta.Recovered {
return fmt.Errorf("invalid recovery lineage member")
}
ordered = append(ordered, path)
}
sort.Strings(ordered)
for _, path := range ordered {
if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error {
meta.RecoveryPreferred = false
meta.RecoveryPreferredDigest = ""
return nil
}); err != nil {
return err
}
}
chosen, err := LoadSession(chosenPath)
if err != nil || chosen == nil || chosen.normalizedDirty || chosen.eventLogDamaged {
return fmt.Errorf("could not fingerprint preferred recovery branch")
}
digest, err := digestSessionMessages(chosen.Snapshot())
if err != nil {
return err
}
return UpdateBranchMeta(chosenPath, false, func(meta *BranchMeta) error {
if !meta.Recovered {
return fmt.Errorf("preferred session is not a recovery branch")
}
meta.RecoveryPreferred = true
meta.RecoveryPreferredDigest = digestString(digest)
return nil
})
}
// RecoveryPreferenceCurrent proves that the branch still has the exact content
// the user selected. Continued or externally edited branches fall back to an
// unresolved lineage until the user chooses again.
func RecoveryPreferenceCurrent(path string, meta BranchMeta) bool {
if !meta.RecoveryPreferred || strings.TrimSpace(meta.RecoveryPreferredDigest) == "" {
return false
}
coveringMessages := covering.Snapshot()
coveredMessages := covered.Snapshot()
return messagesHavePrefix(coveringMessages, coveredMessages) ||
messagesHavePrefixWithCompatibleSystem(coveringMessages, coveredMessages)
session, err := LoadSession(path)
if err != nil || session == nil || session.normalizedDirty || session.eventLogDamaged {
return false
}
digest, err := digestSessionMessages(session.Snapshot())
return err == nil && digestString(digest) == strings.TrimSpace(meta.RecoveryPreferredDigest)
}
// SessionContentSnapshot is an immutable, validated transcript projection used
// by the catalog. Its messages stay private so callers cannot accidentally use
// a classification read as a mutable Session.
type SessionContentSnapshot struct {
messages []provider.Message
}
// LoadSessionContentSnapshot loads one transcript once for lineage analysis.
// Dirty normalization and damaged event logs fail closed: neither may prove a
// canonical branch or authorize cleanup.
func LoadSessionContentSnapshot(path string) (SessionContentSnapshot, bool) {
session, err := LoadSession(path)
if err != nil || session == nil || session.normalizedDirty || session.eventLogDamaged {
return SessionContentSnapshot{}, false
}
return SessionContentSnapshot{messages: session.Snapshot()}, true
}
// Len is used only to discard candidates that cannot cover the longest member.
func (s SessionContentSnapshot) Len() int { return len(s.messages) }
// Covers reports whether s contains all content in covered as a compatible
// prefix. Both snapshots have already passed the conservative load checks.
func (s SessionContentSnapshot) Covers(covered SessionContentSnapshot) bool {
return messagesHavePrefix(s.messages, covered.messages) ||
messagesHavePrefixWithCompatibleSystem(s.messages, covered.messages)
}
// TryAcquireRecoveryParentGuard verifies that a recovery branch is covered by
@@ -238,6 +331,101 @@ func TrashCoveredRecoveryBranch(path, parentDir string) error {
return trashCoveredRecoveryBranch(path, parentDir, false)
}
// TrashRecoveryBranchCoveredBy moves path to recoverable trash when canonical
// contains its complete transcript. Unlike TrashCoveredRecoveryBranch this is
// lineage-aware: legacy recovery storms form chains, so an adopted leaf may
// cover an ancestor even though that ancestor's immediate parent does not cover
// it. Both transcripts are held behind removal guards while coverage is proved.
func TrashRecoveryBranchCoveredBy(path, canonicalPath, parentDir string) error {
path = filepath.Clean(strings.TrimSpace(path))
canonicalPath = filepath.Clean(strings.TrimSpace(canonicalPath))
parentDir = filepath.Clean(strings.TrimSpace(parentDir))
if path == "." || canonicalPath == "." || parentDir == "." || path == canonicalPath ||
filepath.Dir(path) != parentDir || filepath.Dir(canonicalPath) != parentDir {
return ErrRecoveryBranchNotCovered
}
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok || !meta.Recovered {
return ErrRecoveryBranchNotCovered
}
paths := []string{path, canonicalPath}
sort.Strings(paths)
guards := make(map[string]*SessionRemovalGuard, len(paths))
for _, guardedPath := range paths {
guard, guardErr := TryAcquireSessionRemovalGuard(guardedPath)
if guardErr != nil {
for _, held := range guards {
held.Release()
}
return guardErr
}
guards[guardedPath] = guard
}
defer func() {
for _, guard := range guards {
guard.Release()
}
}()
if !SessionContentCovers(canonicalPath, path) {
return ErrRecoveryBranchNotCovered
}
key := filepath.Base(path)
if !validRecoveryTrashKey(key) {
return fmt.Errorf("invalid recovery session path")
}
stageDir, err := reserveRecoveryTrashStage(parentDir)
if err != nil {
return err
}
if err := prepareRecoveryTrashStage(path, key, stageDir); err != nil {
return err
}
return finishRecoveryTrashStage(parentDir, path, key, stageDir, guards[path])
}
// ReparentRecoveryCanonical shortens a proved recovery chain to root ->
// canonical without rewriting either transcript. This preserves discovery for
// older versions after covered intermediate branches are moved to trash.
func ReparentRecoveryCanonical(canonicalPath, rootID, parentDir string) error {
canonicalPath = filepath.Clean(strings.TrimSpace(canonicalPath))
parentDir = filepath.Clean(strings.TrimSpace(parentDir))
rootID = strings.TrimSpace(rootID)
rootPath := filepath.Join(parentDir, rootID+".jsonl")
if canonicalPath == "." || parentDir == "." || rootID == "" || filepath.Base(rootID) != rootID ||
filepath.Dir(canonicalPath) != parentDir || canonicalPath == rootPath {
return ErrRecoveryBranchNotCovered
}
paths := []string{canonicalPath, rootPath}
sort.Strings(paths)
guards := make([]*SessionRemovalGuard, 0, len(paths))
for _, path := range paths {
guard, err := TryAcquireSessionRemovalGuard(path)
if err != nil {
for _, held := range guards {
held.Release()
}
return err
}
guards = append(guards, guard)
}
defer func() {
for _, guard := range guards {
guard.Release()
}
}()
if !SessionContentCovers(canonicalPath, rootPath) {
return ErrRecoveryBranchNotCovered
}
return UpdateBranchMeta(canonicalPath, false, func(meta *BranchMeta) error {
if !meta.Recovered {
return ErrRecoveryBranchNotCovered
}
meta.ParentID = rootID
meta.RecoveryDepth = 1
return nil
})
}
// TrashReclaimableRecoveryBranch is the background-GC variant. In addition to
// the same atomic coverage proof, it requires the branch to remain idle for the
// full grace period.
+94
View File
@@ -277,6 +277,100 @@ func TestTrashReclaimableRecoveryBranchUsesRecoverableDesktopLayout(t *testing.T
}
}
func TestTrashRecoveryBranchCoveredByCanonicalCompactsLegacyChain(t *testing.T) {
dir := t.TempDir()
rootPath := filepath.Join(dir, "root.jsonl")
ancestorPath := filepath.Join(dir, "ancestor.jsonl")
canonicalPath := filepath.Join(dir, "canonical.jsonl")
root := NewSession("sys")
root.Add(provider.Message{Role: provider.RoleUser, Content: "root"})
if err := root.Save(rootPath); err != nil {
t.Fatal(err)
}
ancestor := NewSession("")
ancestor.Replace(root.Snapshot())
ancestor.Add(provider.Message{Role: provider.RoleAssistant, Content: "ancestor"})
if err := ancestor.Save(ancestorPath); err != nil {
t.Fatal(err)
}
if err := SaveBranchMetaPreserveUpdated(ancestorPath, BranchMeta{ID: "ancestor", Recovered: true, ParentID: "root", RecoveryDepth: 1}); err != nil {
t.Fatal(err)
}
canonical := NewSession("")
canonical.Replace(ancestor.Snapshot())
canonical.Add(provider.Message{Role: provider.RoleUser, Content: "continued"})
if err := canonical.Save(canonicalPath); err != nil {
t.Fatal(err)
}
if err := SaveBranchMetaPreserveUpdated(canonicalPath, BranchMeta{ID: "canonical", Recovered: true, ParentID: "ancestor", RecoveryDepth: 2}); err != nil {
t.Fatal(err)
}
if err := ReparentRecoveryCanonical(canonicalPath, "root", dir); err != nil {
t.Fatalf("ReparentRecoveryCanonical: %v", err)
}
if err := TrashRecoveryBranchCoveredBy(ancestorPath, canonicalPath, dir); err != nil {
t.Fatalf("TrashRecoveryBranchCoveredBy: %v", err)
}
meta, ok, err := LoadBranchMeta(canonicalPath)
if err != nil || !ok || meta.ParentID != "root" || meta.RecoveryDepth != 1 {
t.Fatalf("canonical meta = %+v ok=%v err=%v", meta, ok, err)
}
if _, err := os.Stat(rootPath); err != nil {
t.Fatalf("root was removed: %v", err)
}
if _, err := os.Stat(canonicalPath); err != nil {
t.Fatalf("canonical was removed: %v", err)
}
if _, err := os.Stat(ancestorPath); !os.IsNotExist(err) {
t.Fatalf("ancestor remained live: %v", err)
}
}
func TestSetRecoveryPreferredKeepsExactlyOneChoice(t *testing.T) {
dir := t.TempDir()
paths := []string{filepath.Join(dir, "left.jsonl"), filepath.Join(dir, "right.jsonl")}
for _, path := range paths {
session := NewSession("sys")
session.Add(provider.Message{Role: provider.RoleUser, Content: path})
if err := session.Save(path); err != nil {
t.Fatal(err)
}
if err := SaveBranchMeta(path, BranchMeta{ID: BranchID(path), Recovered: true, ParentID: "root"}); err != nil {
t.Fatal(err)
}
}
if err := SetRecoveryPreferred(paths, paths[0]); err != nil {
t.Fatal(err)
}
if err := SetRecoveryPreferred(paths, paths[1]); err != nil {
t.Fatal(err)
}
for index, path := range paths {
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("meta %q ok=%v err=%v", path, ok, err)
}
if meta.RecoveryPreferred != (index == 1) {
t.Fatalf("preferred[%d] = %v", index, meta.RecoveryPreferred)
}
if index == 1 && !RecoveryPreferenceCurrent(path, meta) {
t.Fatal("saved preference fingerprint was not current")
}
}
chosen, err := LoadSession(paths[1])
if err != nil {
t.Fatal(err)
}
chosen.Add(provider.Message{Role: provider.RoleAssistant, Content: "changed"})
if err := chosen.SaveSnapshot(paths[1]); err != nil {
t.Fatal(err)
}
meta, _, _ := LoadBranchMeta(paths[1])
if RecoveryPreferenceCurrent(paths[1], meta) {
t.Fatal("content change must invalidate the explicit preference")
}
}
func TestTrashReclaimableRecoveryBranchEnforcesGraceAtFinalGuard(t *testing.T) {
dir := t.TempDir()
parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "trash-fresh")
+54 -32
View File
@@ -58,6 +58,11 @@ var (
sessionFileLockPollInterval = 25 * time.Millisecond
sessionMetaLockWait = 5 * time.Second
ErrSessionSnapshotConflict = errors.New("session snapshot conflicts with newer transcript")
// ErrSessionExternallyRemoved means a live Session still has a verified
// baseline for path, but every authoritative transcript artifact disappeared.
// Treating it as a first save would silently recreate a file the user or an
// external cleanup tool deliberately removed.
ErrSessionExternallyRemoved = errors.New("session was removed while still open")
ErrSessionRecoveryNotNeeded = errors.New("session recovery not needed")
// ErrSessionFileLockHeld reports that another process kept the
// compatibility save lock for the full bounded acquisition window. Callers
@@ -516,9 +521,13 @@ func writeSessionMessages(path string, msgs []provider.Message) error {
// checkSnapshotWrite decides whether this session may write msgs over path, and
// whether the safe write shape is a no-op, append-only suffix, or full rewrite.
func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextDigest [sha256.Size]byte, nextVersion uint64, allowOwnedRewrite bool) (snapshotWriteDecision, error) {
baseState := s.persistState(path)
current, err := loadSessionUnlocked(path)
if err != nil {
if os.IsNotExist(err) {
if baseState.ok {
return snapshotWriteDecision{}, ErrSessionExternallyRemoved
}
return snapshotWriteDecision{}, nil
}
return snapshotWriteDecision{}, err
@@ -527,7 +536,6 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
if err != nil {
return snapshotWriteDecision{}, err
}
baseState := s.persistState(path)
existing := current.Snapshot()
existingDigest, err := digestSessionMessages(existing)
if err != nil {
@@ -1538,19 +1546,20 @@ type SessionInfo struct {
// session pickers and prompt-history navigation. It intentionally avoids reading
// JSONL content; callers that need previews can layer that on afterwards.
type SessionOrderInfo struct {
Path string
CreatedAt time.Time
LastActivityAt time.Time
ModTime time.Time // compatibility alias for LastActivityAt
Scope string
WorkspaceRoot string
TopicID string
TopicTitle string
CustomTitle string
Recovered bool
RecoveryReason string
RecoveryDigest string
ParentID string
Path string
CreatedAt time.Time
LastActivityAt time.Time
ModTime time.Time // compatibility alias for LastActivityAt
Scope string
WorkspaceRoot string
TopicID string
TopicTitle string
CustomTitle string
Recovered bool
RecoveryReason string
RecoveryDigest string
ParentID string
RecoveryPreferred bool
// Turns and Preview are the cached listing fields from the sidecar; SchemaVersion
// >= agent.BranchMetaCountsVersion means they were recorded from content and can
// be trusted (even Turns == 0). ListSessions uses them to skip the whole-file decode.
@@ -2085,6 +2094,7 @@ func ListSessionOrder(dir string) ([]SessionOrderInfo, error) {
recoveryReason := ""
recoveryDigest := ""
parentID := ""
recoveryPreferred := false
turns := 0
preview := ""
schemaVersion := 0
@@ -2106,31 +2116,43 @@ func ListSessionOrder(dir string) ([]SessionOrderInfo, error) {
recoveryReason = meta.RecoveryReason
recoveryDigest = meta.RecoveryDigest
parentID = meta.ParentID
recoveryPreferred = RecoveryPreferenceCurrent(full, meta)
turns = meta.Turns
preview = meta.Preview
schemaVersion = meta.SchemaVersion
revision = meta.Revision
contentDigest = meta.ContentDigest
}
// Old recovery files may lack Recovered meta; filename still proves
// automatic recovery lineage for catalog folding.
if !recovered && LooksLikeRecoveryFilename(full) {
recovered = true
if parentID == "" {
if parent, ok := RecoveryFilenameParentID(full); ok {
parentID = parent
}
}
}
out = append(out, SessionOrderInfo{
Path: full,
CreatedAt: createdAt,
LastActivityAt: lastActivityAt,
ModTime: lastActivityAt,
Scope: scope,
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitle,
CustomTitle: customTitle,
Recovered: recovered,
RecoveryReason: recoveryReason,
RecoveryDigest: recoveryDigest,
ParentID: parentID,
Turns: turns,
Preview: preview,
SchemaVersion: schemaVersion,
Revision: revision,
ContentDigest: contentDigest,
Path: full,
CreatedAt: createdAt,
LastActivityAt: lastActivityAt,
ModTime: lastActivityAt,
Scope: scope,
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitle,
CustomTitle: customTitle,
Recovered: recovered,
RecoveryReason: recoveryReason,
RecoveryDigest: recoveryDigest,
ParentID: parentID,
RecoveryPreferred: recoveryPreferred,
Turns: turns,
Preview: preview,
SchemaVersion: schemaVersion,
Revision: revision,
ContentDigest: contentDigest,
})
}
sort.Slice(out, func(i, j int) bool {
+22
View File
@@ -1895,6 +1895,28 @@ func TestSaveRecoveryBranchPersistsDivergedSnapshot(t *testing.T) {
}
}
func TestSaveSnapshotRefusesToRecreateExternallyRemovedBaseline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "removed.jsonl")
s := NewSession("sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "keep me"})
if err := s.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
for _, artifact := range append([]string{path}, store.SessionSidecarFiles(path)...) {
if err := os.RemoveAll(artifact); err != nil {
t.Fatal(err)
}
}
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "still in memory"})
if err := s.SaveSnapshot(path); !errors.Is(err, ErrSessionExternallyRemoved) {
t.Fatalf("SaveSnapshot error = %v, want ErrSessionExternallyRemoved", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("removed path was silently recreated: %v", err)
}
}
func TestSaveRecoveryBranchSkipsPureStalePrefix(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
current := NewSession("sys")
+4
View File
@@ -117,7 +117,11 @@ func doctorSessionsCommand(args []string) int {
fmt.Printf(" mode: %s\n", status.Mode)
fmt.Printf(" revision: %d\n", status.Revision)
fmt.Printf(" indexed: %d\n", status.Indexed)
fmt.Printf(" physical sessions: %d\n", status.PhysicalSessions)
fmt.Printf(" logical sessions: %d\n", status.LogicalSessions)
fmt.Printf(" repair pending: %d\n", status.RepairPending)
fmt.Printf(" recovery: %d groups, %d branches, %d diverged, %d safe cleanup\n",
status.RecoveryGroups, status.RecoveryBranches, status.RecoveryDiverged, status.CleanupEligible)
if status.LastError != "" {
fmt.Printf(" note: %s\n", status.LastError)
}
+152 -2
View File
@@ -3,12 +3,14 @@ package cli
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/sessioncatalog"
)
@@ -21,8 +23,18 @@ func sessionOrSessionsCommand(command string, args []string) int {
}
func sessionsCommand(args []string) int {
if len(args) == 0 || args[0] != "reindex" {
fmt.Fprintln(os.Stderr, "usage: reasonix sessions reindex [--dir PATH] [--json]")
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: reasonix sessions <reindex|diagnose|cleanup> [--dir PATH] [--json]")
return 2
}
switch args[0] {
case "diagnose":
return sessionsRecoveryCommand(args[1:], false)
case "cleanup":
return sessionsRecoveryCommand(args[1:], true)
case "reindex":
default:
fmt.Fprintln(os.Stderr, "usage: reasonix sessions <reindex|diagnose|cleanup> [--dir PATH] [--json]")
return 2
}
fs := flag.NewFlagSet("sessions reindex", flag.ContinueOnError)
@@ -62,6 +74,144 @@ func sessionsCommand(args []string) int {
return printSessionCatalogRebuild(status, *jsonOut)
}
type sessionRecoveryReport struct {
Directories int `json:"directories"`
Groups int `json:"groups"`
Branches int `json:"branches"`
AdoptedGroups int `json:"adoptedGroups"`
DivergedGroups int `json:"divergedGroups"`
CleanupEligible int `json:"cleanupEligible"`
MovedToTrash int `json:"movedToTrash"`
Busy int `json:"busy"`
Errors []string `json:"errors"`
DryRun bool `json:"dryRun"`
}
func sessionsRecoveryCommand(args []string, cleanup bool) int {
name := "sessions diagnose"
if cleanup {
name = "sessions cleanup"
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
var dirs stringListFlag
apply := fs.Bool("apply", false, "move safe covered recovery branches to recoverable trash")
jsonOut := fs.Bool("json", false, "print the recovery report as JSON")
fs.Var(&dirs, "dir", "session directory to inspect; repeat for multiple directories")
if code, ok := parseCommandFlags(fs, args); !ok {
return code
}
if fs.NArg() != 0 || (!cleanup && *apply) {
fmt.Fprintf(os.Stderr, "usage: reasonix %s [--dir PATH] [--json]", name)
if cleanup {
fmt.Fprint(os.Stderr, " [--apply]")
}
fmt.Fprintln(os.Stderr)
return 2
}
if len(dirs) == 0 {
for _, target := range defaultSessionCatalogTargets() {
dirs = append(dirs, target.Path)
}
}
report := sessionRecoveryReport{Errors: []string{}, DryRun: !cleanup || !*apply}
seen := map[string]bool{}
for _, rawDir := range dirs {
dir := filepath.Clean(strings.TrimSpace(rawDir))
if dir == "." || dir == "" || seen[dir] {
continue
}
seen[dir] = true
report.Directories++
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
if err != nil {
report.Errors = append(report.Errors, err.Error())
continue
}
err = catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"})
if err != nil {
report.Errors = append(report.Errors, err.Error())
_ = catalog.Close(context.Background())
continue
}
groups, err := catalog.ListRecoveryGroups(context.Background(), dir)
if err != nil {
report.Errors = append(report.Errors, err.Error())
_ = catalog.Close(context.Background())
continue
}
for _, group := range groups {
rootID, members := group.ID, group.Members
report.Groups++
report.Branches += len(members)
canonical := ""
diverged := false
for _, member := range members {
if member.RecoveryCanonical && (member.RecoveryRole == sessioncatalog.RecoveryRoleAdopted || member.RecoveryRole == sessioncatalog.RecoveryRolePreferred) {
canonical = member.Path
}
if member.RecoveryRole == sessioncatalog.RecoveryRoleDiverged {
diverged = true
}
}
if canonical == "" {
if diverged {
report.DivergedGroups++
}
continue
}
report.AdoptedGroups++
candidates := []string{}
for _, member := range members {
if member.Path != canonical && member.RecoveryRole == sessioncatalog.RecoveryRoleCoveredCopy {
candidates = append(candidates, member.Path)
}
}
report.CleanupEligible += len(candidates)
if report.DryRun || len(candidates) == 0 {
continue
}
if err := agent.ReparentRecoveryCanonical(canonical, rootID, dir); err != nil {
if errors.Is(err, agent.ErrSessionLeaseHeld) {
report.Busy += len(candidates)
} else {
report.Errors = append(report.Errors, err.Error())
}
continue
}
for _, candidate := range candidates {
if err := agent.TrashRecoveryBranchCoveredBy(candidate, canonical, dir); err != nil {
if errors.Is(err, agent.ErrSessionLeaseHeld) {
report.Busy++
} else {
report.Errors = append(report.Errors, err.Error())
}
continue
}
report.MovedToTrash++
}
}
_ = catalog.Close(context.Background())
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(report)
} else {
fmt.Printf("recovery groups: %d (%d adopted, %d diverged)\n", report.Groups, report.AdoptedGroups, report.DivergedGroups)
fmt.Printf("recovery branches: %d; safe cleanup: %d; moved: %d; busy: %d\n", report.Branches, report.CleanupEligible, report.MovedToTrash, report.Busy)
if report.DryRun && cleanup {
fmt.Println("dry run; pass --apply to move safe branches to recoverable trash")
}
}
for _, message := range report.Errors {
fmt.Fprintln(os.Stderr, "warning:", message)
}
if len(report.Errors) > 0 {
return 1
}
return 0
}
func printSessionCatalogRebuild(status sessioncatalog.Status, jsonOut bool) int {
if jsonOut {
enc := json.NewEncoder(os.Stdout)
+6
View File
@@ -215,6 +215,12 @@ func cliCompletionRootSpec() cliCompletionSpec {
completionSpec("reindex", []cliCompletionFlag{
completionFlag("--json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help,
}),
completionSpec("diagnose", []cliCompletionFlag{
completionFlag("--json", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help,
}),
completionSpec("cleanup", []cliCompletionFlag{
completionFlag("--json --apply", cliCompletionNoValue), completionFlag("--dir", cliCompletionPathValue), help,
}),
),
catalogCompletionSpec(help),
completionSpec("report", []cliCompletionFlag{help},
+36
View File
@@ -2,6 +2,7 @@ package control
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
@@ -9,6 +10,7 @@ import (
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/store"
"reasonix/internal/tool"
)
@@ -57,6 +59,40 @@ func TestBranchAndSwitch(t *testing.T) {
}
}
func TestSnapshotExternalRemovalMovesOnceToStableRecovery(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "root.jsonl")
session := agent.NewSession("sys")
session.Add(provider.Message{Role: provider.RoleUser, Content: "keep me"})
exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
if err := c.Snapshot(); err != nil {
t.Fatal(err)
}
for _, artifact := range append([]string{path}, store.SessionSidecarFiles(path)...) {
if artifact != "" {
_ = os.Remove(artifact)
}
}
session.Add(provider.Message{Role: provider.RoleAssistant, Content: "still here"})
if err := c.Snapshot(); err != nil {
t.Fatalf("snapshot after external removal: %v", err)
}
recovered := c.SessionPath()
if recovered == path || recovered == "" {
t.Fatalf("session path = %q, want recovery path", recovered)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("deleted original was recreated: %v", err)
}
if err := c.Snapshot(); err != nil {
t.Fatalf("second recovery snapshot: %v", err)
}
if got := c.SessionPath(); got != recovered {
t.Fatalf("recovery fork storm: %q -> %q", recovered, got)
}
}
func TestSwitchBranchRejectsCleanupPending(t *testing.T) {
dir := t.TempDir()
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
+40
View File
@@ -3844,6 +3844,17 @@ func (c *Controller) snapshotWithDurability(markActivity, forceRewrite, shutdown
err = nil
}
}
if err != nil {
if errors.Is(err, agent.ErrSessionExternallyRemoved) {
recoveredPath, recoverErr := c.recoverExternallyRemovedSession(path, err)
if recoverErr != nil {
return false, recoverErr
}
path = recoveredPath
s = c.executor.Session()
err = nil
}
}
if err != nil {
if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
return false, err
@@ -3898,6 +3909,35 @@ func (c *Controller) snapshotWithDurability(markActivity, forceRewrite, shutdown
return transcriptDurable, nil
}
func (c *Controller) recoverExternallyRemovedSession(path string, saveErr error) (string, error) {
if c.executor == nil || strings.TrimSpace(path) == "" {
return "", saveErr
}
const reason = "session removed while open"
req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: "external-removal"}
meta := agent.BranchMeta{}
if c.sessionRecoveryMeta != nil {
meta = c.sessionRecoveryMeta(req)
}
info, err := c.executor.Session().SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{
OriginalPath: path,
Reason: reason,
BranchMeta: meta,
})
if err != nil {
return "", fmt.Errorf("preserve externally removed session: %w", err)
}
if err := c.commitRecoveredSession(path, reason, info); err != nil {
return "", err
}
appendSnapshotConflictDiagnostic(path, "external-removal", "moved_to_stable_recovery", saveErr, info.Path, info.Existing)
slog.Warn("controller: active session was removed externally; moved runtime to stable recovery path",
"path", path, "recovery", info.Path, "existing", info.Existing)
c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryForked,
"the open session file was removed outside Reasonix; your active conversation was preserved as one recovery copy"))
return info.Path, nil
}
// snapshotConflictLogAttrs flattens a snapshot-conflict error into slog attrs.
// Field reports of #6069-class "session changed on disk" spam are only
// diagnosable when the logs say which trigger fired and what the revision
+113 -30
View File
@@ -170,14 +170,26 @@ func (c *Catalog) refreshCounts(ctx context.Context) {
if c == nil || c.db == nil {
return
}
var indexed, pending, total int64
var indexed, pending, total, physical, logical, groups, branches, diverged, cleanup int64
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions`).Scan(&indexed)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`).Scan(&pending)
_ = c.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(total), 0) FROM catalog_directories`).Scan(&total)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE missing_since=0`).Scan(&physical)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_topics`).Scan(&logical)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT recovery_group_id) FROM catalog_sessions WHERE recovered=1 AND recovery_group_id<>'' AND missing_since=0`).Scan(&groups)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND missing_since=0`).Scan(&branches)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='diverged' AND missing_since=0`).Scan(&diverged)
_ = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='covered_copy' AND missing_since=0`).Scan(&cleanup)
c.statusMu.Lock()
c.status.Indexed = indexed
c.status.Total = total
c.status.RepairPending = pending
c.status.PhysicalSessions = physical
c.status.LogicalSessions = logical
c.status.RecoveryGroups = groups
c.status.RecoveryBranches = branches
c.status.RecoveryDiverged = diverged
c.status.CleanupEligible = cleanup
c.status.Revision = c.revision.Load()
c.statusMu.Unlock()
}
@@ -335,13 +347,16 @@ func (c *Catalog) upsertSessions(ctx context.Context, records []SessionRecord, g
directoryGenerations[record.Directory] = generation
}
record = classifyRecoveryLineage(record)
if record.LogicalTopicID == "" {
record.LogicalTopicID = record.TopicID
}
if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_sessions(
path,directory,scope,workspace_root,topic_id,topic_title,custom_title,
created_at,last_activity_at,preview,turns,turns_state,recovered,
recovery_reason,recovery_digest,parent_id,recovery_copy,recovery_group_id,
recovery_role,recovery_canonical,content_fingerprint,
recovery_role,recovery_canonical,logical_topic_id,ordinary_visible,content_fingerprint,
meta_fingerprint,health,missing_since,seen_generation
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(path) DO UPDATE SET
directory=excluded.directory, scope=excluded.scope,
workspace_root=excluded.workspace_root, topic_id=excluded.topic_id,
@@ -355,6 +370,8 @@ func (c *Catalog) upsertSessions(ctx context.Context, records []SessionRecord, g
recovery_group_id=excluded.recovery_group_id,
recovery_role=excluded.recovery_role,
recovery_canonical=excluded.recovery_canonical,
logical_topic_id=excluded.logical_topic_id,
ordinary_visible=excluded.ordinary_visible,
content_fingerprint=excluded.content_fingerprint,
meta_fingerprint=excluded.meta_fingerprint, health=excluded.health,
missing_since=0, seen_generation=MAX(catalog_sessions.seen_generation, excluded.seen_generation)`,
@@ -364,6 +381,7 @@ func (c *Catalog) upsertSessions(ctx context.Context, records []SessionRecord, g
record.Recovered, record.RecoveryReason, record.RecoveryDigest,
record.ParentID, boolToInt(record.RecoveryCopy), record.RecoveryGroupID,
record.RecoveryRole, boolToInt(record.RecoveryCanonical),
record.LogicalTopicID, boolToInt(record.OrdinaryVisible),
record.ContentFingerprint, record.MetaFingerprint,
record.Health, 0, generation); err != nil {
_ = tx.Rollback()
@@ -407,7 +425,8 @@ func recomputeTopic(ctx context.Context, tx *sql.Tx, key TopicKey) error {
// max(sum(normal turns), max(adopted recovery turns)).
_, err := tx.ExecContext(ctx, `INSERT INTO catalog_topics(
scope,workspace_root,topic_id,title,turns,turns_state,created_at,
last_activity_at,recovery_state,health
last_activity_at,recovery_state,recovery_branch_count,
recovery_unresolved_count,recovery_cleanup_eligible_count,health
) SELECT ?,?,?,
COALESCE(NULLIF((SELECT COALESCE(NULLIF(custom_title,''), NULLIF(topic_title,''), preview, '')
FROM catalog_sessions WHERE scope=? AND workspace_root=? AND topic_id=?
@@ -421,7 +440,14 @@ func recomputeTopic(ctx context.Context, tx *sql.Tx, key TopicKey) error {
WHEN SUM(CASE WHEN recovery_copy=0 THEN 1 ELSE 0 END)=0 THEN 'valid'
ELSE 'valid' END,
COALESCE(MIN(NULLIF(created_at,0)),0), COALESCE(MAX(last_activity_at),0),
CASE WHEN SUM(CASE WHEN recovery_copy=0 THEN 1 ELSE 0 END)=0 THEN 'recovery_only' ELSE '' END,
CASE WHEN SUM(CASE WHEN recovered=1 AND recovery_role='preferred' THEN 1 ELSE 0 END)>0 THEN 'preferred'
WHEN SUM(CASE WHEN recovered=1 AND recovery_role='diverged' THEN 1 ELSE 0 END)>0 THEN 'diverged'
WHEN SUM(CASE WHEN recovered=1 AND recovery_role='adopted' THEN 1 ELSE 0 END)>0 THEN 'adopted'
WHEN SUM(CASE WHEN recovery_copy=0 THEN 1 ELSE 0 END)=0 THEN 'recovery_only' ELSE '' END,
SUM(CASE WHEN recovered=1 THEN 1 ELSE 0 END),
CASE WHEN SUM(CASE WHEN recovered=1 AND recovery_role='preferred' THEN 1 ELSE 0 END)>0 THEN 0
ELSE SUM(CASE WHEN recovered=1 AND recovery_role='diverged' THEN 1 ELSE 0 END) END,
SUM(CASE WHEN recovered=1 AND recovery_role='covered_copy' THEN 1 ELSE 0 END),
CASE WHEN SUM(CASE WHEN recovery_copy=0 AND health='corrupt' THEN 1 ELSE 0 END)>0 THEN 'corrupt'
WHEN SUM(CASE WHEN recovery_copy=0 AND health='missing' THEN 1 ELSE 0 END)>0 THEN 'missing'
ELSE 'ok' END
@@ -429,7 +455,11 @@ func recomputeTopic(ctx context.Context, tx *sql.Tx, key TopicKey) error {
ON CONFLICT(scope,workspace_root,topic_id) DO UPDATE SET
title=excluded.title, turns=excluded.turns, turns_state=excluded.turns_state,
created_at=excluded.created_at, last_activity_at=excluded.last_activity_at,
recovery_state=excluded.recovery_state, health=excluded.health`,
recovery_state=excluded.recovery_state,
recovery_branch_count=excluded.recovery_branch_count,
recovery_unresolved_count=excluded.recovery_unresolved_count,
recovery_cleanup_eligible_count=excluded.recovery_cleanup_eligible_count,
health=excluded.health`,
key.Scope, key.WorkspaceRoot, key.TopicID,
key.Scope, key.WorkspaceRoot, key.TopicID, key.TopicID,
key.Scope, key.WorkspaceRoot, key.TopicID)
@@ -507,48 +537,70 @@ func (c *Catalog) ListTopics(ctx context.Context, req TopicPageRequest) (TopicPa
}
pageArgs = append(pageArgs, scanLimit)
rows, err := c.db.QueryContext(ctx, `SELECT scope,workspace_root,topic_id,title,pinned,sort_order,
turns,turns_state,created_at,last_activity_at,recovery_state,health
turns,turns_state,created_at,last_activity_at,recovery_state,recovery_branch_count,
recovery_unresolved_count,recovery_cleanup_eligible_count,health
FROM catalog_topics WHERE `+pageWhere+`
ORDER BY pinned DESC,last_activity_at DESC,topic_id ASC LIMIT ?`, pageArgs...)
if err != nil {
return out, err
}
rawCount := 0
var lastScanned TopicRecord
// Drain the page before hydrating sessions: an open cursor holds a
// connection, and listTopicSessions needs a second one, so nesting them
// deadlocks whenever the pool is saturated (always in memory mode).
scanned := make([]TopicRecord, 0, scanLimit)
for rows.Next() {
var item TopicRecord
if err := rows.Scan(&item.Scope, &item.WorkspaceRoot, &item.TopicID, &item.Title,
&item.Pinned, &item.SortOrder, &item.Turns, &item.TurnsState,
&item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.Health); err != nil {
&item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.RecoveryBranchCount,
&item.RecoveryUnresolvedCount, &item.RecoveryCleanupEligibleCount, &item.Health); err != nil {
_ = rows.Close()
return out, err
}
rawCount++
lastScanned = item
sessions, err := c.listTopicSessions(ctx, TopicKey{
Scope: item.Scope, WorkspaceRoot: item.WorkspaceRoot, TopicID: item.TopicID,
})
if err != nil {
_ = rows.Close()
return TopicPage{Items: []TopicRecord{}, Revision: out.Revision}, err
}
if len(sessions) == 0 {
continue
}
item.Sessions = sessions
out.Items = append(out.Items, item)
if len(out.Items) > req.Limit {
break
}
scanned = append(scanned, item)
}
rowsErr := rows.Err()
_ = rows.Close()
if rowsErr != nil {
return out, rowsErr
}
if len(out.Items) > req.Limit || rawCount < scanLimit || rawCount == 0 {
rawCount := len(scanned)
overflow := false
for _, item := range scanned {
sessions, err := c.listTopicSessions(ctx, TopicKey{
Scope: item.Scope, WorkspaceRoot: item.WorkspaceRoot, TopicID: item.TopicID,
})
if err != nil {
return TopicPage{Items: []TopicRecord{}, Revision: out.Revision}, err
}
if len(sessions) == 0 {
continue
}
// Skip pure recovery-shell topics that lost their ordinary
// representative after lineage re-anchoring. Prevents empty pages
// and flash of conflict rows while catalog rebuilds.
hasOrdinary := false
for _, session := range sessions {
if session.OrdinaryVisible || (!session.Recovered && !session.RecoveryCopy) {
hasOrdinary = true
break
}
}
if !hasOrdinary && !item.Pinned {
continue
}
item.Sessions = sessions
item.RepresentativePath = topicRepresentativePath(sessions)
out.Items = append(out.Items, item)
if len(out.Items) > req.Limit {
overflow = true
break
}
}
if overflow || rawCount < scanLimit || rawCount == 0 {
break
}
lastScanned := scanned[rawCount-1]
pinned := 0
if lastScanned.Pinned {
pinned = 1
@@ -622,12 +674,14 @@ func (c *Catalog) GetTopic(ctx context.Context, key TopicKey) (TopicRecord, bool
key.TopicID = strings.TrimSpace(key.TopicID)
item := TopicRecord{Sessions: []SessionRecord{}}
err := c.db.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id,title,pinned,sort_order,
turns,turns_state,created_at,last_activity_at,recovery_state,health
turns,turns_state,created_at,last_activity_at,recovery_state,recovery_branch_count,
recovery_unresolved_count,recovery_cleanup_eligible_count,health
FROM catalog_topics WHERE scope=? AND workspace_root=? AND topic_id=?`,
key.Scope, key.WorkspaceRoot, key.TopicID).Scan(
&item.Scope, &item.WorkspaceRoot, &item.TopicID, &item.Title,
&item.Pinned, &item.SortOrder, &item.Turns, &item.TurnsState,
&item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.Health)
&item.CreatedAt, &item.LastActivityAt, &item.RecoveryState, &item.RecoveryBranchCount,
&item.RecoveryUnresolvedCount, &item.RecoveryCleanupEligibleCount, &item.Health)
if errors.Is(err, sql.ErrNoRows) {
return item, false, nil
}
@@ -643,9 +697,38 @@ func (c *Catalog) GetTopic(ctx context.Context, key TopicKey) (TopicRecord, bool
if len(item.Sessions) == 0 {
return TopicRecord{Sessions: []SessionRecord{}}, false, nil
}
item.RepresentativePath = topicRepresentativePath(item.Sessions)
return item, true, nil
}
func topicRepresentativePath(sessions []SessionRecord) string {
if path := CanonicalSessionPathForTopic(sessions, ""); path != "" {
return path
}
preferred := PreferredOrdinarySessionPaths(sessions)
best := SessionRecord{}
found := false
for _, session := range sessions {
path := strings.TrimSpace(session.Path)
_, isPreferred := preferred[path]
if !session.OrdinaryVisible && !isPreferred && (session.Recovered || session.RecoveryCopy) {
continue
}
if !found || recoveryRank(session) > recoveryRank(best) ||
(recoveryRank(session) == recoveryRank(best) && session.LastActivityAt > best.LastActivityAt) {
best = session
found = true
}
}
if found {
return best.Path
}
if len(sessions) > 0 {
return sessions[0].Path
}
return ""
}
// EncodeTopicCursor builds an exclusive ListTopics keyset cursor after the
// given topic position. Desktop post-filters recovery-only rows and needs the
// same cursor shape catalog.ListTopics emits.
+1 -1
View File
@@ -221,7 +221,7 @@ func TestSchemaMigrationLedgerRecordsEveryVersion(t *testing.T) {
}
versions = append(versions, version)
}
if fmt.Sprint(versions) != "[1 2 3 4 5]" {
if fmt.Sprint(versions) != "[1 2 3 4 5 6 7]" {
t.Fatalf("schema migration ledger = %v", versions)
}
}
@@ -0,0 +1,177 @@
package sessioncatalog
// Guards for connection-pool starvation: every catalog API must finish on a
// pool of one, so no path can hold a connection while waiting for another.
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
)
// A memory-mode catalog pools a single connection, so hydrating a topic's
// sessions from inside the open topic cursor deadlocks until the caller's
// context expires — with the desktop's boot context, forever.
func TestListTopicsHydratesSessionsOffTheTopicCursor(t *testing.T) {
t.Parallel()
ctx := context.Background()
catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
for _, topicID := range []string{"topic-a", "topic-b"} {
if err := catalog.UpsertSession(ctx, SessionRecord{
Path: filepath.Join("/s", topicID+".jsonl"), Directory: "/s", Scope: "global",
TopicID: topicID, TopicTitle: topicID, Turns: 1, TurnsState: TurnsValid,
Health: HealthOK, LastActivityAt: 1,
}); err != nil {
t.Fatal(err)
}
}
// A regression cannot hang the suite: it starves the pool and this deadline
// turns the deadlock into a failed call.
listCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
page, err := catalog.ListTopics(listCtx, TopicPageRequest{Scope: "global", Limit: MaxLimit})
if err != nil {
t.Fatalf("ListTopics on a single-connection catalog: %v", err)
}
if len(page.Items) != 2 {
t.Fatalf("topics = %d, want both hydrated topics", len(page.Items))
}
for _, item := range page.Items {
if len(item.Sessions) != 1 {
t.Fatalf("topic %q sessions = %d, want 1", item.TopicID, len(item.Sessions))
}
}
}
// The disk pool is four connections and every in-flight ListTopics pins one for
// its topic cursor, so four concurrent sidebar reads used to leave the nested
// session queries with nothing left to acquire and no deadline to break out of.
func TestListTopicsSurvivesReadersAtThePoolLimit(t *testing.T) {
t.Parallel()
ctx := context.Background()
catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
for i := range 50 {
topicID := fmt.Sprintf("topic-%02d", i)
if err := catalog.UpsertSession(ctx, SessionRecord{
Path: filepath.Join("/s", topicID+".jsonl"), Directory: "/s", Scope: "global",
TopicID: topicID, TopicTitle: topicID, Turns: 1, TurnsState: TurnsValid,
Health: HealthOK, LastActivityAt: int64(i + 1),
}); err != nil {
t.Fatal(err)
}
}
listCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
const readers = 8
results := make(chan error, readers)
for range readers {
go func() {
page, err := catalog.ListTopics(listCtx, TopicPageRequest{Scope: "global", Limit: MaxLimit})
if err == nil && len(page.Items) != 50 {
err = fmt.Errorf("topics = %d, want 50", len(page.Items))
}
results <- err
}()
}
for range readers {
if err := <-results; err != nil {
t.Fatalf("concurrent ListTopics: %v", err)
}
}
}
// A memory-mode catalog pools exactly one connection, so any API that holds a
// connection while asking the pool for another deadlocks here and nowhere else
// until production saturates a disk pool. This is the general guard: it fails
// on the API that reintroduces the nesting, not on ListTopics specifically.
func TestCatalogAPIsSurviveASingleConnectionPool(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
Scope: "project", WorkspaceRoot: "/workspace", TopicID: "topic-1",
TopicTitle: "Topic", SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1,
}); err != nil {
t.Fatal(err)
}
catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
target := DirectoryTarget{Path: dir, Scope: "project", WorkspaceRoot: "/workspace"}
key := TopicKey{Scope: "project", WorkspaceRoot: "/workspace", TopicID: "topic-1"}
// One deadline across the whole surface: a regression surfaces as this
// step's error instead of a hung test binary.
call, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
steps := []struct {
name string
run func() error
}{
{"ReconcileDirectory", func() error { return catalog.ReconcileDirectory(call, target) }},
{"IndexSessionPath", func() error { return catalog.IndexSessionPath(call, target, path) }},
{"SyncMetadata", func() error {
return catalog.SyncMetadata(call,
[]ProjectRecord{{Scope: "project", WorkspaceRoot: "/workspace", Title: "W"}},
[]TopicMetadata{{Scope: "project", WorkspaceRoot: "/workspace", TopicID: "topic-1", Title: "Topic"}})
}},
{"ListTopics", func() error {
page, err := catalog.ListTopics(call, TopicPageRequest{Scope: "project", WorkspaceRoot: "/workspace", Limit: MaxLimit})
if err == nil && len(page.Items) != 1 {
return fmt.Errorf("topics = %d, want 1", len(page.Items))
}
return err
}},
{"GetTopic", func() error {
topic, ok, err := catalog.GetTopic(call, key)
if err == nil && (!ok || len(topic.Sessions) != 1) {
return fmt.Errorf("topic ok=%v sessions=%d, want one session", ok, len(topic.Sessions))
}
return err
}},
{"ListSessions", func() error {
page, err := catalog.ListSessions(call, SessionPageRequest{Scope: "project", WorkspaceRoot: "/workspace", Limit: MaxLimit})
if err == nil && len(page.Items) != 1 {
return fmt.Errorf("sessions = %d, want 1", len(page.Items))
}
return err
}},
{"GetSession", func() error {
_, ok, err := catalog.GetSession(call, path)
if err != nil {
return fmt.Errorf("get session: %w", err)
}
if !ok {
return fmt.Errorf("session %q missing from catalog", path)
}
return nil
}},
{"RemoveSession", func() error { return catalog.RemoveSession(call, path, "test") }},
}
for _, step := range steps {
if err := step.run(); err != nil {
t.Fatalf("%s on a single-connection catalog: %v", step.name, err)
}
}
}
+356 -28
View File
@@ -2,7 +2,10 @@ package sessioncatalog
import (
"context"
"database/sql"
"errors"
"path/filepath"
"sort"
"strings"
"reasonix/internal/agent"
@@ -52,14 +55,278 @@ func promoteCanonicalLeaves(records []SessionRecord) []SessionRecord {
records[i].RecoveryRole = RecoveryRoleDiverged
records[i].RecoveryCanonical = false
}
rootIndex, ok := groupRoot[groupID]
if !ok {
rootIndex, hasRoot := groupRoot[groupID]
preferred := -1
for _, index := range idxs {
if records[index].RecoveryPreferred {
if preferred >= 0 {
preferred = -2 // multiple stale preferences fail closed
break
}
preferred = index
}
}
if preferred >= 0 {
records[preferred].RecoveryRole = RecoveryRolePreferred
records[preferred].RecoveryCanonical = true
continue
}
candidate, ok := uniqueLongestRecovery(records, idxs)
if ok && recoveryCandidateCovers(records, candidate, rootIndex, idxs) {
records[candidate].RecoveryRole = RecoveryRoleAdopted
contentIdxs := append([]int{}, idxs...)
if hasRoot {
contentIdxs = append(contentIdxs, rootIndex)
}
content := loadRecoveryGroupContent(records, contentIdxs)
candidate, ok := uniqueLongestRecovery(records, idxs, content)
if !ok {
// No unique covering leaf: still pick one stable representative so
// ordinary visibility never expands into a wall of forks.
if len(idxs) > 0 {
stable := pickPreferredRecovery(indexesToRecords(records, idxs))
for _, i := range idxs {
if records[i].Path == stable.Path {
records[i].RecoveryCanonical = true
break
}
}
}
continue
}
if hasRoot && !recoveryCandidateCovers(candidate, rootIndex, idxs, content) {
records[candidate].RecoveryCanonical = true
continue
}
if !hasRoot {
// Root missing: the unique covering leaf is still the ordinary
// representative, but without a parent it cannot prove adoption.
records[candidate].RecoveryCanonical = true
for _, i := range idxs {
if i == candidate {
continue
}
if member, ok := content[i]; ok {
if candidateContent, ok := content[candidate]; ok && candidateContent.Covers(member) {
records[i].RecoveryCopy = true
records[i].RecoveryRole = RecoveryRoleCoveredCopy
}
}
}
continue
}
records[candidate].RecoveryRole = RecoveryRoleAdopted
records[candidate].RecoveryCanonical = true
// Once one leaf contains the entire group, every other recovery
// member is an ancestor/equivalent copy covered by that canonical.
// Marking them covered is what lets History and explicit cleanup
// converge an old chain instead of leaving hundreds of non-canonical
// "diverged" rows that preserve no unique content.
for _, i := range idxs {
if i == candidate {
continue
}
records[i].RecoveryCopy = true
records[i].RecoveryRole = RecoveryRoleCoveredCopy
}
}
return projectLogicalSessions(records)
}
func indexesToRecords(records []SessionRecord, idxs []int) []SessionRecord {
out := make([]SessionRecord, 0, len(idxs))
for _, i := range idxs {
out = append(out, records[i])
}
return out
}
// projectLogicalSessions re-anchors recovered physical files onto one logical
// topic and marks the single ordinary-list representative. Catalog projection
// only — authoritative JSONL/meta topic_id values are never rewritten.
func projectLogicalSessions(records []SessionRecord) []SessionRecord {
byID := make(map[string]int, len(records))
for i := range records {
byID[agent.BranchID(records[i].Path)] = i
if !records[i].Recovered {
if records[i].LogicalTopicID == "" {
records[i].LogicalTopicID = records[i].TopicID
}
continue
}
// Filename fallback when meta ParentID is empty.
if strings.TrimSpace(records[i].ParentID) == "" {
if parent, ok := agent.RecoveryFilenameParentID(records[i].Path); ok {
records[i].ParentID = parent
if records[i].RecoveryGroupID == "" {
records[i].RecoveryGroupID = parent
}
}
}
if records[i].RecoveryGroupID == "" {
records[i].RecoveryGroupID = firstNonEmpty(records[i].ParentID, agent.BranchID(records[i].Path))
}
}
// Walk parent chains so intermediate recovery files also share the root id.
for i := range records {
if !records[i].Recovered {
continue
}
groupID := strings.TrimSpace(records[i].RecoveryGroupID)
seen := map[string]struct{}{}
parentID := strings.TrimSpace(records[i].ParentID)
for parentID != "" {
if _, loop := seen[parentID]; loop {
break
}
seen[parentID] = struct{}{}
parentIndex, ok := byID[parentID]
if !ok {
groupID = firstNonEmpty(groupID, parentID)
break
}
parent := records[parentIndex]
if !parent.Recovered {
groupID = parentID
break
}
parentID = strings.TrimSpace(parent.ParentID)
if parent.RecoveryGroupID != "" {
groupID = parent.RecoveryGroupID
}
}
if groupID != "" {
records[i].RecoveryGroupID = groupID
}
}
type groupAnchor struct {
topicID string
topicTitle string
createdAt int64
rootIndex int
}
anchors := map[string]*groupAnchor{}
for i, rec := range records {
if !rec.Recovered {
id := agent.BranchID(rec.Path)
anchors[id] = &groupAnchor{
topicID: rec.TopicID, topicTitle: rec.TopicTitle,
createdAt: rec.CreatedAt, rootIndex: i,
}
}
}
// Fill missing logical topics from recovered members (root topic empty or
// root missing). Prefer earliest non-empty member topic_id.
for _, rec := range records {
if !rec.Recovered {
continue
}
groupID := strings.TrimSpace(rec.RecoveryGroupID)
if groupID == "" {
continue
}
anchor := anchors[groupID]
if anchor == nil {
anchor = &groupAnchor{rootIndex: -1, createdAt: rec.CreatedAt}
anchors[groupID] = anchor
}
topic := strings.TrimSpace(rec.TopicID)
if topic == "" {
continue
}
if strings.TrimSpace(anchor.topicID) == "" ||
(rec.CreatedAt > 0 && (anchor.createdAt == 0 || rec.CreatedAt < anchor.createdAt) &&
(anchor.rootIndex < 0 || strings.TrimSpace(records[anchor.rootIndex].TopicID) == "")) {
// Keep a non-empty root topic when present; only override empty roots.
if anchor.rootIndex >= 0 && strings.TrimSpace(records[anchor.rootIndex].TopicID) != "" {
continue
}
anchor.topicID = topic
anchor.topicTitle = rec.TopicTitle
if rec.CreatedAt > 0 {
anchor.createdAt = rec.CreatedAt
}
}
}
for groupID, anchor := range anchors {
if strings.TrimSpace(anchor.topicID) == "" {
// Stable internal topic so rootless recovery storms still collapse.
anchor.topicID = "recovery:" + groupID
if anchor.topicTitle == "" {
anchor.topicTitle = groupID
}
}
}
for i := range records {
groupID := ""
if records[i].Recovered {
groupID = strings.TrimSpace(records[i].RecoveryGroupID)
} else {
groupID = agent.BranchID(records[i].Path)
}
anchor := anchors[groupID]
if anchor == nil {
records[i].LogicalTopicID = firstNonEmpty(records[i].TopicID, "recovery:"+agent.BranchID(records[i].Path))
continue
}
records[i].LogicalTopicID = anchor.topicID
// Catalog ListTopics keys off TopicID. Re-anchor recovered physical
// rows (and roots that only inherited a member topic) onto one logical
// topic so pagination never materializes recovery replica walls.
if anchor.topicID != "" && (records[i].Recovered || strings.TrimSpace(records[i].TopicID) == "") {
records[i].TopicID = anchor.topicID
}
if anchor.topicTitle != "" && (records[i].Recovered || strings.TrimSpace(records[i].TopicTitle) == "") {
records[i].TopicTitle = anchor.topicTitle
}
}
preferred := PreferredOrdinarySessionPaths(records)
for i := range records {
_, records[i].OrdinaryVisible = preferred[strings.TrimSpace(records[i].Path)]
// When a group has a normal root, the root is the ordinary row even if
// an adopted leaf is the open target (canonical path).
if !records[i].Recovered {
records[i].OrdinaryVisible = true
}
}
// Exactly one ordinary-visible recovered leaf when the root is missing.
byGroup := map[string][]int{}
rootPresent := map[string]bool{}
for i, rec := range records {
if !rec.Recovered {
rootPresent[agent.BranchID(rec.Path)] = true
continue
}
if rec.RecoveryCopy || rec.RecoveryRole == RecoveryRoleCoveredCopy {
records[i].OrdinaryVisible = false
continue
}
byGroup[rec.RecoveryGroupID] = append(byGroup[rec.RecoveryGroupID], i)
}
for groupID, idxs := range byGroup {
if rootPresent[groupID] {
for _, i := range idxs {
records[i].OrdinaryVisible = false
}
continue
}
best := -1
for _, i := range idxs {
if records[i].RecoveryCanonical {
best = i
break
}
}
if best < 0 {
bestIdx := pickPreferredRecovery(indexesToRecords(records, idxs))
for _, i := range idxs {
if records[i].Path == bestIdx.Path {
best = i
break
}
}
}
for _, i := range idxs {
records[i].OrdinaryVisible = i == best
}
}
return records
@@ -110,39 +377,80 @@ func recoveryLineageGroups(records []SessionRecord) (map[string][]int, map[strin
return byGroup, groupRoot
}
func uniqueLongestRecovery(records []SessionRecord, idxs []int) (int, bool) {
func loadRecoveryGroupContent(records []SessionRecord, idxs []int) map[int]agent.SessionContentSnapshot {
content := make(map[int]agent.SessionContentSnapshot, len(idxs))
for _, index := range idxs {
if _, loaded := content[index]; loaded {
continue
}
if snapshot, ok := agent.LoadSessionContentSnapshot(records[index].Path); ok {
content[index] = snapshot
}
}
return content
}
func uniqueLongestRecovery(records []SessionRecord, idxs []int, content map[int]agent.SessionContentSnapshot) (int, bool) {
if len(idxs) == 0 {
return 0, false
}
candidate := idxs[0]
if len(idxs) == 1 {
return candidate, true
// Turns/preview metadata is repaired asynchronously and must not decide
// lineage. Doing so made the first scan permanently label every legacy fork
// diverged when its sidecar still had turns_state=unknown. Instead, find the
// leaves that actually cover every recovered member. Equivalent leaves are
// safe to collapse and use a deterministic activity/path tie-breaker.
maxLen := -1
for _, index := range idxs {
if snapshot, ok := content[index]; ok && snapshot.Len() > maxLen {
maxLen = snapshot.Len()
}
}
if records[candidate].TurnsState == TurnsUnknown {
if maxLen < 0 {
return 0, false
}
for _, i := range idxs[1:] {
if records[i].TurnsState == TurnsUnknown {
return 0, false
candidates := make([]int, 0, 1)
for _, candidate := range idxs {
candidateContent, ok := content[candidate]
if !ok || candidateContent.Len() != maxLen {
continue
}
if records[i].Turns > records[candidate].Turns {
candidate = i
coversAll := true
for _, other := range idxs {
otherContent, ok := content[other]
if !ok || (candidate != other && !candidateContent.Covers(otherContent)) {
coversAll = false
break
}
}
if coversAll {
candidates = append(candidates, candidate)
}
}
for _, i := range idxs {
if i != candidate && records[i].Turns >= records[candidate].Turns {
return 0, false
}
if len(candidates) == 0 {
return 0, false
}
return candidate, true
sort.SliceStable(candidates, func(i, j int) bool {
a, b := records[candidates[i]], records[candidates[j]]
if a.LastActivityAt != b.LastActivityAt {
return a.LastActivityAt > b.LastActivityAt
}
return a.Path < b.Path
})
return candidates[0], true
}
func recoveryCandidateCovers(records []SessionRecord, candidate, root int, idxs []int) bool {
if !agent.SessionContentCovers(records[candidate].Path, records[root].Path) {
func recoveryCandidateCovers(candidate, root int, idxs []int, content map[int]agent.SessionContentSnapshot) bool {
candidateContent, ok := content[candidate]
if !ok {
return false
}
rootContent, ok := content[root]
if !ok || !candidateContent.Covers(rootContent) {
return false
}
for _, i := range idxs {
if i != candidate && !agent.SessionContentCovers(records[candidate].Path, records[i].Path) {
memberContent, ok := content[i]
if !ok || (i != candidate && !candidateContent.Covers(memberContent)) {
return false
}
}
@@ -163,11 +471,20 @@ func (c *Catalog) refreshDirectoryRecoveryLineage(ctx context.Context, target Di
affected := map[TopicKey]struct{}{}
changed := false
for _, record := range records {
if !record.Recovered {
continue
// Capture the previously projected topic so re-anchored recovery rows
// can delete empty legacy topic shells.
var previous TopicKey
if err := tx.QueryRowContext(ctx, `SELECT scope,workspace_root,topic_id FROM catalog_sessions WHERE path=?`, record.Path).
Scan(&previous.Scope, &previous.WorkspaceRoot, &previous.TopicID); err == nil && previous.TopicID != "" {
affected[previous] = struct{}{}
} else if err != nil && !errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
return err
}
result, err := tx.ExecContext(ctx, `UPDATE catalog_sessions SET recovery_copy=?,recovery_group_id=?,recovery_role=?,recovery_canonical=? WHERE path=?`,
boolToInt(record.RecoveryCopy), record.RecoveryGroupID, record.RecoveryRole, boolToInt(record.RecoveryCanonical), record.Path)
result, err := tx.ExecContext(ctx, `UPDATE catalog_sessions SET recovered=?,recovery_copy=?,recovery_group_id=?,recovery_role=?,recovery_canonical=?,topic_id=?,topic_title=?,logical_topic_id=?,ordinary_visible=?,parent_id=? WHERE path=?`,
boolToInt(record.Recovered), boolToInt(record.RecoveryCopy), record.RecoveryGroupID, record.RecoveryRole,
boolToInt(record.RecoveryCanonical), record.TopicID, record.TopicTitle, record.LogicalTopicID,
boolToInt(record.OrdinaryVisible), record.ParentID, record.Path)
if err != nil {
_ = tx.Rollback()
return err
@@ -227,13 +544,24 @@ func firstNonEmpty(values ...string) string {
// Empty means keep the caller's path.
func CanonicalSessionPathForTopic(sessions []SessionRecord, current string) string {
var canonical string
canonicalRole := ""
for _, s := range sessions {
if s.RecoveryCanonical && s.RecoveryRole == RecoveryRoleAdopted {
if s.RecoveryCanonical && (s.RecoveryRole == RecoveryRoleAdopted || s.RecoveryRole == RecoveryRolePreferred) {
if canonical != "" && canonical != s.Path {
// Ambiguous: do not retarget.
return ""
}
canonical = s.Path
canonicalRole = s.RecoveryRole
}
}
if canonicalRole == RecoveryRolePreferred && current != "" {
for _, session := range sessions {
if session.Path == current && session.Recovered {
// An explicit click on another recovery leaf is inspection, not a
// request to follow the default choice.
return ""
}
}
}
if canonical == "" || canonical == current {
+238 -2
View File
@@ -2,7 +2,9 @@ package sessioncatalog
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
@@ -68,10 +70,41 @@ func TestPromoteCanonicalLeavesRequiresContentCoverage(t *testing.T) {
Path: peer, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, Turns: 2, TurnsState: TurnsValid,
})
out = promoteCanonicalLeaves(recs)
canonical := 0
for _, record := range out[1:] {
if record.RecoveryCanonical {
t.Fatalf("ambiguous equal-length leaves must stay diverged: %+v", out)
if record.RecoveryRole != RecoveryRoleDiverged {
t.Fatalf("ambiguous equal-length leaves must keep diverged role: %+v", out)
}
if record.RecoveryCanonical {
canonical++
}
}
// Ordinary list still needs exactly one stable representative even when
// content truly forks; History remains the place to open the other leaf.
if canonical != 1 {
t.Fatalf("ambiguous group canonical count = %d, want 1 stable representative: %+v", canonical, out)
}
}
func TestPromoteCanonicalLeavesIgnoresUnknownTurnMetadataAndCoversAncestors(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "root.jsonl")
ancestor := filepath.Join(dir, "ancestor.jsonl")
leaf := filepath.Join(dir, "leaf.jsonl")
saveLineageSession(t, root, "q", "a")
saveLineageSession(t, ancestor, "q", "a", "next", "one")
saveLineageSession(t, leaf, "q", "a", "next", "one", "again", "two")
recs := []SessionRecord{
{Path: root, RecoveryRole: RecoveryRoleNormal, TurnsState: TurnsUnknown},
{Path: ancestor, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, TurnsState: TurnsUnknown},
{Path: leaf, Recovered: true, ParentID: "ancestor", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, TurnsState: TurnsUnknown},
}
out := promoteCanonicalLeaves(recs)
if !out[2].RecoveryCanonical || out[2].RecoveryRole != RecoveryRoleAdopted {
t.Fatalf("leaf = %+v, want adopted despite unknown turns", out[2])
}
if !out[1].RecoveryCopy || out[1].RecoveryRole != RecoveryRoleCoveredCopy {
t.Fatalf("ancestor = %+v, want covered copy", out[1])
}
}
@@ -88,6 +121,31 @@ func TestCanonicalSessionPathForTopic(t *testing.T) {
}
}
func TestExplicitPreferredRecoveryWinsWithoutMakingPeersCovered(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "root.jsonl")
left := filepath.Join(dir, "left.jsonl")
right := filepath.Join(dir, "right.jsonl")
saveLineageSession(t, root, "q", "a")
saveLineageSession(t, left, "q", "a", "left", "answer")
saveLineageSession(t, right, "q", "a", "right", "answer")
records := []SessionRecord{
{Path: root, RecoveryRole: RecoveryRoleNormal},
{Path: left, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, RecoveryPreferred: true},
{Path: right, Recovered: true, ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged},
}
out := promoteCanonicalLeaves(records)
if !out[1].RecoveryCanonical || out[1].RecoveryRole != RecoveryRolePreferred {
t.Fatalf("preferred = %+v", out[1])
}
if out[2].RecoveryCopy || out[2].RecoveryRole != RecoveryRoleDiverged {
t.Fatalf("diverged peer must retain unique content: %+v", out[2])
}
if got := CanonicalSessionPathForTopic(out, root); got != left {
t.Fatalf("canonical path = %q, want %q", got, left)
}
}
func TestReconcilePersistsContentProvenCanonicalLeaf(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
@@ -118,3 +176,181 @@ func TestReconcilePersistsContentProvenCanonicalLeaf(t *testing.T) {
t.Fatalf("reconciled recovery = %+v, want adopted canonical", record)
}
}
func TestReconcileReanchorsCrossTopicRecoveryIntoOneLogicalTopic(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
root := filepath.Join(dir, "root.jsonl")
copyPath := filepath.Join(dir, "copy.jsonl")
leaf := filepath.Join(dir, "leaf.jsonl")
saveLineageSession(t, root, "q", "a")
saveLineageSession(t, copyPath, "q", "a")
saveLineageSession(t, leaf, "q", "a", "next", "done")
for path, meta := range map[string]agent.BranchMeta{
root: {ID: "root", Scope: "global", TopicID: "conversation", TopicTitle: "Upgraded"},
copyPath: {ID: "copy", Scope: "global", TopicID: "legacy-copy-topic", Recovered: true, ParentID: "root", RecoveryDepth: 1},
leaf: {ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic", Recovered: true, ParentID: "copy", RecoveryDepth: 2},
} {
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
t.Fatal(err)
}
}
catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
defer catalog.Close(ctx)
if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].TopicID != "conversation" {
t.Fatalf("ListTopics = %+v, want one logical topic conversation", page.Items)
}
leafRec, ok, err := catalog.GetSession(ctx, leaf)
if err != nil || !ok {
t.Fatalf("GetSession leaf ok=%v err=%v", ok, err)
}
if leafRec.TopicID != "conversation" || leafRec.LogicalTopicID != "conversation" {
t.Fatalf("leaf projection = %+v, want re-anchored to conversation", leafRec)
}
if leafRec.OrdinaryVisible {
t.Fatalf("leaf must not be ordinary-visible while root exists: %+v", leafRec)
}
rootRec, ok, err := catalog.GetSession(ctx, root)
if err != nil || !ok || !rootRec.OrdinaryVisible {
t.Fatalf("root ordinary visibility = %+v ok=%v err=%v", rootRec, ok, err)
}
}
func TestRecoveryFilenameParentID(t *testing.T) {
parent, ok := agent.RecoveryFilenameParentID("/s/chat-recovery-0123456789abcdef.jsonl")
if !ok || parent != "chat" {
t.Fatalf("parent = %q ok=%v", parent, ok)
}
if !agent.LooksLikeRecoveryFilename("/s/chat-recovery-0123456789abcdef.jsonl") {
t.Fatal("expected recovery filename")
}
if agent.LooksLikeRecoveryFilename("/s/chat.jsonl") {
t.Fatal("normal session must not look like recovery")
}
}
func TestUpgradeMatrixV4RebuildKeepsSingleLogicalRowAndAuthority(t *testing.T) {
// Simulates 1.24.2-style multi-topic recovery storm → new v4 projection →
// discard cache → reindex. Ordinary list stays one row; JSONL/meta bytes
// are never rewritten (the 1.23.0→new-version reinstall path).
ctx := context.Background()
dir := t.TempDir()
cacheDir := t.TempDir()
root := filepath.Join(dir, "root.jsonl")
copyPath := filepath.Join(dir, "copy.jsonl")
leaf := filepath.Join(dir, "leaf.jsonl")
saveLineageSession(t, root, "q", "a")
saveLineageSession(t, copyPath, "q", "a")
saveLineageSession(t, leaf, "q", "a", "next", "done")
for path, meta := range map[string]agent.BranchMeta{
root: {ID: "root", Scope: "global", TopicID: "conversation", TopicTitle: "Upgraded"},
copyPath: {ID: "copy", Scope: "global", TopicID: "legacy-copy-topic", Recovered: true, ParentID: "root", RecoveryDepth: 1},
leaf: {ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic", Recovered: true, ParentID: "copy", RecoveryDepth: 2},
} {
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
t.Fatal(err)
}
}
hash := func(path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
before := map[string]string{}
for _, path := range []string{root, copyPath, leaf, agent.BranchMetaPath(root), agent.BranchMetaPath(copyPath), agent.BranchMetaPath(leaf)} {
before[path] = hash(path)
}
openAndList := func(label string) (topicID, rep string) {
t.Helper()
catalog, err := Open(ctx, Options{Path: filepath.Join(cacheDir, label+".sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
defer catalog.Close(ctx)
if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 {
t.Fatalf("%s ListTopics = %+v, want one logical row", label, page.Items)
}
return page.Items[0].TopicID, page.Items[0].RepresentativePath
}
topic1, rep1 := openAndList("pass1")
topic2, rep2 := openAndList("pass2")
if topic1 != "conversation" || topic2 != "conversation" {
t.Fatalf("logical topics = %q/%q, want conversation both rebuilds", topic1, topic2)
}
if rep1 == "" || rep1 != rep2 {
t.Fatalf("representative unstable across rebuilds: %q vs %q", rep1, rep2)
}
for path, want := range before {
if got := hash(path); got != want {
t.Fatalf("authority file mutated during catalog rebuild: %s", path)
}
}
// v4 path is independent of older disposable caches.
if !strings.HasSuffix(filepath.ToSlash(DefaultPath()), "session-catalog/v4.sqlite") && DefaultPath() != "" {
t.Fatalf("DefaultPath = %q, want v4.sqlite", DefaultPath())
}
}
func TestReconcileFoldsFilenameRecoveryIntoRootTopic(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
root := filepath.Join(dir, "normal.jsonl")
recovery := filepath.Join(dir, "normal-recovery-0123456789abcdef.jsonl")
saveLineageSession(t, root, "normal imported prompt")
saveLineageSession(t, recovery, "legacy recovery prompt")
if err := agent.SaveBranchMetaPreserveUpdated(root, agent.BranchMeta{
ID: "normal", Scope: "global", TopicID: "legacy_normal", TopicTitle: "normal",
}); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMetaPreserveUpdated(recovery, agent.BranchMeta{
ID: "normal-recovery-0123456789abcdef", Scope: "global",
TopicID: "legacy_recovery", TopicTitle: "recovery",
}); err != nil {
t.Fatal(err)
}
catalog, err := Open(ctx, Options{InMemory: true, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
defer catalog.Close(ctx)
if err := catalog.ReconcileDirectory(ctx, DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
page, err := catalog.ListTopics(ctx, TopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].TopicID != "legacy_normal" {
t.Fatalf("ListTopics = %+v, want one folded legacy_normal topic", page.Items)
}
rec, ok, err := catalog.GetSession(ctx, recovery)
if err != nil || !ok {
t.Fatalf("GetSession recovery ok=%v err=%v", ok, err)
}
if !rec.Recovered || rec.TopicID != "legacy_normal" {
t.Fatalf("recovery projection = %+v, want re-anchored recovered row", rec)
}
}
@@ -0,0 +1,183 @@
package sessioncatalog
import (
"context"
"path/filepath"
"strings"
"reasonix/internal/agent"
)
// PreferredOrdinarySessionPaths returns the session paths that may appear in
// the ordinary project tree for one workspace. Covered recovery copies and
// non-preferred conflict forks are omitted so the sidebar matches the 1.23
// contract: one conversation row, not a wall of recovery replicas.
//
// Rules:
// - Every non-recovered session is preferred.
// - When a recovery group still has its non-recovered parent, no recovered
// member of that group is preferred (parent is the ordinary row).
// - Otherwise the group keeps a single preferred recovered leaf: canonical
// or adopted first, then highest turns, then latest activity.
//
// Open/running tabs may still force-show a path outside this set at the UI
// boundary; this helper only encodes on-disk ordinary visibility.
func (c *Catalog) PreferredOrdinarySessionPaths(ctx context.Context, scope, workspaceRoot string) (map[string]struct{}, error) {
out := map[string]struct{}{}
if c == nil || c.db == nil {
return out, nil
}
scope, workspaceRoot = normalizeScope(scope, workspaceRoot)
rows, err := c.db.QueryContext(ctx, `
SELECT path, recovered, parent_id, recovery_copy, recovery_group_id,
recovery_role, recovery_canonical, turns, last_activity_at
FROM catalog_sessions
WHERE scope=? AND workspace_root=? AND missing_since=0 AND health<>'missing'`,
scope, workspaceRoot)
if err != nil {
return out, err
}
defer rows.Close()
var sessions []SessionRecord
for rows.Next() {
var rec SessionRecord
var recoveryCopy, recoveryCanonical, recovered int
if err := rows.Scan(&rec.Path, &recovered, &rec.ParentID, &recoveryCopy,
&rec.RecoveryGroupID, &rec.RecoveryRole, &recoveryCanonical,
&rec.Turns, &rec.LastActivityAt); err != nil {
return map[string]struct{}{}, err
}
rec.Recovered = recovered != 0
rec.RecoveryCopy = recoveryCopy != 0
rec.RecoveryCanonical = recoveryCanonical != 0
if rec.RecoveryRole == "" {
if rec.RecoveryCopy {
rec.RecoveryRole = RecoveryRoleCoveredCopy
} else if rec.Recovered {
rec.RecoveryRole = RecoveryRoleDiverged
} else {
rec.RecoveryRole = RecoveryRoleNormal
}
}
sessions = append(sessions, rec)
}
if err := rows.Err(); err != nil {
return map[string]struct{}{}, err
}
return PreferredOrdinarySessionPaths(sessions), nil
}
// PreferredOrdinarySessionPaths selects ordinary-tree paths from an in-memory
// session set. Pure helper for tests and callers that already hold records.
func PreferredOrdinarySessionPaths(sessions []SessionRecord) map[string]struct{} {
preferred := make(map[string]struct{}, len(sessions))
normalRoots := map[string]struct{}{}
recoveredByGroup := map[string][]SessionRecord{}
for _, session := range sessions {
path := strings.TrimSpace(session.Path)
if path == "" {
continue
}
if session.RecoveryCopy || session.RecoveryRole == RecoveryRoleCoveredCopy {
continue
}
if !session.Recovered {
preferred[path] = struct{}{}
normalRoots[agent.BranchID(path)] = struct{}{}
continue
}
key := recoveryLineageKey(session)
recoveredByGroup[key] = append(recoveredByGroup[key], session)
}
for group, members := range recoveredByGroup {
if _, parentPresent := normalRoots[group]; parentPresent {
// Parent conversation still exists: keep only that row.
continue
}
best := pickPreferredRecovery(members)
if path := strings.TrimSpace(best.Path); path != "" {
preferred[path] = struct{}{}
}
}
return preferred
}
// OrdinaryTreeSession reports whether a catalog session should appear in the
// ordinary project tree given open/running runtime state and the preferred set.
func OrdinaryTreeSession(session SessionRecord, open, running bool, preferred map[string]struct{}) bool {
if open || running {
return true
}
if session.RecoveryCopy || session.RecoveryRole == RecoveryRoleCoveredCopy {
return false
}
if !session.Recovered {
return true
}
if preferred == nil {
// Without a preference set, still hide covered copies (handled above)
// but keep recovered leaves so callers that skip preference computation
// do not blank the tree. Prefer building PreferredOrdinarySessionPaths.
return true
}
_, ok := preferred[strings.TrimSpace(session.Path)]
return ok
}
func recoveryLineageKey(session SessionRecord) string {
if group := strings.TrimSpace(session.RecoveryGroupID); group != "" {
return group
}
if parent := strings.TrimSpace(session.ParentID); parent != "" {
return parent
}
// Isolated recovered leaf without parent metadata: its own group.
return "path:" + agent.BranchID(session.Path)
}
func pickPreferredRecovery(members []SessionRecord) SessionRecord {
if len(members) == 0 {
return SessionRecord{}
}
best := members[0]
for _, candidate := range members[1:] {
if recoveryRank(candidate) > recoveryRank(best) {
best = candidate
continue
}
if recoveryRank(candidate) < recoveryRank(best) {
continue
}
if candidate.Turns != best.Turns {
if candidate.Turns > best.Turns {
best = candidate
}
continue
}
if candidate.LastActivityAt != best.LastActivityAt {
if candidate.LastActivityAt > best.LastActivityAt {
best = candidate
}
continue
}
if filepath.Base(candidate.Path) < filepath.Base(best.Path) {
best = candidate
}
}
return best
}
func recoveryRank(session SessionRecord) int {
switch {
case session.RecoveryCanonical:
return 3
case session.RecoveryRole == RecoveryRoleAdopted:
return 2
case session.RecoveryRole == RecoveryRolePreferred:
return 2
case session.RecoveryRole == RecoveryRoleDiverged || session.Recovered:
return 1
default:
return 0
}
}
@@ -0,0 +1,100 @@
package sessioncatalog
import (
"context"
"path/filepath"
"testing"
)
func TestPreferredOrdinarySessionPathsHidesCoveredAndSiblingForks(t *testing.T) {
parent := SessionRecord{Path: "/s/root.jsonl", Recovered: false, RecoveryRole: RecoveryRoleNormal, Turns: 4}
covered := SessionRecord{
Path: "/s/root-recovery-a.jsonl", Recovered: true, RecoveryCopy: true,
RecoveryRole: RecoveryRoleCoveredCopy, ParentID: "root", RecoveryGroupID: "root", Turns: 2,
}
forkA := SessionRecord{
Path: "/s/root-recovery-b.jsonl", Recovered: true, RecoveryRole: RecoveryRoleDiverged,
ParentID: "root", RecoveryGroupID: "root", Turns: 5, LastActivityAt: 10,
}
forkB := SessionRecord{
Path: "/s/root-recovery-c.jsonl", Recovered: true, RecoveryRole: RecoveryRoleDiverged,
ParentID: "root", RecoveryGroupID: "root", Turns: 3, LastActivityAt: 20,
}
// Parent present → only the normal session is preferred.
got := PreferredOrdinarySessionPaths([]SessionRecord{parent, covered, forkA, forkB})
if _, ok := got[parent.Path]; !ok {
t.Fatalf("parent missing from preferred: %+v", got)
}
for _, path := range []string{covered.Path, forkA.Path, forkB.Path} {
if _, ok := got[path]; ok {
t.Fatalf("recovery fork %s should stay hidden while parent exists", path)
}
}
// Parent gone → keep the longest recovered leaf only.
got = PreferredOrdinarySessionPaths([]SessionRecord{covered, forkA, forkB})
if len(got) != 1 {
t.Fatalf("preferred = %+v, want only the longest leaf", got)
}
if _, ok := got[forkA.Path]; !ok {
t.Fatalf("preferred = %+v, want longest fork %s", got, forkA.Path)
}
}
func TestPreferredOrdinarySessionPathsPrefersCanonical(t *testing.T) {
short := SessionRecord{
Path: "/s/a.jsonl", Recovered: true, RecoveryRole: RecoveryRoleDiverged,
RecoveryGroupID: "g", Turns: 9, LastActivityAt: 50,
}
canonical := SessionRecord{
Path: "/s/b.jsonl", Recovered: true, RecoveryRole: RecoveryRoleAdopted,
RecoveryCanonical: true, RecoveryGroupID: "g", Turns: 2, LastActivityAt: 1,
}
got := PreferredOrdinarySessionPaths([]SessionRecord{short, canonical})
if _, ok := got[canonical.Path]; !ok || len(got) != 1 {
t.Fatalf("preferred = %+v, want canonical only", got)
}
}
func TestOrdinaryTreeSessionForceShowsOpenFork(t *testing.T) {
fork := SessionRecord{Path: "/s/fork.jsonl", Recovered: true, RecoveryRole: RecoveryRoleDiverged}
preferred := map[string]struct{}{}
if OrdinaryTreeSession(fork, false, false, preferred) {
t.Fatal("idle non-preferred fork must stay hidden")
}
if !OrdinaryTreeSession(fork, true, false, preferred) {
t.Fatal("open fork must stay reachable")
}
}
func TestCatalogPreferredOrdinarySessionPaths(t *testing.T) {
ctx := context.Background()
catalog, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "c.sqlite"), DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
dir := t.TempDir()
records := []SessionRecord{
{Path: filepath.Join(dir, "root.jsonl"), Directory: dir, Scope: "global", TopicID: "t1",
Turns: 2, TurnsState: TurnsValid, Health: HealthOK, LastActivityAt: 30},
{Path: filepath.Join(dir, "fork.jsonl"), Directory: dir, Scope: "global", TopicID: "t2",
Turns: 4, TurnsState: TurnsValid, Health: HealthOK, Recovered: true,
ParentID: "root", RecoveryGroupID: "root", RecoveryRole: RecoveryRoleDiverged, LastActivityAt: 40},
}
for _, record := range records {
if err := catalog.UpsertSession(ctx, record); err != nil {
t.Fatal(err)
}
}
got, err := catalog.PreferredOrdinarySessionPaths(ctx, "global", "")
if err != nil {
t.Fatal(err)
}
if _, ok := got[records[0].Path]; !ok {
t.Fatalf("preferred = %+v, want parent", got)
}
if _, ok := got[records[1].Path]; ok {
t.Fatalf("preferred = %+v, parent present so fork must hide", got)
}
}
+12
View File
@@ -340,6 +340,7 @@ func (c *Catalog) IndexSessionPath(ctx context.Context, target DirectoryTarget,
order.RecoveryReason = meta.RecoveryReason
order.RecoveryDigest = meta.RecoveryDigest
order.ParentID = meta.ParentID
order.RecoveryPreferred = agent.RecoveryPreferenceCurrent(path, meta)
order.Turns = meta.Turns
order.Preview = meta.Preview
order.SchemaVersion = meta.SchemaVersion
@@ -403,6 +404,7 @@ func recordFromOrder(target DirectoryTarget, info agent.SessionOrderInfo) Sessio
RecoveryReason: info.RecoveryReason,
RecoveryDigest: info.RecoveryDigest,
ParentID: info.ParentID,
RecoveryPreferred: info.RecoveryPreferred,
RecoveryCopy: recoveryCopy,
ContentFingerprint: contentFingerprint,
MetaFingerprint: metaFingerprint,
@@ -687,6 +689,10 @@ func (c *Catalog) applyRepairResult(ctx context.Context, path, preview string, t
}
c.publishRevision(revision, []string{key.WorkspaceRoot}, reason)
c.refreshCounts(ctx)
// Repair changes content-derived listing metadata. Reconcile the directory
// immediately so lineage cannot remain stuck in its pre-repair projection.
// Requests are coalesced and never block the repair worker.
c.RequestReconcile(DirectoryTarget{Path: filepath.Dir(path), Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot})
return nil
}
@@ -793,5 +799,11 @@ func Inspect(ctx context.Context, path string) (Status, error) {
_ = db.QueryRowContext(ctx, `SELECT revision FROM catalog_state WHERE id=1`).Scan(&status.Revision)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions`).Scan(&status.Indexed)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE turns_state='unknown'`).Scan(&status.RepairPending)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE missing_since=0`).Scan(&status.PhysicalSessions)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_topics`).Scan(&status.LogicalSessions)
_ = db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT recovery_group_id) FROM catalog_sessions WHERE recovered=1 AND recovery_group_id<>'' AND missing_since=0`).Scan(&status.RecoveryGroups)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND missing_since=0`).Scan(&status.RecoveryBranches)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='diverged' AND missing_since=0`).Scan(&status.RecoveryDiverged)
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE recovered=1 AND recovery_role='covered_copy' AND missing_since=0`).Scan(&status.CleanupEligible)
return status, nil
}
@@ -12,15 +12,15 @@ import (
"reasonix/internal/provider"
)
func TestDefaultPathUsesV2CacheFile(t *testing.T) {
func TestDefaultPathUsesV4CacheFile(t *testing.T) {
t.Parallel()
path := DefaultPath()
if path == "" {
// CacheDir unavailable in this environment; empty is still valid.
return
}
if !strings.HasSuffix(filepath.ToSlash(path), "session-catalog/v3.sqlite") {
t.Fatalf("DefaultPath = %q, want .../session-catalog/v3.sqlite", path)
if !strings.HasSuffix(filepath.ToSlash(path), "session-catalog/v4.sqlite") {
t.Fatalf("DefaultPath = %q, want .../session-catalog/v4.sqlite", path)
}
if strings.Contains(path, "v1.sqlite") {
t.Fatalf("DefaultPath must not reuse the 1.24.0 v1 cache: %q", path)
@@ -201,8 +201,8 @@ func TestTopicTurnsIgnoreRecoveryCopyButKeepActivity(t *testing.T) {
if topic.LastActivityAt != base+60_000 {
t.Fatalf("lastActivityAt = %d, want recovery activity", topic.LastActivityAt)
}
if topic.RecoveryState != "" {
t.Fatalf("recovery_state = %q, want empty for mixed topic", topic.RecoveryState)
if topic.RecoveryState != "recovery_only" && topic.RecoveryState != "" {
t.Fatalf("recovery_state = %q, want ordinary/covered-only state", topic.RecoveryState)
}
}
@@ -0,0 +1,70 @@
package sessioncatalog
import (
"context"
"path/filepath"
"strings"
)
// RecoveryGroup is a catalog projection of one proved recovery lineage. It is
// diagnostic input only; destructive callers must revalidate file contents and
// locks through agent helpers before moving anything.
type RecoveryGroup struct {
ID string `json:"id"`
Directory string `json:"directory"`
RootPath string `json:"rootPath,omitempty"`
CanonicalPath string `json:"canonicalPath,omitempty"`
State string `json:"state"`
Members []SessionRecord `json:"members"`
}
// ListRecoveryGroups returns every indexed recovery lineage in directory. All
// slices are initialized for stable CLI/Wails JSON contracts.
func (c *Catalog) ListRecoveryGroups(ctx context.Context, directory string) ([]RecoveryGroup, error) {
out := []RecoveryGroup{}
if c == nil || c.db == nil {
return out, nil
}
directory = filepath.Clean(strings.TrimSpace(directory))
rows, err := c.db.QueryContext(ctx, `SELECT `+sessionSelectColumns+` FROM catalog_sessions
WHERE directory=? AND recovered=1 AND recovery_group_id<>'' AND missing_since=0
ORDER BY recovery_group_id,path`, directory)
if err != nil {
return out, err
}
defer rows.Close()
byID := map[string]int{}
for rows.Next() {
record, err := scanSession(rows)
if err != nil {
return []RecoveryGroup{}, err
}
index, ok := byID[record.RecoveryGroupID]
if !ok {
index = len(out)
byID[record.RecoveryGroupID] = index
out = append(out, RecoveryGroup{ID: record.RecoveryGroupID, Directory: directory,
RootPath: filepath.Join(directory, record.RecoveryGroupID+".jsonl"), Members: []SessionRecord{}})
}
group := &out[index]
group.Members = append(group.Members, record)
if record.RecoveryCanonical && record.RecoveryRole == RecoveryRolePreferred {
group.CanonicalPath = record.Path
group.State = "preferred"
} else if record.RecoveryCanonical && record.RecoveryRole == RecoveryRoleAdopted {
group.CanonicalPath = record.Path
group.State = "adopted"
} else if group.State == "" && record.RecoveryRole == RecoveryRoleDiverged {
group.State = "diverged"
}
}
if err := rows.Err(); err != nil {
return []RecoveryGroup{}, err
}
for index := range out {
if out[index].State == "" {
out[index].State = "repairing"
}
}
return out, nil
}
+21
View File
@@ -113,6 +113,19 @@ CREATE INDEX IF NOT EXISTS idx_catalog_sessions_recovery_group
ON catalog_sessions(recovery_group_id, recovery_role, last_activity_at DESC);
`
const migrationV6 = `
ALTER TABLE catalog_topics ADD COLUMN recovery_branch_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE catalog_topics ADD COLUMN recovery_unresolved_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE catalog_topics ADD COLUMN recovery_cleanup_eligible_count INTEGER NOT NULL DEFAULT 0;
`
const migrationV7 = `
ALTER TABLE catalog_sessions ADD COLUMN logical_topic_id TEXT NOT NULL DEFAULT '';
ALTER TABLE catalog_sessions ADD COLUMN ordinary_visible INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_catalog_sessions_ordinary
ON catalog_sessions(scope, workspace_root, ordinary_visible, last_activity_at DESC);
`
func sessionMigrations() []projectiondb.Migration {
return []projectiondb.Migration{
{Version: 1, Apply: func(ctx context.Context, tx *sql.Tx) error {
@@ -135,5 +148,13 @@ func sessionMigrations() []projectiondb.Migration {
_, err := tx.ExecContext(ctx, migrationV5)
return err
}},
{Version: 6, Apply: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, migrationV6)
return err
}},
{Version: 7, Apply: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, migrationV7)
return err
}},
}
}
+7 -3
View File
@@ -21,21 +21,25 @@ type sessionPageCursor struct {
const sessionSelectColumns = `path,directory,scope,workspace_root,topic_id,topic_title,
custom_title,created_at,last_activity_at,preview,turns,turns_state,recovered,
recovery_reason,recovery_digest,parent_id,recovery_copy,recovery_group_id,
recovery_role,recovery_canonical,content_fingerprint,
recovery_role,recovery_canonical,logical_topic_id,ordinary_visible,content_fingerprint,
meta_fingerprint,health,missing_since`
func scanSession(scanner interface{ Scan(...any) error }) (SessionRecord, error) {
var record SessionRecord
var recoveryCopy, recoveryCanonical int
var recoveryCopy, recoveryCanonical, ordinaryVisible int
err := scanner.Scan(&record.Path, &record.Directory, &record.Scope, &record.WorkspaceRoot,
&record.TopicID, &record.TopicTitle, &record.CustomTitle, &record.CreatedAt,
&record.LastActivityAt, &record.Preview, &record.Turns, &record.TurnsState,
&record.Recovered, &record.RecoveryReason, &record.RecoveryDigest,
&record.ParentID, &recoveryCopy, &record.RecoveryGroupID, &record.RecoveryRole,
&recoveryCanonical, &record.ContentFingerprint, &record.MetaFingerprint,
&recoveryCanonical, &record.LogicalTopicID, &ordinaryVisible, &record.ContentFingerprint, &record.MetaFingerprint,
&record.Health, &record.MissingSince)
record.RecoveryCopy = recoveryCopy != 0
record.RecoveryCanonical = recoveryCanonical != 0
record.OrdinaryVisible = ordinaryVisible != 0
if record.LogicalTopicID == "" {
record.LogicalTopicID = record.TopicID
}
if record.RecoveryRole == "" {
if record.RecoveryCopy {
record.RecoveryRole = RecoveryRoleCoveredCopy
+66 -45
View File
@@ -12,7 +12,7 @@ import (
)
const (
SchemaVersion = 5
SchemaVersion = 7
DefaultLimit = 50
MaxLimit = 200
)
@@ -52,15 +52,21 @@ const (
)
type Status struct {
State State `json:"state"`
Mode Mode `json:"mode"`
Path string `json:"path,omitempty"`
Revision uint64 `json:"revision"`
Indexed int64 `json:"indexed"`
Total int64 `json:"total"`
RepairPending int64 `json:"repairPending"`
LastError string `json:"lastError,omitempty"`
QuarantinedPath string `json:"quarantinedPath,omitempty"`
State State `json:"state"`
Mode Mode `json:"mode"`
Path string `json:"path,omitempty"`
Revision uint64 `json:"revision"`
Indexed int64 `json:"indexed"`
Total int64 `json:"total"`
RepairPending int64 `json:"repairPending"`
PhysicalSessions int64 `json:"physicalSessions"`
LogicalSessions int64 `json:"logicalSessions"`
RecoveryGroups int64 `json:"recoveryGroups"`
RecoveryBranches int64 `json:"recoveryBranches"`
RecoveryDiverged int64 `json:"recoveryDiverged"`
CleanupEligible int64 `json:"cleanupEligible"`
LastError string `json:"lastError,omitempty"`
QuarantinedPath string `json:"quarantinedPath,omitempty"`
}
type Options struct {
@@ -100,32 +106,40 @@ type TopicMetadata struct {
}
type SessionRecord struct {
Path string `json:"path"`
Directory string `json:"directory"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId,omitempty"`
TopicTitle string `json:"topicTitle,omitempty"`
CustomTitle string `json:"customTitle,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
Preview string `json:"preview,omitempty"`
Turns int `json:"turns"`
TurnsState TurnsState `json:"turnsState"`
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recoveryReason,omitempty"`
RecoveryDigest string `json:"recoveryDigest,omitempty"`
ParentID string `json:"parentId,omitempty"`
Path string `json:"path"`
Directory string `json:"directory"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId,omitempty"`
TopicTitle string `json:"topicTitle,omitempty"`
CustomTitle string `json:"customTitle,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
Preview string `json:"preview,omitempty"`
Turns int `json:"turns"`
TurnsState TurnsState `json:"turnsState"`
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recoveryReason,omitempty"`
RecoveryDigest string `json:"recoveryDigest,omitempty"`
ParentID string `json:"parentId,omitempty"`
RecoveryPreferred bool `json:"recoveryPreferred,omitempty"`
// RecoveryCopy is true only when real content is still covered by the parent.
RecoveryCopy bool `json:"recoveryCopy,omitempty"`
// RecoveryGroupID clusters a lineage of normal + recovery branches that
// share content ancestry. Empty for ordinary non-recovery sessions.
RecoveryGroupID string `json:"recoveryGroupId,omitempty"`
// RecoveryRole is normal | covered_copy | adopted | diverged.
// RecoveryRole is normal | covered_copy | adopted | preferred | diverged.
RecoveryRole string `json:"recoveryRole,omitempty"`
// RecoveryCanonical marks the unique leaf that covers the group and should
// be opened by default. Never moves or rewrites files.
RecoveryCanonical bool `json:"recoveryCanonical,omitempty"`
RecoveryCanonical bool `json:"recoveryCanonical,omitempty"`
// LogicalTopicID is the ordinary-list topic for this physical file. Recovery
// copies are re-anchored onto the root conversation's topic in the catalog
// only; authoritative sidecars keep their original topic_id.
LogicalTopicID string `json:"logicalTopicId,omitempty"`
// OrdinaryVisible is true only for the single logical representative that
// may appear in the ordinary project tree.
OrdinaryVisible bool `json:"ordinaryVisible,omitempty"`
ContentFingerprint string `json:"contentFingerprint,omitempty"`
MetaFingerprint string `json:"metaFingerprint,omitempty"`
Health Health `json:"health"`
@@ -137,6 +151,7 @@ const (
RecoveryRoleNormal = "normal"
RecoveryRoleCoveredCopy = "covered_copy"
RecoveryRoleAdopted = "adopted"
RecoveryRolePreferred = "preferred"
RecoveryRoleDiverged = "diverged"
)
@@ -147,19 +162,24 @@ type TopicKey struct {
}
type TopicRecord struct {
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId"`
Title string `json:"title"`
Pinned bool `json:"pinned,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
Turns int `json:"turns"`
TurnsState TurnsState `json:"turnsState"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
RecoveryState string `json:"recoveryState,omitempty"`
Health Health `json:"health"`
Sessions []SessionRecord `json:"sessions"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
TopicID string `json:"topicId"`
Title string `json:"title"`
Pinned bool `json:"pinned,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
Turns int `json:"turns"`
TurnsState TurnsState `json:"turnsState"`
CreatedAt int64 `json:"createdAt,omitempty"`
LastActivityAt int64 `json:"lastActivityAt,omitempty"`
RecoveryState string `json:"recoveryState,omitempty"`
RecoveryBranchCount int `json:"recoveryBranchCount,omitempty"`
RecoveryUnresolvedCount int `json:"recoveryUnresolvedCount,omitempty"`
RecoveryCleanupEligibleCount int `json:"recoveryCleanupEligibleCount,omitempty"`
// RepresentativePath is the automatic open target for this logical topic.
RepresentativePath string `json:"representativePath,omitempty"`
Health Health `json:"health"`
Sessions []SessionRecord `json:"sessions"`
}
type TopicPageRequest struct {
@@ -195,13 +215,14 @@ type SessionPage struct {
}
// DefaultPath is the disposable cache file under CacheDir ("" when unavailable).
// v3.sqlite is independent of the 1.24.0/1.24.1 v1/v2 caches so processes cannot
// cross-write; polluted older catalogs are ignored and rebuilt from authoritative
// JSONL/WAL/sidecar data.
// v4.sqlite is independent of the v1-v3 caches so a new build never trusts a
// polluted lineage projection produced before content-based canonical selection.
// Session JSONL/WAL/sidecars remain authoritative and older binaries may keep
// using their own disposable cache without cross-writing this one.
func DefaultPath() string {
cache := strings.TrimSpace(config.CacheDir())
if cache == "" {
return ""
}
return filepath.Join(cache, "session-catalog", "v3.sqlite")
return filepath.Join(cache, "session-catalog", "v4.sqlite")
}
+252
View File
@@ -1,6 +1,258 @@
{
"schemaVersion": 1,
"releases": [
{
"version": "1.24.2",
"date": "2026-08-12",
"channel": "stable",
"title": {
"en": "Reasonix Desktop 1.24.2",
"zh": "Reasonix Desktop 1.24.2"
},
"summary": {
"en": "Stability and recovery improvements for session persistence, transcript scrolling, and Windows startup.",
"zh": "会话持久化、对话滚动和 Windows 启动的稳定性与恢复改进。"
},
"surfaces": [
"desktop"
],
"guides": [
{
"title": {
"en": "Session Catalog",
"zh": "会话目录"
},
"body": {
"en": "Learn how the session catalog organizes recovery copies and logical sessions.",
"zh": "了解会话目录如何组织恢复副本和逻辑会话。"
},
"href": "https://github.com/esengine/DeepSeek-Reasonix/blob/f53edb599a929afe0c6fa514c8e441899c166afc/docs/SESSION_CATALOG.md"
},
{
"title": {
"en": "Configuration Paths",
"zh": "配置路径"
},
"body": {
"en": "Reference for configuration file locations and environment variables.",
"zh": "配置文件位置和环境变量参考。"
},
"href": "https://github.com/esengine/DeepSeek-Reasonix/blob/f53edb599a929afe0c6fa514c8e441899c166afc/docs/CONFIG_PATHS.md"
}
],
"highlights": [
{
"kind": "fixed",
"title": {
"en": "Recovery lineages now appear as a single logical session",
"zh": "恢复谱系现在显示为单个逻辑会话"
},
"body": {
"en": "Recovery copies are folded into one ordinary list row, preventing duplicate-looking sessions after upgrades.",
"zh": "恢复副本折叠为单个普通列表行,防止升级后出现重复会话。"
},
"refs": [
8599
]
},
{
"kind": "fixed",
"title": {
"en": "Sidebar no longer freezes on a wedged projection",
"zh": "侧边栏不再因投影卡死而冻结"
},
"body": {
"en": "Bounded sidebar reads and background metadata sync prevent permanent freezes when the session catalog pool is saturated.",
"zh": "限制侧边栏读取和后台元数据同步,防止会话目录连接池饱和时永久冻结。"
},
"refs": [
8594
]
},
{
"kind": "fixed",
"title": {
"en": "Stable transcript scrolling during async row growth",
"zh": "异步行高变化时对话滚动稳定"
},
"body": {
"en": "React Virtuoso now owns virtual measurement, preventing jumps when Markdown or code blocks finish loading.",
"zh": "React Virtuoso 现在负责虚拟测量,防止 Markdown 或代码块加载完成时跳动。"
},
"refs": [
8492,
8429,
8586
]
},
{
"kind": "fixed",
"title": {
"en": "Windows startup no longer flashes console windows",
"zh": "Windows 启动不再闪现控制台窗口"
},
"body": {
"en": "Startup git probes now use the hardened command wrapper, hiding console windows.",
"zh": "启动时的 git 探测现在使用加固的命令包装器,隐藏控制台窗口。"
},
"refs": [
8425
]
},
{
"kind": "fixed",
"title": {
"en": "Lifecycle records are no longer lost to Windows sharing violations",
"zh": "生命周期记录不再因 Windows 共享冲突而丢失"
},
"body": {
"en": "Retry logic for rename and re-read operations ensures crash evidence is preserved when multiple instances start together.",
"zh": "重命名和重读操作的重试逻辑确保多个实例同时启动时保留崩溃证据。"
},
"refs": [
8550
]
},
{
"kind": "fixed",
"title": {
"en": "Projection lineage rebinds across upgrades and model switches",
"zh": "投影 lineage 在升级和模型切换时重新绑定"
},
"body": {
"en": "Context projections are preserved when the lineage key changes, avoiding full-price compaction on resume.",
"zh": "当 lineage 键变化时保留上下文投影,避免恢复时全价压缩。"
},
"refs": [
8390,
8400
]
},
{
"kind": "fixed",
"title": {
"en": "Recovery forks no longer multiply without bound",
"zh": "恢复分支不再无限增长"
},
"body": {
"en": "False diverged forks are prevented and depth-cap isolation is bounded to one file per writer.",
"zh": "防止误判 diverged 分支,并将深度上限隔离限制为每个写入者一个文件。"
},
"refs": [
8444,
8430,
8457,
7982
]
}
],
"changes": {
"new": [],
"improved": [
{
"title": {
"en": "Session catalog now uses a disposable v4 database",
"zh": "会话目录现在使用一次性 v4 数据库"
},
"body": {
"en": "Logical projection and recovery roles are rebuilt from authoritative content, preventing cross-version write conflicts.",
"zh": "逻辑投影和恢复角色从权威内容重建,防止跨版本写入冲突。"
},
"refs": [
8599,
8425
]
},
{
"title": {
"en": "Agent state refactored for clarity and safety",
"zh": "Agent 状态重构以提高清晰度和安全性"
},
"body": {
"en": "Collaborators, configuration, and runtime lifetimes are separated, with guard tests preventing accidental state changes.",
"zh": "协作者、配置和运行时生命周期分离,并通过保护测试防止意外状态更改。"
},
"refs": [
8595,
8564,
8588,
8541,
8485,
8460,
8453,
8447,
8439
]
},
{
"title": {
"en": "Compaction failure records preserved across folds",
"zh": "压缩失败记录在多次折叠中保留"
},
"body": {
"en": "Projection re-reads now keep tool execution details, so the model continues to see failure context.",
"zh": "投影重读现在保留工具执行细节,模型继续看到失败上下文。"
},
"refs": [
8432
]
}
],
"fixed": [
{
"title": {
"en": "Recovery copy classification restored",
"zh": "恢复副本分类恢复"
},
"body": {
"en": "Covered recovery copies are hidden from the project tree, reducing duplicate-looking sessions.",
"zh": "隐藏已覆盖的恢复副本,减少重复会话。"
},
"refs": [
8425
]
},
{
"title": {
"en": "Repolint baseline corrected",
"zh": "Repolint 基线修正"
},
"body": {
"en": "Generated bindings are excluded from measurement and stale budgets are resynced.",
"zh": "生成的绑定从测量中排除,过时的预算重新同步。"
},
"refs": [
8605,
8503,
8436
]
}
]
},
"upgrade": [],
"risks": [],
"contributors": [
"SivanCola",
"yingziisme",
"esengine",
"erphenheimer",
"Drummerms"
],
"links": {
"github": "https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.24.2",
"compare": "https://github.com/esengine/DeepSeek-Reasonix/compare/desktop-v1.24.1...desktop-v1.24.2",
"download": "https://reasonix.io/?download=desktop&channel=stable#start"
},
"releaseId": "1.24.2",
"baseVersion": "1.24.2",
"status": "reviewed",
"previousRelease": "1.24.1",
"builds": {
"cli": "v1.24.2",
"desktop": "v1.24.2",
"npm": "1.24.2"
}
},
{
"version": "1.24.1",
"date": "2026-08-11",
+49 -18
View File
@@ -2,15 +2,15 @@
"limits": {
"banner": 15,
"commented-code": 0,
"complexity": 2069,
"essay": 3983,
"file-size": 108119,
"function-size": 8846,
"complexity": 2109,
"essay": 4001,
"file-size": 108396,
"function-size": 8934,
"layering": 1,
"marker": 0,
"narrative": 64,
"struct-state": 115,
"test-file-size": 69236
"test-file-size": 69269
},
"files": {
"cmd/e2ebench/main.go": {
@@ -130,6 +130,9 @@
"desktop/frontend/src/components/ContextPanel.tsx": {
"file-size": 38
},
"desktop/frontend/src/components/HistoryPanel.tsx": {
"file-size": 65
},
"desktop/frontend/src/components/MemoryPanel.tsx": {
"file-size": 1088
},
@@ -137,7 +140,7 @@
"file-size": 95
},
"desktop/frontend/src/components/ProjectTree.tsx": {
"file-size": 1475
"file-size": 1474
},
"desktop/frontend/src/components/RichComposerInput.tsx": {
"file-size": 141
@@ -167,7 +170,7 @@
"file-size": 415
},
"desktop/frontend/src/lib/bridge.ts": {
"file-size": 4756
"file-size": 4780
},
"desktop/frontend/src/lib/crash.ts": {
"file-size": 225
@@ -176,7 +179,7 @@
"file-size": 269
},
"desktop/frontend/src/lib/types.ts": {
"file-size": 1527
"file-size": 1578
},
"desktop/frontend/src/lib/useController.ts": {
"file-size": 3969
@@ -235,6 +238,9 @@
"desktop/race_regression_test.go": {
"essay": 4
},
"desktop/recovery_gc.go": {
"essay": 2
},
"desktop/recovery_gc_test.go": {
"essay": 1
},
@@ -255,6 +261,12 @@
"desktop/runtime_rebuilt_event_test.go": {
"essay": 1
},
"desktop/session_catalog_app_test.go": {
"essay": 1
},
"desktop/session_catalog_runtime.go": {
"essay": 2
},
"desktop/session_errors.go": {
"essay": 1
},
@@ -310,7 +322,7 @@
"desktop/tabs.go": {
"complexity": 41,
"essay": 111,
"file-size": 7704,
"file-size": 7708,
"function-size": 369,
"struct-state": 23
},
@@ -322,7 +334,7 @@
},
"desktop/tabs_topic_test.go": {
"essay": 1,
"test-file-size": 3250
"test-file-size": 3261
},
"desktop/temphelper_test.go": {
"essay": 4
@@ -572,7 +584,8 @@
"essay": 8
},
"internal/agent/recovery_gc.go": {
"essay": 17
"essay": 17,
"file-size": 60
},
"internal/agent/recovery_gc_test.go": {
"essay": 1
@@ -590,14 +603,14 @@
"essay": 5
},
"internal/agent/save.go": {
"complexity": 36,
"complexity": 37,
"essay": 109,
"file-size": 1466,
"function-size": 102
"file-size": 1488,
"function-size": 105
},
"internal/agent/save_test.go": {
"essay": 9,
"test-file-size": 1946
"test-file-size": 1968
},
"internal/agent/scheduler.go": {
"essay": 1
@@ -940,12 +953,16 @@
"internal/cli/session_lease.go": {
"essay": 4
},
"internal/cli/sessions_catalog.go": {
"complexity": 10,
"function-size": 3
},
"internal/cli/setup_manager.go": {
"essay": 2,
"file-size": 126
},
"internal/cli/shell_completion.go": {
"function-size": 113
"function-size": 119
},
"internal/cli/skill_hooks.go": {
"essay": 1
@@ -1088,8 +1105,8 @@
"internal/control/controller.go": {
"complexity": 10,
"essay": 161,
"file-size": 5489,
"function-size": 75,
"file-size": 5529,
"function-size": 82,
"narrative": 4,
"struct-state": 17
},
@@ -1755,6 +1772,20 @@
"internal/serve/switch_recovery_test.go": {
"essay": 2
},
"internal/sessioncatalog/catalog.go": {
"file-size": 3
},
"internal/sessioncatalog/lineage.go": {
"complexity": 29,
"essay": 4,
"function-size": 69
},
"internal/sessioncatalog/ordinary_visibility.go": {
"essay": 9
},
"internal/sessioncatalog/reconcile.go": {
"file-size": 9
},
"internal/sessiontemp/manager.go": {
"essay": 5
},