mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
Merge pull request #9900 from SivanCola/fix/desktop-rejection-containment
fix(desktop): contain rejected backend calls instead of the crash overlay / 后端调用失败改为提示而非崩溃遮罩
This commit is contained in:
12 files changed
+319
-21
No files matched your search
@@ -30,6 +30,7 @@ import {
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "./lib/toast";
|
||||
import { onRecoverableError } from "./lib/globalCrashHandlers";
|
||||
import { useGoalActionHandler } from "./lib/goalAction";
|
||||
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
|
||||
import { asArray } from "./lib/array";
|
||||
@@ -1810,6 +1811,7 @@ export default function App() {
|
||||
}
|
||||
drainExtensionNotifications();
|
||||
}, [state.extensionNotifications, showToast, drainExtensionNotifications]);
|
||||
useEffect(() => onRecoverableError(({ message }) => showToast(message, "warn", { durationMs: 6000 })), [showToast]);
|
||||
const extensionStatusList = useMemo(() => Object.values(state.extensionStatuses ?? {}), [state.extensionStatuses]);
|
||||
const patchActiveComposerProfile = useCallback(
|
||||
(patch: Partial<Omit<ComposerProfile, "pending">>, pendingFields: ComposerProfileField[]) => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Run: tsx src/__tests__/crash-rejection-containment.test.ts
|
||||
//
|
||||
// A rejected Wails call arrives as a bare string (or a stackless object). That
|
||||
// is an ordinary backend error and must never paint the full-screen crash
|
||||
// overlay; only rejections carrying a real stack keep the crash surface.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" });
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.Event = dom.window.Event;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
|
||||
const { installGlobalCrashHandlers, isRecoverableRejectionReason, onRecoverableError } = await import("../lib/globalCrashHandlers");
|
||||
const { setFrontendDiagnosticSink } = await import("../lib/frontendDiagnosticBridge");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(cond: boolean, label: string) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const diagnostics: string[] = [];
|
||||
setFrontendDiagnosticSink((_source, type) => { diagnostics.push(type); });
|
||||
const toasts: string[] = [];
|
||||
onRecoverableError(({ message }) => { toasts.push(message); });
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
|
||||
// Mirrors main.tsx: bridge-level Wails filters are installed before the crash handlers.
|
||||
window.addEventListener("unhandledrejection", (e) => { if (e.reason === "bridge-filtered") e.preventDefault(); });
|
||||
installGlobalCrashHandlers();
|
||||
|
||||
function rejectWith(reason: unknown): Event {
|
||||
const event = new dom.window.Event("unhandledrejection", { cancelable: true });
|
||||
Object.defineProperty(event, "reason", { value: reason });
|
||||
window.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
const overlay = () => document.getElementById("crash-overlay");
|
||||
|
||||
console.log("\ncrash rejection containment");
|
||||
|
||||
const stacked = new TypeError("renderer fault");
|
||||
stacked.stack = "TypeError: renderer fault\n at render (src/App.tsx:12:3)";
|
||||
ok(isRecoverableRejectionReason("selected branch is outside the recovery lineage"), "a bare Go error string is recoverable");
|
||||
ok(isRecoverableRejectionReason({ message: "stackless error-like object" }), "a stackless error-like object is recoverable");
|
||||
ok(isRecoverableRejectionReason(undefined), "an undefined reason is recoverable");
|
||||
ok(!isRecoverableRejectionReason(stacked), "an Error with a stack is a genuine crash");
|
||||
|
||||
const backend = rejectWith("selected branch is outside the recovery lineage");
|
||||
ok(backend.defaultPrevented, "a backend rejection is marked handled");
|
||||
ok(overlay() === null, "a backend rejection does not paint the crash overlay");
|
||||
ok(
|
||||
toasts.length === 1 && toasts[0] === "selected branch is outside the recovery lineage",
|
||||
"a backend rejection dispatches the recoverable-error event with its message",
|
||||
);
|
||||
ok(diagnostics.includes("unhandled-backend-rejection"), "a backend rejection records a frontend diagnostic");
|
||||
|
||||
rejectWith('remote tab "remote-1" status was superseded by newer runtime state');
|
||||
ok(overlay() === null && toasts.length === 1, "existing crash suppressions still short-circuit before containment");
|
||||
|
||||
rejectWith("bridge-filtered");
|
||||
ok(overlay() === null && toasts.length === 1, "a rejection already handled by a bridge filter is left alone");
|
||||
|
||||
const crash = rejectWith(stacked);
|
||||
ok(!crash.defaultPrevented, "a stacked Error rejection stays unhandled for the crash surface");
|
||||
ok((overlay()?.textContent ?? "").includes("renderer fault"), "a stacked Error rejection still paints the crash overlay");
|
||||
ok(toasts.length === 1, "a stacked Error rejection is not downgraded to a toast");
|
||||
|
||||
overlay()?.remove();
|
||||
window.dispatchEvent(new dom.window.ErrorEvent("error", { message: "renderer fault", error: stacked, cancelable: true }));
|
||||
ok((overlay()?.textContent ?? "").includes("renderer fault"), "window.error still paints the crash overlay");
|
||||
|
||||
console.error = originalConsoleError;
|
||||
dom.window.close();
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { RecoveryLineageView } from "../lib/types";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" });
|
||||
@@ -19,6 +20,8 @@ globalThis.localStorage = dom.window.localStorage;
|
||||
|
||||
const { RecoveryLineageDialog } = await import("../components/RecoveryLineageDialog");
|
||||
const { LocaleProvider } = await import("../lib/i18n");
|
||||
const { ToastProvider } = await import("../lib/toast");
|
||||
const { setFrontendDiagnosticSink } = await import("../lib/frontendDiagnosticBridge");
|
||||
|
||||
const initial: RecoveryLineageView = {
|
||||
groupId: "group",
|
||||
@@ -62,5 +65,63 @@ await act(async () => { openButtons[1].click(); });
|
||||
if (opened !== "/private/fork.jsonl") throw new Error("open action was not bound to the selected version");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
console.log(" PASS session version dialog hides persistence details");
|
||||
|
||||
// A rejected backend call must end as a toast, never as an unhandledrejection
|
||||
// (which the global handler would otherwise paint as a full-screen crash).
|
||||
const rejections: unknown[] = [];
|
||||
const onProcessRejection = (reason: unknown) => { rejections.push(reason); };
|
||||
process.on("unhandledRejection", onProcessRejection);
|
||||
const diagnostics: string[] = [];
|
||||
setFrontendDiagnosticSink((_source, type) => { diagnostics.push(type); });
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ChooseRecoveryBranch: () => Promise.reject("selected branch is outside the recovery lineage"),
|
||||
GetRecoveryLineage: async () => initial,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
let closed = false;
|
||||
const failing = createRoot(document.getElementById("root")!);
|
||||
await act(async () => {
|
||||
failing.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<RecoveryLineageDialog
|
||||
topic={{ scope: "global", topicId: "topic" }}
|
||||
initial={initial}
|
||||
onClose={() => { closed = true; }}
|
||||
onChanged={() => {}}
|
||||
onOpenVersion={() => Promise.reject(new Error("resume failed"))}
|
||||
/>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
const findButton = (label: string) => Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === label);
|
||||
const errorToasts = () => Array.from(document.querySelectorAll(".toast--error .toast__text")).map((node) => node.textContent);
|
||||
|
||||
await act(async () => { findButton("Set as default version")!.click(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
if (!errorToasts().includes("Could not set the default version: selected branch is outside the recovery lineage")) {
|
||||
throw new Error(`rejected default-version choice did not toast: ${JSON.stringify(errorToasts())}`);
|
||||
}
|
||||
if (!diagnostics.includes("session.recovery-choose-failed")) throw new Error("rejected default-version choice did not record a diagnostic");
|
||||
if (findButton("Set as default version")?.disabled) throw new Error("dialog stayed busy after the rejected choice");
|
||||
|
||||
await act(async () => { findButton("Open this version")!.click(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
if (!errorToasts().includes("Could not open this version: resume failed")) {
|
||||
throw new Error(`rejected open-version did not toast: ${JSON.stringify(errorToasts())}`);
|
||||
}
|
||||
if (closed) throw new Error("dialog closed although opening the version failed");
|
||||
|
||||
await act(async () => failing.unmount());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
process.off("unhandledRejection", onProcessRejection);
|
||||
if (rejections.length > 0) throw new Error(`recovery dialog leaked unhandled rejections: ${JSON.stringify(rejections)}`);
|
||||
console.log(" PASS session version dialog contains rejected backend calls as toasts");
|
||||
|
||||
dom.window.close();
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { GitBranch, Pencil, X } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { recordFrontendDiagnostic } from "../lib/frontendDiagnosticBridge";
|
||||
import type { ProjectTopicKey } from "../lib/sessionCatalogTypes";
|
||||
import type { RecoveryLineageMember, RecoveryLineageView } from "../lib/types";
|
||||
import { useT } from "../lib/i18n";
|
||||
@@ -20,6 +21,10 @@ function versionActivityAt(member: RecoveryLineageMember): number {
|
||||
return member.lastActivityAt || member.createdAt || 0;
|
||||
}
|
||||
|
||||
function failureText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOpenVersion }: RecoveryLineageDialogProps) {
|
||||
const t = useT();
|
||||
const { showToast } = useToast();
|
||||
@@ -44,6 +49,9 @@ export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOp
|
||||
try {
|
||||
await app.ChooseRecoveryBranch({ ...topic, path });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
recordFrontendDiagnostic("app", "session.recovery-choose-failed", { status: "error" });
|
||||
showToast(t("recovery.chooseBranchFailed", { error: failureText(error) }), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -55,6 +63,9 @@ export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOp
|
||||
try {
|
||||
await onOpenVersion(member);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
recordFrontendDiagnostic("app", "session.recovery-open-failed", { status: "error" });
|
||||
showToast(t("recovery.openVersionFailed", { error: failureText(error) }), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -74,7 +85,7 @@ export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOp
|
||||
setEditingPath("");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
showToast(failureText(error), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -757,7 +757,7 @@ function paintPerformancePrompt(payload: CrashPayload, snapshot: PerformanceSnap
|
||||
host.replaceChildren(title, body, actions, note);
|
||||
}
|
||||
|
||||
function paint(payload: CrashPayload) {
|
||||
export function paintCrashOverlay(payload: CrashPayload) {
|
||||
let host = document.getElementById("crash-overlay");
|
||||
if (!host) {
|
||||
host = document.createElement("div");
|
||||
@@ -783,7 +783,7 @@ function paint(payload: CrashPayload) {
|
||||
}
|
||||
|
||||
export function reportCrash(label: string, err: unknown, extra?: string) {
|
||||
paint(buildCrashPayload(label, err, extra));
|
||||
paintCrashOverlay(buildCrashPayload(label, err, extra));
|
||||
}
|
||||
|
||||
type GlobalCrashEventLike = Pick<Event, "defaultPrevented"> & {
|
||||
@@ -1011,15 +1011,3 @@ export function installPerformancePressureMonitor() {
|
||||
maybePromptForHeapPressure();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
export function installGlobalCrashHandlers() {
|
||||
window.addEventListener("error", (e) => {
|
||||
if (!shouldReportGlobalCrashEvent(e)) return;
|
||||
const payload = buildCrashPayload("window.error", globalCrashReportReason(e));
|
||||
if (isOpaqueScriptErrorEvent(e)) payload.fingerprintHint = opaqueScriptFingerprintHint();
|
||||
paint(payload);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
if (shouldReportGlobalCrashEvent(e)) reportCrash("unhandledrejection", e.reason);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Window-level failure routing. Wails rejects a bound Go call with the bare
|
||||
// error string, so a stackless rejection is an ordinary backend error that is
|
||||
// contained and toasted; only faults carrying a stack reach the crash overlay.
|
||||
|
||||
import {
|
||||
buildCrashPayload,
|
||||
globalCrashReportReason,
|
||||
isOpaqueScriptErrorEvent,
|
||||
normalizeCrashError,
|
||||
opaqueScriptFingerprintHint,
|
||||
paintCrashOverlay,
|
||||
reportCrash,
|
||||
shouldReportGlobalCrashEvent,
|
||||
} from "./crash";
|
||||
import { recordFrontendDiagnostic } from "./frontendDiagnosticBridge";
|
||||
|
||||
export const RECOVERABLE_ERROR_EVENT = "reasonix:recoverable-error";
|
||||
|
||||
export type RecoverableErrorDetail = { message: string };
|
||||
|
||||
export function isRecoverableRejectionReason(reason: unknown): boolean {
|
||||
if (typeof reason !== "object" || reason === null) return true;
|
||||
const stack = (reason as { stack?: unknown }).stack;
|
||||
return typeof stack !== "string" || stack.trim() === "";
|
||||
}
|
||||
|
||||
export function onRecoverableError(cb: (detail: RecoverableErrorDetail) => void): () => void {
|
||||
const handler = (e: Event) => cb((e as CustomEvent<RecoverableErrorDetail>).detail);
|
||||
window.addEventListener(RECOVERABLE_ERROR_EVENT, handler);
|
||||
return () => window.removeEventListener(RECOVERABLE_ERROR_EVENT, handler);
|
||||
}
|
||||
|
||||
function containRecoverableRejection(e: PromiseRejectionEvent): boolean {
|
||||
if (!isRecoverableRejectionReason(e.reason)) return false;
|
||||
e.preventDefault();
|
||||
recordFrontendDiagnostic("runtime", "unhandled-backend-rejection", { status: "error" });
|
||||
const detail: RecoverableErrorDetail = { message: normalizeCrashError(e.reason).errorMessage };
|
||||
window.dispatchEvent(new CustomEvent(RECOVERABLE_ERROR_EVENT, { detail }));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function installGlobalCrashHandlers() {
|
||||
window.addEventListener("error", (e) => {
|
||||
if (!shouldReportGlobalCrashEvent(e)) return;
|
||||
const payload = buildCrashPayload("window.error", globalCrashReportReason(e));
|
||||
if (isOpaqueScriptErrorEvent(e)) payload.fingerprintHint = opaqueScriptFingerprintHint();
|
||||
paintCrashOverlay(payload);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
if (!shouldReportGlobalCrashEvent(e) || containRecoverableRejection(e)) return;
|
||||
reportCrash("unhandledrejection", e.reason);
|
||||
});
|
||||
}
|
||||
@@ -1366,7 +1366,9 @@ export const en = {
|
||||
"recovery.versionPreviewEmpty": "No preview available for this version.",
|
||||
"recovery.inUse": "in use",
|
||||
"recovery.chooseBranch": "Set as default version",
|
||||
"recovery.chooseBranchFailed": "Could not set the default version: {error}",
|
||||
"recovery.openVersion": "Open this version",
|
||||
"recovery.openVersionFailed": "Could not open this version: {error}",
|
||||
"recovery.role.covered_copy": "covered by a fuller version",
|
||||
"recovery.role.adopted": "full version",
|
||||
"recovery.role.preferred": "default version",
|
||||
|
||||
@@ -1117,7 +1117,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"recovery.versionPreviewEmpty": "此版本暫無內容預覽。",
|
||||
"recovery.inUse": "正在使用",
|
||||
"recovery.chooseBranch": "設為預設版本",
|
||||
"recovery.chooseBranchFailed": "設為預設版本失敗:{error}",
|
||||
"recovery.openVersion": "開啟此版本",
|
||||
"recovery.openVersionFailed": "開啟此版本失敗:{error}",
|
||||
"recovery.role.covered_copy": "已被更完整版本覆蓋",
|
||||
"recovery.role.adopted": "完整版本",
|
||||
"recovery.role.preferred": "預設版本",
|
||||
|
||||
@@ -1367,7 +1367,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"recovery.versionPreviewEmpty": "此版本暂无内容预览。",
|
||||
"recovery.inUse": "正在使用",
|
||||
"recovery.chooseBranch": "设为默认版本",
|
||||
"recovery.chooseBranchFailed": "设为默认版本失败:{error}",
|
||||
"recovery.openVersion": "打开此版本",
|
||||
"recovery.openVersionFailed": "打开此版本失败:{error}",
|
||||
"recovery.role.covered_copy": "已被更完整版本覆盖",
|
||||
"recovery.role.adopted": "完整版本",
|
||||
"recovery.role.preferred": "默认版本",
|
||||
|
||||
@@ -3,7 +3,8 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { installGlobalCrashHandlers, installPerformancePressureMonitor } from "./lib/crash";
|
||||
import { installPerformancePressureMonitor } from "./lib/crash";
|
||||
import { installGlobalCrashHandlers } from "./lib/globalCrashHandlers";
|
||||
import { installWailsNonFileDragErrorSuppression } from "./lib/bridge";
|
||||
import { installBreadcrumbConsoleHook } from "./lib/breadcrumbs";
|
||||
import { installMessageSelectionCopy } from "./lib/messageSelectionCopy";
|
||||
|
||||
@@ -437,12 +437,17 @@ func (a *App) ChooseRecoveryBranch(req RecoveryPreferenceRequest) error {
|
||||
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 {
|
||||
if err := func() error {
|
||||
defer a.lockRuntimeMutation("choose-recovery-branch")()
|
||||
a.sessionRemovalMu.Lock()
|
||||
defer a.sessionRemovalMu.Unlock()
|
||||
return agent.SetRecoveryPreferred(paths, chosen)
|
||||
}(); err != nil {
|
||||
return errors.New("could not save the recovery branch choice")
|
||||
}
|
||||
// The rescan reads session files and rewrites only the catalog projection,
|
||||
// so it needs neither barrier; only the preference write above must stay
|
||||
// atomic with respect to session removal.
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -160,3 +161,86 @@ func TestGetRecoveryLineageBindsRequestedPhysicalGroup(t *testing.T) {
|
||||
t.Fatalf("ambiguous legacy lookup = %+v, want safe empty array", ambiguous)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseRecoveryBranchPersistsPreferenceAndRefreshesOffBarrier(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{InMemory: true, DisableRepair: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
|
||||
root := filepath.Join(dir, "root.jsonl")
|
||||
fork := filepath.Join(dir, "fork.jsonl")
|
||||
save := func(path string, messages ...string) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("system")
|
||||
for index, message := range messages {
|
||||
role := provider.RoleUser
|
||||
if index%2 == 1 {
|
||||
role = provider.RoleAssistant
|
||||
}
|
||||
session.Add(provider.Message{Role: role, Content: message})
|
||||
}
|
||||
if err := session.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
save(root, "shared question", "shared answer", "root unique", "root answer")
|
||||
save(fork, "shared question", "shared answer", "fork unique", "fork answer")
|
||||
created := time.UnixMilli(100)
|
||||
for path, meta := range map[string]agent.BranchMeta{
|
||||
root: {ID: "root", Scope: "global", TopicID: "topic", TopicTitle: "Topic", CreatedAt: created, UpdatedAt: created},
|
||||
fork: {ID: "fork", Scope: "global", TopicID: "topic", TopicTitle: "Topic", Recovered: true, ParentID: "root", RecoveryDepth: 1, CreatedAt: created.Add(time.Second), UpdatedAt: created.Add(time.Second)},
|
||||
} {
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := catalog.ReconcileDirectory(ctx, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
|
||||
app.sessionCatalog.Store(catalog)
|
||||
var barrierReleased atomic.Bool
|
||||
app.projectTreeChangedHook = func() {
|
||||
if !app.runtimeRebuildMu.TryLock() {
|
||||
return
|
||||
}
|
||||
app.runtimeRebuildMu.Unlock()
|
||||
if app.sessionRemovalMu.TryLock() {
|
||||
app.sessionRemovalMu.Unlock()
|
||||
barrierReleased.Store(true)
|
||||
}
|
||||
}
|
||||
key := ProjectTopicKey{Scope: "global", TopicID: "topic", Path: fork}
|
||||
if view := app.GetRecoveryLineage(key); view.State != "diverged" {
|
||||
t.Fatalf("lineage before choice = %+v, want diverged", view)
|
||||
}
|
||||
|
||||
if err := app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: fork}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !barrierReleased.Load() {
|
||||
t.Fatal("catalog refresh after the choice still ran under the runtime mutation barrier")
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(fork)
|
||||
if err != nil || !ok || !meta.RecoveryPreferred {
|
||||
t.Fatalf("fork meta = %+v ok=%v err=%v, want a persisted preference", meta, ok, err)
|
||||
}
|
||||
view := app.GetRecoveryLineage(key)
|
||||
if view.State != "preferred" || view.Unresolved != 0 {
|
||||
t.Fatalf("lineage after choice = %+v, want preferred", view)
|
||||
}
|
||||
for _, member := range view.Members {
|
||||
if member.Canonical != (member.Path == fork) {
|
||||
t.Fatalf("member %+v, want only the chosen fork canonical", member)
|
||||
}
|
||||
}
|
||||
|
||||
err = app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: filepath.Join(dir, "missing.jsonl")})
|
||||
if err == nil || err.Error() != "selected branch is outside the recovery lineage" {
|
||||
t.Fatalf("outside-lineage choice error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user