mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
Merge pull request #7982 from SivanCola/fix/session-wal-recovery-7956
fix(session): make crash recovery WAL-aware and conflict-safe / 修复会话恢复回滚与并发覆盖
This commit is contained in:
55 files changed
+3344
-607
No files matched your search
+49
-5
@@ -5559,6 +5559,8 @@ type HistoryPage struct {
|
||||
EndTurn int `json:"endTurn"`
|
||||
TotalTurns int `json:"totalTurns"`
|
||||
HasOlder bool `json:"hasOlder"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
}
|
||||
|
||||
// historyProviderMessagesWithPersistedTimes overlays legacy event-record
|
||||
@@ -5633,8 +5635,17 @@ func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage
|
||||
}
|
||||
dir := controllerSessionDir(ctrl)
|
||||
path := ctrl.SessionPath()
|
||||
msgs := historyProviderMessagesWithPersistedTimes(ctrl.History(), path)
|
||||
return historyPageFromProviderMessages(
|
||||
msgs := ctrl.History()
|
||||
status := ctrl.RuntimeStatus()
|
||||
if !status.Running && !status.PendingPrompt && !ctrl.SessionHasUnsavedChanges() && strings.TrimSpace(path) != "" {
|
||||
// Once the foreground turn is idle, the durable event log is the source
|
||||
// of truth. Re-reading it prevents a stale controller snapshot from
|
||||
// hiding an assistant/tool suffix after restart or cross-runtime recovery.
|
||||
if loaded, err := agent.LoadSession(path); err == nil && loaded != nil {
|
||||
msgs = loaded.Snapshot()
|
||||
}
|
||||
}
|
||||
page := historyPageFromProviderMessages(
|
||||
msgs,
|
||||
sessionDisplayResolver(dir, path),
|
||||
sessionPlannerDisplayTurns(dir, path),
|
||||
@@ -5642,6 +5653,24 @@ func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage
|
||||
beforeTurn,
|
||||
limit,
|
||||
)
|
||||
digest, _ := agent.ContentDigestForMessages(msgs)
|
||||
return historyPageWithFingerprint(page, path, digest)
|
||||
}
|
||||
|
||||
func historyPageWithFingerprint(page HistoryPage, sessionPath, contentDigest string) HistoryPage {
|
||||
contentDigest = strings.TrimSpace(contentDigest)
|
||||
if strings.TrimSpace(sessionPath) == "" || contentDigest == "" {
|
||||
return page
|
||||
}
|
||||
// Digest is derived from the exact full transcript used to build the page.
|
||||
// Never copy a newer sidecar digest onto older page content.
|
||||
page.Digest = contentDigest
|
||||
if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
|
||||
if strings.TrimSpace(meta.ContentDigest) == contentDigest {
|
||||
page.Revision = meta.Revision
|
||||
}
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func normalizeHistoryPageLimit(limit int) int {
|
||||
@@ -6513,14 +6542,16 @@ func previewSessionPage(sessionDir, path string, beforeTurn, limit int) (History
|
||||
if err != nil {
|
||||
return HistoryPage{}, err
|
||||
}
|
||||
return historyPageFromProviderMessages(
|
||||
historyProviderMessagesWithPersistedTimes(loaded.Snapshot(), sessionPath),
|
||||
msgs := loaded.Snapshot()
|
||||
digest, _ := agent.ContentDigestForMessages(msgs)
|
||||
return historyPageWithFingerprint(historyPageFromProviderMessages(
|
||||
historyProviderMessagesWithPersistedTimes(msgs, sessionPath),
|
||||
sessionDisplayResolver(sessionDir, sessionPath),
|
||||
sessionPlannerDisplayTurns(sessionDir, sessionPath),
|
||||
nil,
|
||||
beforeTurn,
|
||||
limit,
|
||||
), nil
|
||||
), sessionPath, digest), nil
|
||||
}
|
||||
|
||||
type previewEventRecord struct {
|
||||
@@ -6884,6 +6915,9 @@ type Meta struct {
|
||||
Runtime SessionRuntimeView `json:"runtime"`
|
||||
StartupErr string `json:"startupErr,omitempty"`
|
||||
EventChannel string `json:"eventChannel"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
SessionRevision int64 `json:"sessionRevision,omitempty"`
|
||||
SessionDigest string `json:"sessionDigest,omitempty"`
|
||||
Cwd string `json:"cwd"`
|
||||
WorkspaceRoot string `json:"workspaceRoot,omitempty"`
|
||||
WorkspaceName string `json:"workspaceName,omitempty"`
|
||||
@@ -6989,12 +7023,22 @@ func (a *App) MetaForTab(tabID string) Meta {
|
||||
tokenMode := snap.currentTokenMode()
|
||||
goal := snap.currentGoal()
|
||||
goalStatus := snap.currentGoalStatus()
|
||||
sessionPath := strings.TrimSpace(snap.sessionPath)
|
||||
var sessionRevision int64
|
||||
var sessionDigest string
|
||||
if branchMeta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
|
||||
sessionRevision = branchMeta.Revision
|
||||
sessionDigest = branchMeta.ContentDigest
|
||||
}
|
||||
return Meta{
|
||||
Label: snap.label,
|
||||
Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
|
||||
Runtime: runtimeView,
|
||||
StartupErr: snap.startupErr,
|
||||
EventChannel: eventChannel,
|
||||
SessionPath: sessionPath,
|
||||
SessionRevision: sessionRevision,
|
||||
SessionDigest: sessionDigest,
|
||||
Cwd: cwd,
|
||||
WorkspaceRoot: cwd,
|
||||
WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
|
||||
|
||||
+12
-11
@@ -2924,29 +2924,30 @@ func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
|
||||
if err := app.SetEffortForTab(tab.ID, "max"); err != nil {
|
||||
t.Fatalf("SetEffortForTab: %v", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != recoveryPath {
|
||||
t.Fatalf("session path after effort switch = %q, want current recovery branch %q", got, recoveryPath)
|
||||
isolatedPath := tab.Ctrl.SessionPath()
|
||||
if isolatedPath == recoveryPath || !strings.Contains(isolatedPath, "-recovery-") {
|
||||
t.Fatalf("session path after effort switch = %q, want an isolated recovery branch", isolatedPath)
|
||||
}
|
||||
if got := tab.currentSessionPath(); got != recoveryPath {
|
||||
t.Fatalf("tab current session path = %q, want %q", got, recoveryPath)
|
||||
if got := tab.currentSessionPath(); got != isolatedPath {
|
||||
t.Fatalf("tab current session path = %q, want %q", got, isolatedPath)
|
||||
}
|
||||
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(recoveryPath) {
|
||||
t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), recoveryPath)
|
||||
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(isolatedPath) {
|
||||
t.Fatalf("tab lease path = %q, want %q", tab.sessionLeaseRuntimeKey(), isolatedPath)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob recovery branches: %v", err)
|
||||
}
|
||||
matches = primarySessionFiles(matches)
|
||||
if len(matches) != 1 || matches[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after effort switch = %v, want only %q", matches, recoveryPath)
|
||||
if len(matches) != 2 || !slices.Contains(matches, recoveryPath) || !slices.Contains(matches, isolatedPath) {
|
||||
t.Fatalf("recovery branches after effort switch = %v, want canonical and isolated paths", matches)
|
||||
}
|
||||
|
||||
lines := readConflictLogLines(t, store.SessionConflictLog(recoveryPath))
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("conflict log lines = %v, want one depth-cap diagnostic", lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_force_saved"`) {
|
||||
if !strings.Contains(lines[0], `"outcome":"recovery_depth_cap_isolated"`) {
|
||||
t.Fatalf("conflict diagnostic = %s, want depth-cap outcome", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[0], dir) || strings.Contains(lines[0], recoveryPath) {
|
||||
@@ -2965,8 +2966,8 @@ func TestSetEffortForTabReanchorsDepthCapRecoveryBranch(t *testing.T) {
|
||||
t.Fatalf("glob recovery branches after snapshot: %v", err)
|
||||
}
|
||||
matches = primarySessionFiles(matches)
|
||||
if len(matches) != 1 || matches[0] != recoveryPath {
|
||||
t.Fatalf("recovery branches after follow-up snapshot = %v, want only %q", matches, recoveryPath)
|
||||
if len(matches) != 2 || !slices.Contains(matches, recoveryPath) || !slices.Contains(matches, isolatedPath) {
|
||||
t.Fatalf("recovery branches after follow-up snapshot = %v, want canonical and isolated paths", matches)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
import type { HistorySlice, HistorySliceRequest } from "../lib/types";
|
||||
import type { HistoryMessage } from "../lib/types";
|
||||
|
||||
export function historySliceFromMessages(tabID: string, messages: HistoryMessage[], req: HistorySliceRequest): HistorySlice {
|
||||
export function historySliceFromMessages(
|
||||
tabID: string,
|
||||
messages: HistoryMessage[],
|
||||
req: HistorySliceRequest,
|
||||
identity: { revision?: number; digest?: string } = {},
|
||||
): HistorySlice {
|
||||
const turnsOf: number[] = [];
|
||||
let turn = 0;
|
||||
for (const message of messages) {
|
||||
@@ -24,7 +29,9 @@ export function historySliceFromMessages(tabID: string, messages: HistoryMessage
|
||||
if (typeof decoded.before === "number" && decoded.before >= 0 && decoded.before < before) before = decoded.before;
|
||||
} catch { /* unknown cursor: serve the latest page */ }
|
||||
}
|
||||
const empty: HistorySlice = { entries: [], nextCursor: "", hasOlder: false, totalTurns: turn, startTurn: 0, endTurn: 0, stale: false, revision: 0 };
|
||||
const revision = identity.revision ?? 0;
|
||||
const digest = identity.digest ?? "";
|
||||
const empty: HistorySlice = { entries: [], nextCursor: "", hasOlder: false, totalTurns: turn, startTurn: 0, endTurn: 0, stale: false, revision, revisionKnown: revision > 0, digest };
|
||||
if (before <= 0 || messages.length === 0) return empty;
|
||||
const turns = Math.max(1, Math.floor(req.turns || 12));
|
||||
const newestTurn = turnsOf[before - 1];
|
||||
@@ -52,6 +59,8 @@ export function historySliceFromMessages(tabID: string, messages: HistoryMessage
|
||||
startTurn: visibleTurns.length > 0 ? Math.min(...visibleTurns) : 0,
|
||||
endTurn: visibleTurns.length > 0 ? Math.max(...visibleTurns) : 0,
|
||||
stale: false,
|
||||
revision: 0,
|
||||
revision,
|
||||
revisionKnown: revision > 0,
|
||||
digest,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySlice, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -87,6 +87,9 @@ function metaFor(tab: TabMeta): Meta {
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
sessionPath: tab.sessionPath,
|
||||
sessionRevision: tab.sessionRevision,
|
||||
sessionDigest: tab.sessionDigest,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
@@ -137,11 +140,16 @@ const tabG = tabMeta("tab-g");
|
||||
const tabH = tabMeta("tab-h");
|
||||
const tabI = tabMeta("tab-i", { running: true, pendingPrompt: true, cancellable: true });
|
||||
const tabJ = tabMeta("tab-j");
|
||||
const tabK = tabMeta("tab-k", { sessionRevision: 1, sessionDigest: "digest-k-v1" });
|
||||
const tabL = tabMeta("tab-l", { sessionRevision: 1, sessionDigest: "digest-l-v1" });
|
||||
const tabM = tabMeta("tab-m", { sessionRevision: 2, sessionDigest: "digest-m-v2" });
|
||||
const tabN = tabMeta("tab-n", { sessionRevision: 2, sessionDigest: "digest-n-v2" });
|
||||
let backendActiveId = "tab-a";
|
||||
const historyB = deferred<HistoryMessage[]>();
|
||||
const historyD = deferred<HistoryMessage[]>();
|
||||
let metaH = deferred<Meta>();
|
||||
let historyH = deferred<HistoryMessage[]>();
|
||||
let historyLOlder = deferred<HistorySlice>();
|
||||
const contextDGate = deferred<ContextInfo>();
|
||||
const setActiveBGate = deferred<void>();
|
||||
const setActiveEGate = deferred<void>();
|
||||
@@ -154,6 +162,10 @@ const forkResultGate = deferred<void>();
|
||||
const staleForkResultGate = deferred<void>();
|
||||
const staleForkReassertGGate = deferred<void>();
|
||||
const historyCalls: string[] = [];
|
||||
let historyLCalls = 0;
|
||||
let historyMCalls = 0;
|
||||
let historyNCalls = 0;
|
||||
let startTabNDuringMeta = false;
|
||||
const cancelCalls: string[] = [];
|
||||
let contextDCalls = 0;
|
||||
let metaHCalls = 0;
|
||||
@@ -175,7 +187,7 @@ let staleForkStarted = false;
|
||||
let holdStaleForkReassertG = false;
|
||||
let staleForkReassertGStarted = false;
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI].map((tab) => [tab.id, tab]));
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI, tabK, tabL, tabM, tabN].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
const topicActivationHandlers: Array<(e: TopicActivationEvent) => void> = [];
|
||||
@@ -206,6 +218,11 @@ window.go = {
|
||||
holdNextMetaForH = false;
|
||||
return metaH.promise;
|
||||
}
|
||||
if (tabID === "tab-n" && startTabNDuringMeta) {
|
||||
startTabNDuringMeta = false;
|
||||
runningTabs.add(tabID);
|
||||
for (const handler of eventHandlers) handler({ kind: "turn_started", tabId: tabID });
|
||||
}
|
||||
return metaFor(tabsById.get(tabID) ?? tabA);
|
||||
},
|
||||
ContextUsageForTab: async (tabID: string) => {
|
||||
@@ -234,14 +251,48 @@ window.go = {
|
||||
if (tabID === "tab-h") return [userMessage("history H")];
|
||||
if (tabID === "tab-i") return [userMessage("fork I")];
|
||||
if (tabID === "tab-j") return [userMessage("fork J")];
|
||||
if (tabID === "tab-k") {
|
||||
const revision = tabsById.get("tab-k")?.sessionRevision;
|
||||
return [userMessage(revision === 2 ? "history K v2" : "history K v1")];
|
||||
}
|
||||
return [userMessage("cached A")];
|
||||
},
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
const messages = await window.go.main.App.HistoryForTab(tabID);
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
const tab = tabsById.get(tabID);
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false, revision: tab?.sessionRevision, digest: tab?.sessionDigest };
|
||||
},
|
||||
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => {
|
||||
if (tabID === "tab-n") {
|
||||
historyNCalls += 1;
|
||||
const revision = historyNCalls === 1 ? 1 : 2;
|
||||
const digest = historyNCalls === 1 ? "digest-n-v1" : "digest-n-v2";
|
||||
return historySliceFromMessages(tabID, [userMessage(historyNCalls === 1 ? "stale N v1" : "history N v2")], req, { revision, digest });
|
||||
}
|
||||
if (tabID === "tab-m") {
|
||||
historyMCalls += 1;
|
||||
const revision = historyMCalls === 1 ? 1 : 2;
|
||||
const digest = historyMCalls === 1 ? "digest-m-v1" : "digest-m-v2";
|
||||
return historySliceFromMessages(tabID, [userMessage(historyMCalls === 1 ? "stale M v1" : "history M v2")], req, { revision, digest });
|
||||
}
|
||||
if (tabID === "tab-l") {
|
||||
historyLCalls += 1;
|
||||
if (req.cursor) return historyLOlder.promise;
|
||||
const latest = historySliceFromMessages(tabID, [userMessage("newest L")], req, { revision: 1, digest: "digest-l-v1" });
|
||||
return {
|
||||
...latest,
|
||||
entries: latest.entries.map((entry) => ({ ...entry, entryId: "smock-tab-l:r1:m3:o0", order: 3, turn: 4 })),
|
||||
hasOlder: true,
|
||||
totalTurns: 4,
|
||||
startTurn: 4,
|
||||
endTurn: 4,
|
||||
nextCursor: btoa(JSON.stringify({ v: 1, before: 3 })),
|
||||
};
|
||||
}
|
||||
const messages = await window.go.main.App.HistoryForTab(tabID);
|
||||
const tab = tabsById.get(tabID);
|
||||
return historySliceFromMessages(tabID, messages, req, { revision: tab?.sessionRevision, digest: tab?.sessionDigest });
|
||||
},
|
||||
HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) =>
|
||||
historySliceFromMessages(tabID, await window.go.main.App.HistoryForTab(tabID), req),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
|
||||
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
|
||||
@@ -802,6 +853,106 @@ eq(controller?.activeTabId, "tab-h", "third navigation remains visible after sta
|
||||
eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale fork repair");
|
||||
runningTabs.delete("tab-j");
|
||||
|
||||
// A same-path session can advance while this tab is inactive (another runtime,
|
||||
// crash recovery, or a completed turn). Matching only the path would reuse the
|
||||
// old transcript and hide the newer durable suffix. The persisted fingerprint
|
||||
// must force one fresh history read, then allow reuse once the cache is current.
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabK.workspaceRoot, tabK.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("initial fingerprinted tab hydration", () =>
|
||||
controller?.activeTabId === "tab-k" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history K v1") ?? false)
|
||||
);
|
||||
const historyCallsBeforeFingerprintRefresh = historyCalls.length;
|
||||
tabsById.set("tab-k", { ...tabK, sessionRevision: 2, sessionDigest: "digest-k-v2" });
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabA.workspaceRoot, tabA.topicId || "");
|
||||
await controller?.openProjectTab(tabK.workspaceRoot, tabK.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("fingerprint change reloads tab history", () =>
|
||||
controller?.activeTabId === "tab-k" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history K v2") ?? false)
|
||||
);
|
||||
eq(historyCalls.length, historyCallsBeforeFingerprintRefresh + 2, "changed session fingerprint reloads history instead of reusing same-path cache");
|
||||
const historyCallsAfterFingerprintRefresh = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabA.workspaceRoot, tabA.topicId || "");
|
||||
await controller?.openProjectTab(tabK.workspaceRoot, tabK.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("matching fingerprint reuses tab history", () => controller?.activeTabId === "tab-k");
|
||||
eq(historyCalls.length, historyCallsAfterFingerprintRefresh, "matching fingerprint reuses the hydrated transcript after navigation");
|
||||
|
||||
// Older-page hydration must use the same canonical fingerprint as the visible
|
||||
// page. A session can advance while the tab is open; an older response must be
|
||||
// discarded instead of being prepended to the newer transcript.
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabL.workspaceRoot, tabL.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("fingerprinted tab-l initial page", () =>
|
||||
controller?.activeTabId === "tab-l" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "newest L") ?? false) &&
|
||||
controller.state.historyHasOlder
|
||||
);
|
||||
tabsById.set("tab-l", { ...tabL, sessionRevision: 2, sessionDigest: "digest-l-v2" });
|
||||
await act(async () => {
|
||||
await controller?.refreshMeta();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-l metadata advances", () => controller?.state.meta?.sessionRevision === 2);
|
||||
historyLOlder = deferred<HistorySlice>();
|
||||
let olderLoad: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
olderLoad = controller?.loadOlderHistory("tab-l");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-l older page request", () => historyLCalls === 2);
|
||||
historyLOlder.resolve(historySliceFromMessages(
|
||||
"tab-l",
|
||||
[userMessage("stale older L")],
|
||||
{ cursor: "", turns: 12 },
|
||||
{ revision: 1, digest: "digest-l-v1" },
|
||||
));
|
||||
await act(async () => {
|
||||
await olderLoad;
|
||||
await flushPromises();
|
||||
});
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale older L") ?? false), "stale older page is discarded after session fingerprint changes");
|
||||
eq(controller?.state.historyOlderLoading, false, "stale older page releases its loading state");
|
||||
|
||||
// The backend transcript and sidecar are separate durable files. If a save
|
||||
// advances between those reads, hydration must reconcile the pair instead of
|
||||
// caching an older page under the newer metadata fingerprint.
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabM.workspaceRoot, tabM.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("split transcript/meta read reconciles", () =>
|
||||
controller?.activeTabId === "tab-m" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history M v2") ?? false)
|
||||
);
|
||||
eq(historyMCalls, 2, "mismatched page and metadata trigger one bounded history reload");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale M v1") ?? false), "reconciled hydration does not retain the stale page");
|
||||
|
||||
// A live turn may start after the first durable page returns but before the
|
||||
// metadata fingerprint is sampled. That live state owns the transcript; the
|
||||
// bounded durable reconciliation must stop instead of replacing it.
|
||||
startTabNDuringMeta = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabN.workspaceRoot, tabN.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("live turn blocks durable history reconciliation", () =>
|
||||
controller?.activeTabId === "tab-n" && controller.state.running
|
||||
);
|
||||
eq(historyNCalls, 1, "a foreground turn prevents mismatched durable history from being reloaded");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "history N v2") ?? false), "durable reconciliation does not replace a live transcript");
|
||||
runningTabs.delete("tab-n");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ class FakeBackend {
|
||||
contentGate: ReturnType<typeof deferred<HistoryContentChunk>> | undefined;
|
||||
staleNextCursor = false;
|
||||
revision = 1;
|
||||
digest = "digest-1";
|
||||
|
||||
constructor(
|
||||
private readonly messages: HistoryMessage[],
|
||||
@@ -98,6 +99,8 @@ class FakeBackend {
|
||||
endTurn: turns.length > 0 ? Math.max(...turns) : 0,
|
||||
stale: false,
|
||||
revision: this.revision,
|
||||
revisionKnown: true,
|
||||
digest: this.digest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +114,7 @@ class FakeBackend {
|
||||
}
|
||||
let before = this.messages.length;
|
||||
if (req.cursor) {
|
||||
if (this.staleNextCursor) return { entries: [], nextCursor: "", hasOlder: false, totalTurns: 0, startTurn: 0, endTurn: 0, stale: true, revision: this.revision };
|
||||
if (this.staleNextCursor) return { entries: [], nextCursor: "", hasOlder: false, totalTurns: 0, startTurn: 0, endTurn: 0, stale: true, revision: this.revision, revisionKnown: true, digest: this.digest };
|
||||
const decoded = JSON.parse(atob(req.cursor)) as { before?: number };
|
||||
before = Math.min(before, decoded.before ?? before);
|
||||
}
|
||||
@@ -444,5 +447,38 @@ console.log("\ntranscript store");
|
||||
eq(older?.prependItems.length, 20, "older page prepends after reload");
|
||||
}
|
||||
|
||||
// ── same-path resident identity ────────────────────────────────────────────
|
||||
{
|
||||
const backend = new FakeBackend([{ role: "user", content: "u" }, { role: "assistant", content: "a" }]);
|
||||
const store = new TranscriptStore(backend);
|
||||
await store.loadLatest("tab-fp", "/s/fp.jsonl", { expectedRevision: 1, expectedDigest: "digest-1" });
|
||||
const callsAfterFirstLoad = backend.sliceCalls.length;
|
||||
const resident = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
|
||||
preferResident: true,
|
||||
expectedRevision: 1,
|
||||
expectedDigest: "digest-1",
|
||||
});
|
||||
eq(backend.sliceCalls.length, callsAfterFirstLoad, "matching canonical fingerprint reuses the resident projection");
|
||||
eq(resident?.revision, 1, "resident projection retains its canonical revision");
|
||||
|
||||
backend.revision = 2;
|
||||
backend.digest = "digest-2";
|
||||
const refreshed = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
|
||||
preferResident: true,
|
||||
expectedRevision: 2,
|
||||
expectedDigest: "digest-2",
|
||||
});
|
||||
eq(backend.sliceCalls.length, callsAfterFirstLoad + 1, "changed same-path fingerprint bypasses the resident projection");
|
||||
eq(refreshed?.digest, "digest-2", "fresh projection adopts the advanced canonical digest");
|
||||
|
||||
backend.HistorySliceForTab = async () => ({ ...backend.slice(0, 2), revision: 3, revisionKnown: undefined, digest: "digest-3" });
|
||||
const compatible = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
|
||||
preferResident: true,
|
||||
expectedRevision: 3,
|
||||
expectedDigest: "digest-3",
|
||||
});
|
||||
eq(compatible?.revisionKnown, true, "positive legacy slice revision implies a known canonical identity");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,170 @@
|
||||
// Run: tsx src/__tests__/use-controller-history-live-race.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import { historySliceFromMessages } from "./mockHistorySlice";
|
||||
import type { HistorySlice, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => { await flushPromises(); });
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
console.log("\nuse controller history/live race");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const tab: TabMeta = {
|
||||
id: "tab-live",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
topicId: "topic-live",
|
||||
topicTitle: "General",
|
||||
sessionPath: "/repo/sessions/live.jsonl",
|
||||
sessionRevision: 1,
|
||||
sessionDigest: "digest-v1",
|
||||
label: "model",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
};
|
||||
const meta: Meta = {
|
||||
label: "model",
|
||||
ready: true,
|
||||
eventChannel: "agent:event",
|
||||
sessionPath: tab.sessionPath,
|
||||
sessionRevision: tab.sessionRevision,
|
||||
sessionDigest: tab.sessionDigest,
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
const historyGate = deferred<HistorySlice>();
|
||||
const eventHandlers: Array<(event: WireEvent) => void> = [];
|
||||
let historyStarted = false;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, callback: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(callback as (event: WireEvent) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => [tab],
|
||||
MetaForTab: async () => meta,
|
||||
ContextUsageForTab: async () => ({ used: 0, window: 100, sessionTokens: 0 }),
|
||||
EffortForTab: async () => ({ supported: true, current: "auto", default: "auto", levels: ["auto"] }),
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
HistorySliceForTab: async () => {
|
||||
historyStarted = true;
|
||||
return historyGate.promise;
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("history request", () => historyStarted && eventHandlers.length > 0);
|
||||
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "turn_started", tabId: tab.id });
|
||||
await flushPromises();
|
||||
});
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.streaming) ?? false, "turn starts while history is pending");
|
||||
|
||||
historyGate.resolve(historySliceFromMessages(
|
||||
tab.id,
|
||||
[{ role: "user", content: "stale durable history" }],
|
||||
{ cursor: "", turns: 12 },
|
||||
{ revision: 1, digest: "digest-v1" },
|
||||
));
|
||||
await waitFor("hydration completion", () => controller?.state.hydrating === false);
|
||||
|
||||
ok(controller?.state.running ?? false, "late history keeps the live turn running");
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.streaming) ?? false, "late history keeps the live assistant stream");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale durable history") ?? false), "late history cannot replace the live transcript");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -211,12 +211,12 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
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 place.",
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"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 place.",
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"repeated recovery conflict notice can be normalized",
|
||||
);
|
||||
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 place.",
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"session recovery depth-cap localization uses its stable notice code",
|
||||
);
|
||||
eq(
|
||||
|
||||
@@ -209,10 +209,12 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
try {
|
||||
await heartbeatSaveTasks(next);
|
||||
} catch {
|
||||
// ignore
|
||||
// A concurrent external edit wins; reload the authoritative config so
|
||||
// the panel cannot continue editing a stale task list.
|
||||
await loadTasks();
|
||||
}
|
||||
},
|
||||
[],
|
||||
[loadTasks],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(async () => {
|
||||
|
||||
@@ -5,12 +5,31 @@
|
||||
import { app } from "../../../lib/bridge";
|
||||
import type { HeartbeatTask } from "./heartbeat.types";
|
||||
|
||||
export function heartbeatListTasks(): Promise<HeartbeatTask[]> {
|
||||
return app.HeartbeatReloadTasks().then((v) => (v ?? []) as HeartbeatTask[]);
|
||||
interface HeartbeatConfigView {
|
||||
revision: number;
|
||||
etag: string;
|
||||
tasks: HeartbeatTask[];
|
||||
}
|
||||
|
||||
export function heartbeatSaveTasks(tasks: HeartbeatTask[]): Promise<void> {
|
||||
return app.HeartbeatSaveTasks(tasks as unknown);
|
||||
let loadedConfigToken: Pick<HeartbeatConfigView, "revision" | "etag"> | null = null;
|
||||
|
||||
export function heartbeatListTasks(): Promise<HeartbeatTask[]> {
|
||||
return app.HeartbeatReloadConfig().then((raw) => {
|
||||
const view = (raw ?? { revision: 0, etag: "", tasks: [] }) as HeartbeatConfigView;
|
||||
loadedConfigToken = { revision: view.revision || 0, etag: view.etag || "" };
|
||||
return Array.isArray(view.tasks) ? view.tasks : [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function heartbeatSaveTasks(tasks: HeartbeatTask[]): Promise<void> {
|
||||
if (!loadedConfigToken) await heartbeatListTasks();
|
||||
const view = await app.HeartbeatSaveConfig({
|
||||
revision: loadedConfigToken?.revision || 0,
|
||||
etag: loadedConfigToken?.etag || "",
|
||||
tasks,
|
||||
});
|
||||
const saved = (view ?? { revision: 0, etag: "" }) as HeartbeatConfigView;
|
||||
loadedConfigToken = { revision: saved.revision || 0, etag: saved.etag || "" };
|
||||
}
|
||||
|
||||
export function heartbeatTriggerNow(id: string): Promise<void> {
|
||||
|
||||
@@ -174,6 +174,8 @@ export interface AppBindings {
|
||||
HeartbeatListTasks(): Promise<unknown>;
|
||||
HeartbeatReloadTasks(): Promise<unknown>;
|
||||
HeartbeatSaveTasks(tasks: unknown): Promise<void>;
|
||||
HeartbeatReloadConfig(): Promise<unknown>;
|
||||
HeartbeatSaveConfig(update: unknown): Promise<unknown>;
|
||||
HeartbeatTriggerNow(id: string): Promise<void>;
|
||||
HeartbeatGenerateID(): Promise<string>;
|
||||
Submit(input: string): Promise<void>;
|
||||
@@ -4782,6 +4784,8 @@ function makeMockApp(): AppBindings {
|
||||
async HeartbeatListTasks() { return []; },
|
||||
async HeartbeatReloadTasks() { return []; },
|
||||
async HeartbeatSaveTasks(_tasks: unknown) {},
|
||||
async HeartbeatReloadConfig() { return { revision: 0, etag: "", tasks: [] }; },
|
||||
async HeartbeatSaveConfig(_update: unknown) { return { revision: 0, etag: "", tasks: [] }; },
|
||||
async HeartbeatTriggerNow(_id: string) {},
|
||||
async HeartbeatGenerateID() { return "mock-" + Date.now().toString(36); },
|
||||
async ListTasks() { return []; },
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface TranscriptProjection {
|
||||
totalTurns: number;
|
||||
hasOlder: boolean;
|
||||
revision: number;
|
||||
revisionKnown: boolean;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface LoadOlderResult extends TranscriptProjection {
|
||||
@@ -142,6 +144,8 @@ interface SessionTranscript {
|
||||
startTurn: number;
|
||||
endTurn: number;
|
||||
revision: number;
|
||||
revisionKnown: boolean;
|
||||
digest: string;
|
||||
generation: number;
|
||||
bodyBytes: number;
|
||||
olderInFlight: boolean;
|
||||
@@ -156,6 +160,12 @@ function sessionKeyFor(tabId: string, sessionPath: string): string {
|
||||
return `${tabId}\n${sessionPath}`;
|
||||
}
|
||||
|
||||
function sliceRevisionKnown(slice: Pick<HistorySlice, "revision" | "revisionKnown">): boolean {
|
||||
// Compatibility with the first HistorySlice contract: positive revisions
|
||||
// were already canonical, but revisionKnown was not exposed yet.
|
||||
return slice.revisionKnown ?? (slice.revision ?? 0) > 0;
|
||||
}
|
||||
|
||||
function compareRecords(a: Pick<TranscriptRecord, "order" | "entryId">, b: Pick<TranscriptRecord, "order" | "entryId">): number {
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
return a.entryId < b.entryId ? -1 : a.entryId > b.entryId ? 1 : 0;
|
||||
@@ -460,6 +470,8 @@ export class TranscriptStore {
|
||||
startTurn: 0,
|
||||
endTurn: 0,
|
||||
revision: 0,
|
||||
revisionKnown: false,
|
||||
digest: "",
|
||||
generation: 0,
|
||||
bodyBytes: 0,
|
||||
olderInFlight: false,
|
||||
@@ -558,6 +570,8 @@ export class TranscriptStore {
|
||||
totalTurns: session.totalTurns,
|
||||
hasOlder: session.hasOlder,
|
||||
revision: session.revision,
|
||||
revisionKnown: session.revisionKnown,
|
||||
digest: session.digest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -794,11 +808,12 @@ export class TranscriptStore {
|
||||
async loadLatest(
|
||||
tabId: string,
|
||||
sessionPath: string,
|
||||
options: { turns?: number; preferResident?: boolean } = {},
|
||||
options: { turns?: number; preferResident?: boolean; expectedRevision?: number; expectedDigest?: string } = {},
|
||||
): Promise<TranscriptProjection | undefined> {
|
||||
const key = sessionKeyFor(tabId, sessionPath);
|
||||
const existing = this.sessions.get(key);
|
||||
if (options.preferResident && existing && existing.records.length > 0) {
|
||||
if (options.preferResident && existing && existing.records.length > 0 &&
|
||||
this.matchesExpectedFingerprint(existing, options.expectedRevision, options.expectedDigest)) {
|
||||
this.touch(existing);
|
||||
return this.projectionOf(existing);
|
||||
}
|
||||
@@ -827,6 +842,8 @@ export class TranscriptStore {
|
||||
session.startTurn = slice.startTurn ?? 0;
|
||||
session.endTurn = slice.endTurn ?? 0;
|
||||
session.revision = slice.revision ?? 0;
|
||||
session.revisionKnown = sliceRevisionKnown(slice);
|
||||
session.digest = slice.digest ?? "";
|
||||
this.autoFetchRefs(session);
|
||||
this.enforceBudgets();
|
||||
if (this.sessions.get(key) !== session) return undefined; // evicted by the budget
|
||||
@@ -860,12 +877,20 @@ export class TranscriptStore {
|
||||
const projection = await this.loadLatest(tabId, sessionPath, options);
|
||||
return projection ? { ...projection, kind: "reload", prependItems: [], removeIds: [] } : undefined;
|
||||
}
|
||||
if (!this.sameFingerprint(session, slice)) {
|
||||
// A backend that raced a rewrite may return a fresh page instead of a
|
||||
// stale marker. Never prepend rows from a different canonical state.
|
||||
const projection = await this.loadLatest(tabId, sessionPath, options);
|
||||
return projection ? { ...projection, kind: "reload", prependItems: [], removeIds: [] } : undefined;
|
||||
}
|
||||
const { items, removeIds } = this.prependRecords(session, asArray<HistoryEntry>(slice.entries));
|
||||
session.nextCursor = slice.nextCursor ?? "";
|
||||
session.hasOlder = Boolean(slice.hasOlder);
|
||||
session.totalTurns = slice.totalTurns ?? session.totalTurns;
|
||||
session.startTurn = slice.startTurn ?? session.startTurn;
|
||||
session.revision = slice.revision ?? session.revision;
|
||||
session.revisionKnown = sliceRevisionKnown(slice);
|
||||
session.digest = slice.digest ?? session.digest;
|
||||
this.enforceBudgets();
|
||||
if (this.sessions.get(key) !== session) return undefined;
|
||||
return { ...this.projectionOf(session), kind: "prepend", prependItems: items, removeIds };
|
||||
@@ -874,6 +899,25 @@ export class TranscriptStore {
|
||||
}
|
||||
}
|
||||
|
||||
private matchesExpectedFingerprint(session: SessionTranscript, expectedRevision?: number, expectedDigest?: string): boolean {
|
||||
const digest = (expectedDigest ?? "").trim();
|
||||
const revisionKnown = typeof expectedRevision === "number" && expectedRevision > 0;
|
||||
if (digest !== "" && session.digest !== digest) return false;
|
||||
if (revisionKnown && (!session.revisionKnown || session.revision !== expectedRevision)) return false;
|
||||
if (!revisionKnown && digest === "") {
|
||||
// Metadata identity temporarily missing cannot prove a known resident
|
||||
// projection is current. A backend round trip is the safe fallback.
|
||||
return !session.revisionKnown && session.digest === "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private sameFingerprint(session: SessionTranscript, slice: HistorySlice): boolean {
|
||||
return session.revision === (slice.revision ?? 0) &&
|
||||
session.revisionKnown === sliceRevisionKnown(slice) &&
|
||||
session.digest === (slice.digest ?? "");
|
||||
}
|
||||
|
||||
// ── lazy content ──────────────────────────────────────────────────────────
|
||||
|
||||
private sessionForEntry(tabId: string, entryId: string): SessionTranscript | undefined {
|
||||
|
||||
@@ -350,6 +350,8 @@ export interface TabMeta {
|
||||
topicId: string;
|
||||
topicTitle: string;
|
||||
sessionPath?: string;
|
||||
sessionRevision?: number;
|
||||
sessionDigest?: string;
|
||||
readOnly?: boolean;
|
||||
filePath?: string;
|
||||
projectColor?: string;
|
||||
@@ -571,6 +573,8 @@ export interface HistoryPage {
|
||||
endTurn: number;
|
||||
totalTurns: number;
|
||||
hasOlder: boolean;
|
||||
revision?: number;
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
// ── Windowed history paging (desktop/history_slice.go) ──────────────────────
|
||||
@@ -615,6 +619,8 @@ export interface HistorySlice {
|
||||
endTurn: number;
|
||||
stale: boolean; // cursor bound to an older session revision: discard + reload
|
||||
revision: number;
|
||||
revisionKnown?: boolean;
|
||||
digest?: string;
|
||||
// Diagnostic read path: index|scan|event-log|live-index|live-fallback (empty when the
|
||||
// backend predates the field or no session was readable).
|
||||
source?: string;
|
||||
@@ -791,11 +797,13 @@ export interface Meta {
|
||||
runtime?: SessionRuntimeView;
|
||||
startupErr?: string;
|
||||
eventChannel: string;
|
||||
sessionPath?: string;
|
||||
sessionRevision?: number;
|
||||
sessionDigest?: string;
|
||||
cwd: string;
|
||||
workspaceRoot?: string;
|
||||
workspaceName?: string;
|
||||
workspacePath?: string;
|
||||
sessionPath?: string;
|
||||
gitBranch?: string;
|
||||
imageInputEnabled?: boolean;
|
||||
autoApproveTools?: boolean;
|
||||
|
||||
@@ -376,6 +376,8 @@ interface State {
|
||||
historyTotalTurns: number;
|
||||
historyHasOlder: boolean;
|
||||
historyOlderLoading: boolean;
|
||||
historyRevision?: number;
|
||||
historyDigest?: string;
|
||||
backendActivationPending: boolean;
|
||||
messageAction?: MessageActionState;
|
||||
currentAssistant?: string;
|
||||
@@ -600,6 +602,8 @@ export function metaFromTab(tab: TabMeta, existing?: Meta): Meta {
|
||||
workspaceName: tab.workspaceName || existing?.workspaceName,
|
||||
workspacePath: tab.workspacePath || tab.workspaceRoot || existing?.workspacePath,
|
||||
sessionPath: tab.sessionPath !== undefined ? tab.sessionPath : existing?.sessionPath,
|
||||
sessionRevision: tab.sessionRevision !== undefined ? tab.sessionRevision : existing?.sessionRevision,
|
||||
sessionDigest: tab.sessionDigest !== undefined ? tab.sessionDigest : existing?.sessionDigest,
|
||||
gitBranch: tab.gitBranch || existing?.gitBranch,
|
||||
autoApproveTools,
|
||||
bypass: autoApproveTools,
|
||||
@@ -638,6 +642,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
|
||||
a.workspaceName === b.workspaceName &&
|
||||
a.workspacePath === b.workspacePath &&
|
||||
a.sessionPath === b.sessionPath &&
|
||||
a.sessionRevision === b.sessionRevision &&
|
||||
a.sessionDigest === b.sessionDigest &&
|
||||
a.gitBranch === b.gitBranch &&
|
||||
a.imageInputEnabled === b.imageInputEnabled &&
|
||||
a.autoApproveTools === b.autoApproveTools &&
|
||||
@@ -725,11 +731,28 @@ function hasCachedLiveTurn(state: State | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasReusableCachedTranscript(state: State | undefined, sessionPath?: string): boolean {
|
||||
function hasReusableCachedTranscript(state: State | undefined, sessionPath?: string, revision?: number, digest?: string): boolean {
|
||||
if (!state || state.items.length === 0) return false;
|
||||
const expectedSessionPath = (sessionPath ?? "").trim();
|
||||
if (!expectedSessionPath) return true;
|
||||
return (state.meta?.sessionPath ?? "").trim() === expectedSessionPath;
|
||||
if ((state.meta?.sessionPath ?? "").trim() !== expectedSessionPath) return false;
|
||||
if (typeof revision === "number" && revision > 0) {
|
||||
return state.historyRevision === revision && (digest ?? "") === (state.historyDigest ?? "");
|
||||
}
|
||||
if ((digest ?? "").trim() !== "") return state.historyDigest === digest;
|
||||
// A cached page with a known fingerprint must not be reused when the
|
||||
// backend temporarily cannot provide one (for example while its metadata
|
||||
// sidecar is being atomically replaced). Reloading is the only way to avoid
|
||||
// presenting a stale persisted transcript as current.
|
||||
return state.historyRevision === undefined && !state.historyDigest;
|
||||
}
|
||||
|
||||
function historyFingerprintMatchesMeta(history: { revision: number; revisionKnown?: boolean; digest?: string }, meta: Meta): boolean {
|
||||
const expectedDigest = (meta.sessionDigest ?? "").trim();
|
||||
if (expectedDigest && history.digest !== expectedDigest) return false;
|
||||
const expectedRevision = meta.sessionRevision ?? 0;
|
||||
if (expectedRevision > 0 && (!history.revisionKnown || history.revision !== expectedRevision)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Mirrors Go backend's ReadOnly() hints. */
|
||||
@@ -813,8 +836,8 @@ type Action =
|
||||
// TranscriptStore-driven history actions (windowed HistorySliceForTab flow).
|
||||
// Items carry stable entryId-derived ids; prepend also lists existing item
|
||||
// ids superseded by cross-page tool call/result merges.
|
||||
| { type: "history_replace"; items: Item[]; startTurn: number; totalTurns: number; hasOlder: boolean }
|
||||
| { type: "history_prepend"; items: Item[]; removeIds: string[]; startTurn: number; totalTurns: number; hasOlder: boolean }
|
||||
| { type: "history_replace"; items: Item[]; startTurn: number; totalTurns: number; hasOlder: boolean; revision?: number; digest?: string }
|
||||
| { type: "history_prepend"; items: Item[]; removeIds: string[]; startTurn: number; totalTurns: number; hasOlder: boolean; revision?: number; digest?: string }
|
||||
| { type: "history_items_patch"; patches: Record<string, Item> }
|
||||
| { type: "history_older_start" }
|
||||
| { type: "history_older_error" }
|
||||
@@ -2014,7 +2037,7 @@ export function reducer(s: State, a: Action): State {
|
||||
case "message_action_done": return { ...s, messageAction: undefined };
|
||||
case "history": {
|
||||
const { items, seq } = historyMessagesToItems(a.messages, "h", s.seq);
|
||||
return { ...s, items: compactArchivedToolItems(items), pendingSubmissionId: undefined, seq, hydrateHistoryLoaded: true, hydratePlaceholderItems: undefined, historyStartTurn: 0, historyTotalTurns: 0, historyHasOlder: false, historyOlderLoading: false };
|
||||
return { ...s, items: compactArchivedToolItems(items), pendingSubmissionId: undefined, seq, hydrateHistoryLoaded: true, hydratePlaceholderItems: undefined, historyStartTurn: 0, historyTotalTurns: 0, historyHasOlder: false, historyOlderLoading: false, historyRevision: undefined, historyDigest: undefined };
|
||||
}
|
||||
case "history_page": {
|
||||
const { items, seq } = historyPageItems(a.page);
|
||||
@@ -2030,6 +2053,8 @@ export function reducer(s: State, a: Action): State {
|
||||
historyTotalTurns: a.page.totalTurns,
|
||||
historyHasOlder: a.page.hasOlder,
|
||||
historyOlderLoading: false,
|
||||
historyRevision: a.page.revision,
|
||||
historyDigest: a.page.digest,
|
||||
};
|
||||
}
|
||||
case "history_older_start": return s.historyOlderLoading ? s : { ...s, historyOlderLoading: true };
|
||||
@@ -2045,6 +2070,8 @@ export function reducer(s: State, a: Action): State {
|
||||
historyTotalTurns: a.totalTurns,
|
||||
historyHasOlder: a.hasOlder,
|
||||
historyOlderLoading: false,
|
||||
historyRevision: a.revision,
|
||||
historyDigest: a.digest,
|
||||
};
|
||||
case "history_prepend": {
|
||||
const remove = a.removeIds.length > 0 ? new Set(a.removeIds) : undefined;
|
||||
@@ -2058,6 +2085,8 @@ export function reducer(s: State, a: Action): State {
|
||||
historyTotalTurns: a.totalTurns,
|
||||
historyHasOlder: a.hasOlder,
|
||||
historyOlderLoading: false,
|
||||
historyRevision: a.revision,
|
||||
historyDigest: a.digest,
|
||||
};
|
||||
}
|
||||
// Ref-resolved full content landed for history items already on screen:
|
||||
@@ -2304,6 +2333,7 @@ export function localizedBackendNoticeText(text: string): string {
|
||||
}
|
||||
if (
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in an isolated recovery branch$/i.test(msg) ||
|
||||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
|
||||
) {
|
||||
return t("recovery.noticeKeptCurrent");
|
||||
@@ -2388,6 +2418,7 @@ function recoveryNoticeDedupeKey(text: string, code?: string): string {
|
||||
}
|
||||
if (
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in an isolated recovery branch$/i.test(msg) ||
|
||||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
|
||||
msg === t("recovery.noticeKeptCurrent")
|
||||
) {
|
||||
@@ -2723,7 +2754,7 @@ export function useController() {
|
||||
const metaRefreshSeq = useRef(new Map<string, number>());
|
||||
const sessionLoadSeq = useRef(new Map<string, number>());
|
||||
const cancelHydrateSeq = useRef(new Map<string, number>());
|
||||
const sessionLoadInFlight = useRef(new Map<string, { sessionPath: string; promise: Promise<void> }>());
|
||||
const sessionLoadInFlight = useRef(new Map<string, { sessionPath: string; revision?: number; digest?: string; promise: Promise<void> }>());
|
||||
const transcriptSubscriptions = useRef(new Map<string, () => void>());
|
||||
const bumpMetaRefreshSeq = useCallback((tabId: string): number => {
|
||||
const seq = (metaRefreshSeq.current.get(tabId) ?? 0) + 1;
|
||||
@@ -2814,16 +2845,21 @@ export function useController() {
|
||||
placeholderItems?: Item[];
|
||||
preserveCachedHistory?: boolean;
|
||||
sessionPath?: string;
|
||||
sessionRevision?: number;
|
||||
sessionDigest?: string;
|
||||
cancelHydrateGeneration?: number;
|
||||
deferResetUntilHistory?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const sessionPath = (options.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath ?? "").trim();
|
||||
const stateMeta = statesRef.current.get(tabId)?.meta;
|
||||
const sessionPath = (options.sessionPath ?? stateMeta?.sessionPath ?? "").trim();
|
||||
const sessionRevision = options.sessionRevision ?? stateMeta?.sessionRevision;
|
||||
const sessionDigest = options.sessionDigest ?? stateMeta?.sessionDigest;
|
||||
const canJoinInFlight = !reset && !options.skipHistory;
|
||||
const shouldTrackInFlight = !options.skipHistory;
|
||||
if (canJoinInFlight) {
|
||||
const existing = sessionLoadInFlight.current.get(tabId);
|
||||
if (existing?.sessionPath === sessionPath) return existing.promise;
|
||||
if (existing?.sessionPath === sessionPath && existing.revision === sessionRevision && existing.digest === sessionDigest) return existing.promise;
|
||||
} else {
|
||||
sessionLoadInFlight.current.delete(tabId);
|
||||
}
|
||||
@@ -2835,7 +2871,7 @@ export function useController() {
|
||||
const hydrateStartedAt = Date.now();
|
||||
const skipHistory = Boolean(
|
||||
options.skipHistory ||
|
||||
(options.preserveCachedHistory && !reset && hasReusableCachedTranscript(statesRef.current.get(tabId), options.sessionPath)),
|
||||
(options.preserveCachedHistory && !reset && hasReusableCachedTranscript(statesRef.current.get(tabId), sessionPath, sessionRevision, sessionDigest)),
|
||||
);
|
||||
const deferResetUntilHistory = Boolean(options.deferResetUntilHistory && reset && !skipHistory);
|
||||
const stillCurrent = () =>
|
||||
@@ -2848,6 +2884,10 @@ export function useController() {
|
||||
if (reset && !deferResetUntilHistory && stillCurrent()) dispatchTo(tabId, { type: "reset" });
|
||||
const requiresVisibleTab = reason === "startup" || reason === "switch-tab" || reason === "open-topic";
|
||||
const stillVisible = () => !requiresVisibleTab || activeTabIdRef.current === tabId;
|
||||
const foregroundTurnActive = (): boolean => {
|
||||
const state = statesRef.current.get(tabId);
|
||||
return Boolean(state?.running || state?.turnActive || state?.pendingPrompt);
|
||||
};
|
||||
const noteFailure = (label: string, err: unknown) => {
|
||||
addBreadcrumb("tab.hydrate", `${label} failed ${tabId}: ${errorMessage(err)}`);
|
||||
};
|
||||
@@ -2866,7 +2906,7 @@ export function useController() {
|
||||
};
|
||||
|
||||
const historyStartedAt = Date.now();
|
||||
const projection = skipHistory
|
||||
let projection = skipHistory
|
||||
? undefined
|
||||
: await loadTimed("history", () =>
|
||||
// Windowed slice load through the transcript store. A resident
|
||||
@@ -2876,11 +2916,13 @@ export function useController() {
|
||||
getTranscriptStore().loadLatest(tabId, sessionPath, {
|
||||
turns: HISTORY_PAGE_TURNS,
|
||||
preferResident: !reset,
|
||||
expectedRevision: sessionRevision,
|
||||
expectedDigest: sessionDigest,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!stillCurrent()) return;
|
||||
if (!skipHistory && projection !== undefined) {
|
||||
if (!skipHistory && projection !== undefined && !foregroundTurnActive()) {
|
||||
if (deferResetUntilHistory && stillCurrent()) dispatchTo(tabId, { type: "reset" });
|
||||
dispatchTo(tabId, {
|
||||
type: "history_replace",
|
||||
@@ -2888,6 +2930,8 @@ export function useController() {
|
||||
startTurn: projection.startTurn,
|
||||
totalTurns: projection.totalTurns,
|
||||
hasOlder: projection.hasOlder,
|
||||
revision: projection.revisionKnown ? projection.revision : undefined,
|
||||
digest: projection.digest || undefined,
|
||||
});
|
||||
addBreadcrumb(
|
||||
"tab.hydrate",
|
||||
@@ -2921,13 +2965,49 @@ export function useController() {
|
||||
addBreadcrumb("tab.hydrate", `ancillary skipped inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const meta = await loadTimed("meta", () => loadMetaForTab(tabId));
|
||||
let meta = await loadTimed("meta", () => loadMetaForTab(tabId));
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `meta ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
|
||||
if (meta !== undefined && projection !== undefined && !skipHistory &&
|
||||
!foregroundTurnActive() && !historyFingerprintMatchesMeta(projection, meta)) {
|
||||
// The transcript and metadata are persisted in separate files. A save
|
||||
// can advance between those reads, so an exact-content page may be
|
||||
// older than the metadata sampled just afterwards. Re-read both as a
|
||||
// bounded pair; only replace the visible history when their canonical
|
||||
// fingerprints agree. A continuously changing session keeps the first
|
||||
// exact page and remains non-reusable because its digest differs.
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const reconciledProjection = await loadTimed("history reconcile", () => getTranscriptStore().loadLatest(tabId, sessionPath, {
|
||||
turns: HISTORY_PAGE_TURNS,
|
||||
preferResident: false,
|
||||
expectedRevision: meta?.sessionRevision,
|
||||
expectedDigest: meta?.sessionDigest,
|
||||
}));
|
||||
if (!stillCurrent() || !stillVisible() || reconciledProjection === undefined) return;
|
||||
const reconciledMeta = await loadTimed("meta reconcile", () => loadMetaForTab(tabId));
|
||||
if (!stillCurrent() || !stillVisible() || reconciledMeta === undefined) return;
|
||||
meta = reconciledMeta;
|
||||
dispatchTo(tabId, { type: "meta", meta });
|
||||
if (!foregroundTurnActive() && historyFingerprintMatchesMeta(reconciledProjection, meta)) {
|
||||
projection = reconciledProjection;
|
||||
dispatchTo(tabId, {
|
||||
type: "history_replace",
|
||||
items: projection.items,
|
||||
startTurn: projection.startTurn,
|
||||
totalTurns: projection.totalTurns,
|
||||
hasOlder: projection.hasOlder,
|
||||
revision: projection.revisionKnown ? projection.revision : undefined,
|
||||
digest: projection.digest || undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (foregroundTurnActive()) break;
|
||||
}
|
||||
}
|
||||
const ancillaryStartedAt = Date.now();
|
||||
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
return loadTimed(`ancillary ${label}`, load);
|
||||
@@ -2965,7 +3045,7 @@ export function useController() {
|
||||
});
|
||||
})();
|
||||
if (shouldTrackInFlight) {
|
||||
sessionLoadInFlight.current.set(tabId, { sessionPath, promise });
|
||||
sessionLoadInFlight.current.set(tabId, { sessionPath, revision: sessionRevision, digest: sessionDigest, promise });
|
||||
}
|
||||
try {
|
||||
await promise;
|
||||
@@ -2993,15 +3073,27 @@ export function useController() {
|
||||
const state = statesRef.current.get(targetTabId);
|
||||
if (!state?.historyHasOlder || state.historyOlderLoading || state.running) return;
|
||||
const sessionPath = state.meta?.sessionPath ?? "";
|
||||
const sessionRevision = state.meta?.sessionRevision ?? state.historyRevision;
|
||||
const sessionDigest = state.meta?.sessionDigest ?? state.historyDigest;
|
||||
ensureTranscriptSubscription(targetTabId);
|
||||
dispatchTo(targetTabId, { type: "history_older_start" });
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const result = await getTranscriptStore().loadOlder(targetTabId, sessionPath, { turns: HISTORY_PAGE_TURNS });
|
||||
const current = statesRef.current.get(targetTabId);
|
||||
const currentRevision = current?.meta?.sessionRevision ?? current?.historyRevision;
|
||||
const currentDigest = current?.meta?.sessionDigest ?? current?.historyDigest;
|
||||
const fingerprintMatches = (expected: number | undefined, actual: number | undefined) =>
|
||||
expected === undefined || expected <= 0 ? true : actual === expected;
|
||||
const digestMatches = (expected: string | undefined, actual: string | undefined) =>
|
||||
!expected || actual === expected;
|
||||
// A replace-level hydrate while the page was in flight clears
|
||||
// historyOlderLoading; prepending on top of it would duplicate rows.
|
||||
if (!current || !current.historyOlderLoading || (current.meta?.sessionPath ?? "") !== sessionPath) {
|
||||
// historyOlderLoading; a metadata or canonical-identity change also
|
||||
// makes the page belong to a different transcript generation.
|
||||
if (!current || !current.historyOlderLoading || (current.meta?.sessionPath ?? "") !== sessionPath ||
|
||||
!fingerprintMatches(sessionRevision, currentRevision) || !digestMatches(sessionDigest, currentDigest) ||
|
||||
(result !== undefined && (!fingerprintMatches(sessionRevision, result.revisionKnown ? result.revision : undefined) ||
|
||||
!digestMatches(sessionDigest, result.digest)))) {
|
||||
dispatchTo(targetTabId, { type: "history_older_error" });
|
||||
return;
|
||||
}
|
||||
@@ -3019,6 +3111,8 @@ export function useController() {
|
||||
startTurn: result.startTurn,
|
||||
totalTurns: result.totalTurns,
|
||||
hasOlder: result.hasOlder,
|
||||
revision: result.revisionKnown ? result.revision : undefined,
|
||||
digest: result.digest || undefined,
|
||||
});
|
||||
} else {
|
||||
dispatchTo(targetTabId, {
|
||||
@@ -3028,6 +3122,8 @@ export function useController() {
|
||||
startTurn: result.startTurn,
|
||||
totalTurns: result.totalTurns,
|
||||
hasOlder: result.hasOlder,
|
||||
revision: result.revisionKnown ? result.revision : undefined,
|
||||
digest: result.digest || undefined,
|
||||
});
|
||||
}
|
||||
addBreadcrumb(
|
||||
@@ -3112,6 +3208,8 @@ export function useController() {
|
||||
await loadSessionDataForTab(active.id, reset, "startup", {
|
||||
preserveCachedHistory,
|
||||
sessionPath: active.sessionPath,
|
||||
sessionRevision: active.sessionRevision,
|
||||
sessionDigest: active.sessionDigest,
|
||||
});
|
||||
if (reset) dispatchRuntimeStatusForTab(active.id, active, snapshotAt);
|
||||
return active.id;
|
||||
@@ -3132,7 +3230,11 @@ export function useController() {
|
||||
const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab, snapshotAt);
|
||||
const missedTurnDone = Boolean(local?.running && !foregroundRunning);
|
||||
if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
|
||||
await loadSessionDataForTab(tabId, missedTurnDone, "startup");
|
||||
await loadSessionDataForTab(tabId, missedTurnDone, "startup", {
|
||||
sessionPath: tab.sessionPath,
|
||||
sessionRevision: tab.sessionRevision,
|
||||
sessionDigest: tab.sessionDigest,
|
||||
});
|
||||
return tabs;
|
||||
}
|
||||
const [jobs, effort] = await Promise.all([
|
||||
@@ -4263,7 +4365,9 @@ export function useController() {
|
||||
noteActivationRequested(switchRequestId);
|
||||
const previousTabId = activeTabIdRef.current;
|
||||
const targetSessionPath = optimisticTab?.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath;
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath);
|
||||
const targetSessionRevision = optimisticTab?.sessionRevision ?? statesRef.current.get(tabId)?.meta?.sessionRevision;
|
||||
const targetSessionDigest = optimisticTab?.sessionDigest ?? statesRef.current.get(tabId)?.meta?.sessionDigest;
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath, targetSessionRevision, targetSessionDigest);
|
||||
addBreadcrumb("tab.switch", `click ${tabId}`);
|
||||
setActiveTabId(tabId);
|
||||
activeTabIdRef.current = tabId;
|
||||
@@ -4315,6 +4419,8 @@ export function useController() {
|
||||
skipHistory: hasCachedLiveTurn(statesRef.current.get(tabId)),
|
||||
preserveCachedHistory,
|
||||
sessionPath: targetSessionPath,
|
||||
sessionRevision: targetSessionRevision,
|
||||
sessionDigest: targetSessionDigest,
|
||||
});
|
||||
noteActivationSettled(switchRequestId, "ready");
|
||||
return tabs;
|
||||
@@ -4340,7 +4446,7 @@ export function useController() {
|
||||
const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined;
|
||||
const prevState = statesRef.current.get(meta.id);
|
||||
const isNewTab = !prevState;
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath);
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath, meta.sessionRevision, meta.sessionDigest);
|
||||
setActiveTabId(meta.id);
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
@@ -4350,6 +4456,8 @@ export function useController() {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
sessionRevision: meta.sessionRevision,
|
||||
sessionDigest: meta.sessionDigest,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
@@ -4367,7 +4475,7 @@ export function useController() {
|
||||
const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined;
|
||||
const prevState = statesRef.current.get(meta.id);
|
||||
const isNewTab = !prevState;
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath);
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath, meta.sessionRevision, meta.sessionDigest);
|
||||
setActiveTabId(meta.id);
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
@@ -4377,6 +4485,8 @@ export function useController() {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
sessionRevision: meta.sessionRevision,
|
||||
sessionDigest: meta.sessionDigest,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
@@ -4394,7 +4504,7 @@ export function useController() {
|
||||
const prevItems = activeTabIdRef.current ? statesRef.current.get(activeTabIdRef.current)?.items : undefined;
|
||||
const prevState = statesRef.current.get(meta.id);
|
||||
const isNewTab = !prevState;
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath);
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(prevState, meta.sessionPath, meta.sessionRevision, meta.sessionDigest);
|
||||
setActiveTabId(meta.id);
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
@@ -4404,6 +4514,8 @@ export function useController() {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
sessionRevision: meta.sessionRevision,
|
||||
sessionDigest: meta.sessionDigest,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
|
||||
@@ -1225,7 +1225,7 @@ export const en = {
|
||||
"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 place.",
|
||||
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in an isolated recovery branch.",
|
||||
"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.",
|
||||
|
||||
|
||||
+321
-58
@@ -11,7 +11,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
@@ -23,6 +28,8 @@ import (
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
|
||||
@@ -49,7 +56,44 @@ type HeartbeatTask struct {
|
||||
|
||||
// heartbeatConfig is the on-disk format.
|
||||
type heartbeatConfig struct {
|
||||
Tasks []HeartbeatTask `json:"tasks"`
|
||||
Revision uint64 `json:"revision,omitempty"`
|
||||
Tasks []HeartbeatTask `json:"tasks"`
|
||||
}
|
||||
|
||||
// ErrHeartbeatConfigConflict means another writer changed the config after
|
||||
// this engine last read it. Callers should reload before retrying the edit.
|
||||
var ErrHeartbeatConfigConflict = errors.New("heartbeat config changed concurrently")
|
||||
|
||||
type heartbeatConfigSnapshot struct {
|
||||
cfg heartbeatConfig
|
||||
digest [sha256.Size]byte
|
||||
exists bool
|
||||
}
|
||||
|
||||
// HeartbeatConfigView is the revisioned Wails contract used by current
|
||||
// frontends. ETag detects external editors that do not increment Revision.
|
||||
type HeartbeatConfigView struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
ETag string `json:"etag"`
|
||||
Tasks []HeartbeatTask `json:"tasks"`
|
||||
}
|
||||
|
||||
type HeartbeatConfigUpdate struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
ETag string `json:"etag"`
|
||||
Tasks []HeartbeatTask `json:"tasks"`
|
||||
}
|
||||
|
||||
func (s heartbeatConfigSnapshot) view() HeartbeatConfigView {
|
||||
tasks := s.cfg.Tasks
|
||||
if tasks == nil {
|
||||
tasks = []HeartbeatTask{}
|
||||
}
|
||||
etag := ""
|
||||
if s.exists {
|
||||
etag = hex.EncodeToString(s.digest[:])
|
||||
}
|
||||
return HeartbeatConfigView{Revision: s.cfg.Revision, ETag: etag, Tasks: tasks}
|
||||
}
|
||||
|
||||
// ── Engine ──────────────────────────────────────────────────────────────────
|
||||
@@ -57,13 +101,18 @@ type heartbeatConfig struct {
|
||||
// HeartbeatEngine runs scheduled task execution in a background goroutine.
|
||||
// It is owned by App and started during App.startup.
|
||||
type HeartbeatEngine struct {
|
||||
mu sync.Mutex
|
||||
tasks []HeartbeatTask
|
||||
cfgMod time.Time // config-file mtime as of the engine's last read/write (external-edit probe)
|
||||
pendingTopics map[string]heartbeatPendingTopic // in-memory retry/in-flight safety for NewConversationEachRun
|
||||
done chan struct{}
|
||||
running bool
|
||||
app *App // back-reference for topic creation, tab routing, and prompt submission
|
||||
mu sync.Mutex
|
||||
tasks []HeartbeatTask
|
||||
cfgRevision uint64 // persisted config revision last observed
|
||||
cfgDigest [sha256.Size]byte // decoded config bytes last observed
|
||||
cfgKnown bool // cfgDigest describes an existing file
|
||||
cfgInitialized bool // engine has observed existing or missing config state
|
||||
cfgDeleted bool // an existing config was removed externally
|
||||
pendingTopics map[string]heartbeatPendingTopic // in-memory retry/in-flight safety for NewConversationEachRun
|
||||
runningTasks map[string]struct{} // task-level execution reservation shared by tick and TriggerNow
|
||||
done chan struct{}
|
||||
running bool
|
||||
app *App // back-reference for topic creation, tab routing, and prompt submission
|
||||
}
|
||||
|
||||
type heartbeatPendingTopic struct {
|
||||
@@ -76,6 +125,7 @@ func newHeartbeatEngine(app *App) *HeartbeatEngine {
|
||||
app: app,
|
||||
done: make(chan struct{}),
|
||||
pendingTopics: make(map[string]heartbeatPendingTopic),
|
||||
runningTasks: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,60 +140,111 @@ func (e *HeartbeatEngine) configPath() string {
|
||||
|
||||
// loadTasks reads tasks from disk.
|
||||
func (e *HeartbeatEngine) loadTasks() []HeartbeatTask {
|
||||
b, err := readFileUTF8(e.configPath())
|
||||
snapshot, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return snapshot.cfg.Tasks
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) readConfigSnapshot() (heartbeatConfigSnapshot, error) {
|
||||
path := e.configPath()
|
||||
b, err := readFileUTF8(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return heartbeatConfigSnapshot{}, nil
|
||||
}
|
||||
return heartbeatConfigSnapshot{}, err
|
||||
}
|
||||
var cfg heartbeatConfig
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
log.Printf("[heartbeat] invalid config: %v", err)
|
||||
return nil
|
||||
return heartbeatConfigSnapshot{}, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
return cfg.Tasks
|
||||
snapshot := heartbeatConfigSnapshot{
|
||||
cfg: cfg,
|
||||
digest: sha256.Sum256(b),
|
||||
exists: true,
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// noteConfigModLocked records the config file's current mtime so the tick
|
||||
// external-edit probe does not re-read a file the engine itself just wrote.
|
||||
func (e *HeartbeatEngine) noteConfigModLocked() {
|
||||
if info, err := os.Stat(e.configPath()); err == nil {
|
||||
e.cfgMod = info.ModTime()
|
||||
func (e *HeartbeatEngine) recordConfigSnapshotLocked(snapshot heartbeatConfigSnapshot) {
|
||||
e.cfgRevision = snapshot.cfg.Revision
|
||||
e.cfgDigest = snapshot.digest
|
||||
e.cfgKnown = snapshot.exists
|
||||
e.cfgInitialized = true
|
||||
if snapshot.exists {
|
||||
e.cfgDeleted = false
|
||||
}
|
||||
}
|
||||
|
||||
// adoptExternalEditsLocked re-reads the config when its mtime moved since the
|
||||
// engine last touched the file: heartbeat-tasks.json is documented as human-
|
||||
// and AI-editable, so an external edit should be scheduled by the next tick
|
||||
// rather than waiting for a manual Refresh or an app restart.
|
||||
// adoptExternalEditsLocked compares the exact content digest so edits are
|
||||
// detected even on filesystems with coarse mtimes.
|
||||
func (e *HeartbeatEngine) adoptExternalEditsLocked() {
|
||||
info, err := os.Stat(e.configPath())
|
||||
if err != nil || info.ModTime().Equal(e.cfgMod) {
|
||||
snapshot, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[heartbeat] invalid external config: %v", err)
|
||||
return
|
||||
}
|
||||
e.cfgMod = info.ModTime()
|
||||
e.tasks = e.loadTasks()
|
||||
if !snapshot.exists {
|
||||
// A config that existed when this engine started was explicitly removed
|
||||
// by the user. Treat that deletion as authoritative: retaining the old
|
||||
// in-memory tasks would execute a side effect on the next tick and then
|
||||
// recreate the file from stale state.
|
||||
if e.cfgInitialized && e.cfgKnown {
|
||||
e.tasks = nil
|
||||
e.pendingTopics = make(map[string]heartbeatPendingTopic)
|
||||
e.cfgDeleted = true
|
||||
e.recordConfigSnapshotLocked(snapshot)
|
||||
}
|
||||
return
|
||||
}
|
||||
if e.cfgKnown && snapshot.digest == e.cfgDigest {
|
||||
return
|
||||
}
|
||||
e.recordConfigSnapshotLocked(snapshot)
|
||||
e.tasks = snapshot.cfg.Tasks
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
}
|
||||
|
||||
// saveTasks writes tasks to disk atomically.
|
||||
func (e *HeartbeatEngine) saveTasks(tasks []HeartbeatTask) error {
|
||||
return e.writeTasks(tasks, heartbeatConfigSnapshot{}, false)
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) writeTasks(tasks []HeartbeatTask, expected heartbeatConfigSnapshot, compare bool) error {
|
||||
if tasks == nil {
|
||||
tasks = []HeartbeatTask{}
|
||||
}
|
||||
cfg := heartbeatConfig{Tasks: tasks}
|
||||
path := e.configPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
lockCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
release, err := filelock.Acquire(lockCtx, path+".lock")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
current, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if compare && (current.exists != expected.exists || current.digest != expected.digest || current.cfg.Revision != expected.cfg.Revision) {
|
||||
return ErrHeartbeatConfigConflict
|
||||
}
|
||||
revision := current.cfg.Revision + 1
|
||||
if !current.exists {
|
||||
revision = 1
|
||||
}
|
||||
cfg := heartbeatConfig{Revision: revision, Tasks: tasks}
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := e.configPath()
|
||||
// Ensure the parent directory exists before writing.
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
return fileutil.AtomicWriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
// Start launches the scheduler goroutine.
|
||||
@@ -153,8 +254,13 @@ func (e *HeartbeatEngine) Start() {
|
||||
if e.running {
|
||||
return
|
||||
}
|
||||
e.tasks = e.loadTasks()
|
||||
e.noteConfigModLocked()
|
||||
snapshot, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[heartbeat] invalid config: %v", err)
|
||||
} else {
|
||||
e.recordConfigSnapshotLocked(snapshot)
|
||||
e.tasks = snapshot.cfg.Tasks
|
||||
}
|
||||
e.running = true
|
||||
go e.loop()
|
||||
log.Printf("[heartbeat] engine started (%d tasks)", len(e.tasks))
|
||||
@@ -241,6 +347,59 @@ func heartbeatControllerBusy(ctrl heartbeatRuntimeStatus) bool {
|
||||
// On controller failure the task is returned WITHOUT updating LastRunAt,
|
||||
// so it will be retried on the next tick.
|
||||
func (e *HeartbeatEngine) executeTask(t HeartbeatTask) HeartbeatTask {
|
||||
if !e.claimTask(t.ID) {
|
||||
log.Printf("[heartbeat] task %q is already running, skipping overlapping trigger", t.Title)
|
||||
return t
|
||||
}
|
||||
releaseLease, err := e.tryAcquireTaskLease(t.ID)
|
||||
if err != nil {
|
||||
log.Printf("[heartbeat] task %q is already owned by another runtime, skipping", t.Title)
|
||||
e.releaseTask(t.ID)
|
||||
return t
|
||||
}
|
||||
defer func() {
|
||||
releaseLease()
|
||||
e.releaseTask(t.ID)
|
||||
}()
|
||||
return e.executeTaskOwned(t)
|
||||
}
|
||||
|
||||
// tryAcquireTaskLease extends the in-process reservation to other Reasonix
|
||||
// processes. The lease is held from before topic creation through prompt
|
||||
// submission, and the OS releases it automatically if the process is killed.
|
||||
func (e *HeartbeatEngine) tryAcquireTaskLease(taskID string) (func(), error) {
|
||||
path := e.heartbeatTaskLeasePath(taskID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filelock.TryAcquire(path)
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) heartbeatTaskLeasePath(taskID string) string {
|
||||
digest := sha256.Sum256([]byte(taskID))
|
||||
return e.configPath() + "." + hex.EncodeToString(digest[:8]) + ".run.lock"
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) claimTask(id string) bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.runningTasks == nil {
|
||||
e.runningTasks = make(map[string]struct{})
|
||||
}
|
||||
if _, exists := e.runningTasks[id]; exists {
|
||||
return false
|
||||
}
|
||||
e.runningTasks[id] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) releaseTask(id string) {
|
||||
e.mu.Lock()
|
||||
delete(e.runningTasks, id)
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) executeTaskOwned(t HeartbeatTask) HeartbeatTask {
|
||||
title := "Heartbeat: " + t.Title
|
||||
scope := t.Scope
|
||||
workspaceRoot := t.WorkspaceRoot
|
||||
@@ -340,7 +499,7 @@ func (e *HeartbeatEngine) executeTask(t HeartbeatTask) HeartbeatTask {
|
||||
delete(e.pendingTopics, t.ID)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
return e.executeTask(t)
|
||||
return e.executeTaskOwned(t)
|
||||
}
|
||||
|
||||
// Set the task's approval mode only after confirming the controller is idle.
|
||||
@@ -396,25 +555,71 @@ func (e *HeartbeatEngine) ListTasks() []HeartbeatTask {
|
||||
|
||||
// ReloadTasks reloads the task list from disk and replaces the in-memory copy.
|
||||
func (e *HeartbeatEngine) ReloadTasks() []HeartbeatTask {
|
||||
return e.ReloadConfig().Tasks
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) ReloadConfig() HeartbeatConfigView {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.tasks = e.loadTasks()
|
||||
e.noteConfigModLocked()
|
||||
snapshot, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[heartbeat] reload config: %v", err)
|
||||
return heartbeatConfigSnapshot{cfg: heartbeatConfig{Tasks: []HeartbeatTask{}}}.view()
|
||||
}
|
||||
e.recordConfigSnapshotLocked(snapshot)
|
||||
e.tasks = snapshot.cfg.Tasks
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
out := make([]HeartbeatTask, len(e.tasks))
|
||||
copy(out, e.tasks)
|
||||
return out
|
||||
return snapshot.view()
|
||||
}
|
||||
|
||||
// ReplaceTasks atomically replaces the task list and persists it.
|
||||
func (e *HeartbeatEngine) ReplaceTasks(tasks []HeartbeatTask) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
expected, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.cfgInitialized && (expected.exists != e.cfgKnown || expected.digest != e.cfgDigest || expected.cfg.Revision != e.cfgRevision) {
|
||||
return ErrHeartbeatConfigConflict
|
||||
}
|
||||
if err := e.writeTasks(tasks, expected, true); err != nil {
|
||||
return err
|
||||
}
|
||||
latest, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.recordConfigSnapshotLocked(latest)
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
err := e.saveTasks(tasks)
|
||||
e.noteConfigModLocked()
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceConfig applies a frontend edit only when its revision and ETag still
|
||||
// identify the exact config the user edited. This prevents a stale panel from
|
||||
// overwriting an external or second-process change.
|
||||
func (e *HeartbeatEngine) ReplaceConfig(update HeartbeatConfigUpdate) (HeartbeatConfigView, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
expected, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
return HeartbeatConfigView{}, err
|
||||
}
|
||||
if expected.cfg.Revision != update.Revision || expected.view().ETag != update.ETag {
|
||||
return expected.view(), ErrHeartbeatConfigConflict
|
||||
}
|
||||
if err := e.writeTasks(update.Tasks, expected, true); err != nil {
|
||||
return expected.view(), err
|
||||
}
|
||||
latest, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
return HeartbeatConfigView{}, err
|
||||
}
|
||||
e.recordConfigSnapshotLocked(latest)
|
||||
e.tasks = latest.cfg.Tasks
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
return latest.view(), nil
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) prunePendingTopicsLocked(tasks []HeartbeatTask) {
|
||||
@@ -465,31 +670,72 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
|
||||
// only the run-state fields (TopicID, LastRunAt, CreatedAt backfill);
|
||||
// task definitions added, edited, or deleted externally are adopted from
|
||||
// disk, so the save below can never silently roll an external edit back.
|
||||
tasks := e.loadTasks()
|
||||
if tasks == nil {
|
||||
// Missing or unreadable file: fall back to the in-memory list so the
|
||||
// run state still lands (the historical behavior).
|
||||
tasks = append([]HeartbeatTask(nil), e.tasks...)
|
||||
for range 3 {
|
||||
expected, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[heartbeat] cannot read config before run-state merge: %v", err)
|
||||
return
|
||||
}
|
||||
tasks := expected.cfg.Tasks
|
||||
if !expected.exists {
|
||||
switch {
|
||||
case e.cfgDeleted || (e.cfgInitialized && e.cfgKnown):
|
||||
// Observe deletion in this CAS loop too. A run can finish before the
|
||||
// next scheduler tick adopts external edits; relying only on tick
|
||||
// would let that completion recreate a file the user just removed.
|
||||
e.tasks = nil
|
||||
e.pendingTopics = make(map[string]heartbeatPendingTopic)
|
||||
e.cfgDeleted = true
|
||||
e.recordConfigSnapshotLocked(expected)
|
||||
return
|
||||
case !e.cfgInitialized:
|
||||
// Only an engine that has never observed disk may bootstrap from an
|
||||
// in-memory list. Once a config existed, deletion is authoritative.
|
||||
tasks = append([]HeartbeatTask(nil), e.tasks...)
|
||||
}
|
||||
}
|
||||
mergeHeartbeatRunUpdates(tasks, updates)
|
||||
if err := e.writeTasks(tasks, expected, true); err != nil {
|
||||
if errors.Is(err, ErrHeartbeatConfigConflict) {
|
||||
continue
|
||||
}
|
||||
log.Printf("[heartbeat] run-state merge failed: %v", err)
|
||||
return
|
||||
}
|
||||
latest, err := e.readConfigSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[heartbeat] reload after run-state merge: %v", err)
|
||||
return
|
||||
}
|
||||
e.recordConfigSnapshotLocked(latest)
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
return
|
||||
}
|
||||
log.Printf("[heartbeat] run-state merge lost repeated config races; next tick will retry")
|
||||
}
|
||||
|
||||
func mergeHeartbeatRunUpdates(tasks []HeartbeatTask, updates map[string]HeartbeatTask) {
|
||||
for i := range tasks {
|
||||
update, ok := updates[tasks[i].ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if update.TopicID != "" {
|
||||
// Run state is monotonic. A runtime that lost the cross-process task
|
||||
// lease returns the task snapshot it started with; if its later config
|
||||
// merge lands after the owner, that stale completion must not roll back
|
||||
// the owner's timestamp or fresh-conversation topic.
|
||||
newerRun := update.LastRunAt > tasks[i].LastRunAt
|
||||
if update.TopicID != "" && (tasks[i].TopicID == "" || newerRun) {
|
||||
tasks[i].TopicID = update.TopicID
|
||||
}
|
||||
if update.LastRunAt != 0 {
|
||||
if newerRun {
|
||||
tasks[i].LastRunAt = update.LastRunAt
|
||||
}
|
||||
if tasks[i].CreatedAt == 0 && update.CreatedAt != 0 {
|
||||
tasks[i].CreatedAt = update.CreatedAt
|
||||
}
|
||||
}
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
_ = e.saveTasks(tasks)
|
||||
e.noteConfigModLocked()
|
||||
}
|
||||
|
||||
// parseInterval converts a string like "5m", "1h", "30s" to time.Duration.
|
||||
@@ -835,6 +1081,14 @@ func (a *App) HeartbeatReloadTasks() []HeartbeatTask {
|
||||
return a.heartbeat.ReloadTasks()
|
||||
}
|
||||
|
||||
// HeartbeatReloadConfig returns tasks with the CAS token used by current UIs.
|
||||
func (a *App) HeartbeatReloadConfig() HeartbeatConfigView {
|
||||
if a.heartbeat == nil {
|
||||
return HeartbeatConfigView{Tasks: []HeartbeatTask{}}
|
||||
}
|
||||
return a.heartbeat.ReloadConfig()
|
||||
}
|
||||
|
||||
// HeartbeatSaveTasks replaces the full task list and persists it.
|
||||
func (a *App) HeartbeatSaveTasks(tasks []HeartbeatTask) error {
|
||||
if a.heartbeat == nil {
|
||||
@@ -843,6 +1097,15 @@ func (a *App) HeartbeatSaveTasks(tasks []HeartbeatTask) error {
|
||||
return a.heartbeat.ReplaceTasks(tasks)
|
||||
}
|
||||
|
||||
// HeartbeatSaveConfig replaces tasks only when the frontend's revision and
|
||||
// ETag still match the exact file it loaded.
|
||||
func (a *App) HeartbeatSaveConfig(update HeartbeatConfigUpdate) (HeartbeatConfigView, error) {
|
||||
if a.heartbeat == nil {
|
||||
return HeartbeatConfigView{Tasks: []HeartbeatTask{}}, nil
|
||||
}
|
||||
return a.heartbeat.ReplaceConfig(update)
|
||||
}
|
||||
|
||||
// HeartbeatTriggerNow immediately executes the task with the given ID.
|
||||
func (a *App) HeartbeatTriggerNow(id string) {
|
||||
if a.heartbeat == nil {
|
||||
|
||||
+215
-1
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/filelock"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
@@ -22,6 +25,100 @@ func TestHeartbeatConfigPathUsesReasonixUserStateDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatConfigRevisionKeepsLegacyFilesReadable(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := &HeartbeatEngine{}
|
||||
legacy := `{"tasks":[{"id":"legacy","title":"Legacy","interval":"1h","enabled":false}]}`
|
||||
if err := os.MkdirAll(filepath.Dir(engine.configPath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(engine.configPath(), []byte(legacy), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks := engine.ReloadTasks()
|
||||
if len(tasks) != 1 || tasks[0].ID != "legacy" {
|
||||
t.Fatalf("legacy tasks = %+v, want one readable task", tasks)
|
||||
}
|
||||
if engine.cfgRevision != 0 {
|
||||
t.Fatalf("legacy revision = %d, want zero", engine.cfgRevision)
|
||||
}
|
||||
if err := engine.ReplaceTasks(tasks); err != nil {
|
||||
t.Fatalf("upgrade save: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(engine.configPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg heartbeatConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Revision != 1 || len(cfg.Tasks) != 1 {
|
||||
t.Fatalf("upgraded config = %+v, want revision 1 with legacy task", cfg)
|
||||
}
|
||||
var previousReader struct {
|
||||
Tasks []HeartbeatTask `json:"tasks"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &previousReader); err != nil || len(previousReader.Tasks) != 1 {
|
||||
t.Fatalf("previous reader could not ignore revision: tasks=%+v err=%v", previousReader.Tasks, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatReplaceTasksRejectsStaleRevision(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := &HeartbeatEngine{}
|
||||
initial := []HeartbeatTask{{ID: "same", Title: "initial", Interval: "1h", Enabled: false}}
|
||||
if err := engine.saveTasks(initial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine.ReloadTasks()
|
||||
external := []HeartbeatTask{{ID: "same", Title: "edited externally", Interval: "2h", Enabled: false}}
|
||||
if err := engine.saveTasks(external); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := engine.ReplaceTasks([]HeartbeatTask{{ID: "same", Title: "stale UI edit", Interval: "3h", Enabled: false}})
|
||||
if !errors.Is(err, ErrHeartbeatConfigConflict) {
|
||||
t.Fatalf("ReplaceTasks error = %v, want config conflict", err)
|
||||
}
|
||||
onDisk := engine.loadTasks()
|
||||
if len(onDisk) != 1 || onDisk[0].Title != "edited externally" || onDisk[0].Interval != "2h" {
|
||||
t.Fatalf("stale replacement changed disk config: %+v", onDisk)
|
||||
}
|
||||
if got := engine.ListTasks()[0].Title; got != "initial" {
|
||||
t.Fatalf("stale replacement changed in-memory tasks: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatReplaceConfigRejectsSameRevisionExternalEditByETag(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := &HeartbeatEngine{}
|
||||
initial := []HeartbeatTask{{ID: "same", Title: "initial", Interval: "1h", Enabled: false}}
|
||||
if err := engine.saveTasks(initial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded := engine.ReloadConfig()
|
||||
external := heartbeatConfig{Revision: loaded.Revision, Tasks: []HeartbeatTask{{ID: "same", Title: "edited externally", Interval: "2h", Enabled: false}}}
|
||||
data, err := json.MarshalIndent(external, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(engine.configPath(), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = engine.ReplaceConfig(HeartbeatConfigUpdate{
|
||||
Revision: loaded.Revision,
|
||||
ETag: loaded.ETag,
|
||||
Tasks: []HeartbeatTask{{ID: "same", Title: "stale UI edit", Interval: "3h", Enabled: false}},
|
||||
})
|
||||
if !errors.Is(err, ErrHeartbeatConfigConflict) {
|
||||
t.Fatalf("ReplaceConfig error = %v, want config conflict", err)
|
||||
}
|
||||
onDisk := engine.loadTasks()
|
||||
if len(onDisk) != 1 || onDisk[0].Title != "edited externally" {
|
||||
t.Fatalf("same-revision external edit was overwritten: %+v", onDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatLoadTasksDecodesGB18030Config(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := &HeartbeatEngine{}
|
||||
@@ -150,6 +247,23 @@ func TestHeartbeatControllerBusyIncludesPendingPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatTaskExecutionReservationSerializesTriggers(t *testing.T) {
|
||||
engine := &HeartbeatEngine{}
|
||||
if !engine.claimTask("same") {
|
||||
t.Fatal("first task claim should succeed")
|
||||
}
|
||||
second := make(chan bool, 1)
|
||||
go func() { second <- engine.claimTask("same") }()
|
||||
if <-second {
|
||||
t.Fatal("overlapping task trigger should be rejected")
|
||||
}
|
||||
engine.releaseTask("same")
|
||||
if !engine.claimTask("same") {
|
||||
t.Fatal("task should be claimable after the owner releases it")
|
||||
}
|
||||
engine.releaseTask("same")
|
||||
}
|
||||
|
||||
func TestHeartbeatExecuteTaskPersistsFreshConversationTopicID(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
@@ -386,6 +500,27 @@ func TestHeartbeatMergeRunUpdatesPreservesConcurrentEditsAndDeletes(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatMergeRunUpdatesNeverRegressesNewerRunState(t *testing.T) {
|
||||
tasks := []HeartbeatTask{{
|
||||
ID: "run",
|
||||
TopicID: "topic-new",
|
||||
LastRunAt: 300,
|
||||
}}
|
||||
mergeHeartbeatRunUpdates(tasks, map[string]HeartbeatTask{
|
||||
"run": {ID: "run", TopicID: "topic-old", LastRunAt: 200},
|
||||
})
|
||||
if tasks[0].TopicID != "topic-new" || tasks[0].LastRunAt != 300 {
|
||||
t.Fatalf("stale run state regressed the owner result: %+v", tasks[0])
|
||||
}
|
||||
|
||||
mergeHeartbeatRunUpdates(tasks, map[string]HeartbeatTask{
|
||||
"run": {ID: "run", TopicID: "topic-latest", LastRunAt: 400},
|
||||
})
|
||||
if tasks[0].TopicID != "topic-latest" || tasks[0].LastRunAt != 400 {
|
||||
t.Fatalf("newer run state was not adopted: %+v", tasks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatReplaceTasksPrunesFreshConversationPendingTopics(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := &HeartbeatEngine{
|
||||
@@ -504,7 +639,6 @@ func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) {
|
||||
}
|
||||
engine.mu.Lock()
|
||||
engine.tasks = engine.loadTasks()
|
||||
engine.noteConfigModLocked()
|
||||
engine.mu.Unlock()
|
||||
|
||||
// External edit lands after the engine last touched the file. Force the
|
||||
@@ -527,3 +661,83 @@ func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) {
|
||||
t.Fatalf("tick did not adopt the external edit: %+v", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatExternalDeletionDoesNotResurrectTasks(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := newHeartbeatEngine(nil)
|
||||
if err := engine.saveTasks([]HeartbeatTask{{ID: "deleted", Title: "old", Interval: "1h", Enabled: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := engine.readConfigSnapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine.mu.Lock()
|
||||
engine.recordConfigSnapshotLocked(snapshot)
|
||||
engine.tasks = append([]HeartbeatTask(nil), snapshot.cfg.Tasks...)
|
||||
if err := os.Remove(engine.configPath()); err != nil {
|
||||
engine.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine.adoptExternalEditsLocked()
|
||||
if len(engine.tasks) != 0 || !engine.cfgDeleted {
|
||||
engine.mu.Unlock()
|
||||
t.Fatalf("deleted config left stale tasks: tasks=%+v deleted=%v", engine.tasks, engine.cfgDeleted)
|
||||
}
|
||||
engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{"deleted": {ID: "deleted", LastRunAt: 123}})
|
||||
engine.mu.Unlock()
|
||||
if _, err := os.Stat(engine.configPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("deleted heartbeat config was recreated, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatRunCompletionObservesDeletionBeforeNextTick(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := newHeartbeatEngine(nil)
|
||||
if err := engine.saveTasks([]HeartbeatTask{{ID: "deleted", Title: "old", Interval: "1h", Enabled: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := engine.readConfigSnapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine.mu.Lock()
|
||||
engine.recordConfigSnapshotLocked(snapshot)
|
||||
engine.tasks = append([]HeartbeatTask(nil), snapshot.cfg.Tasks...)
|
||||
if err := os.Remove(engine.configPath()); err != nil {
|
||||
engine.mu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a run that finishes before the scheduler's next external-edit
|
||||
// adoption pass. The completion merge itself must observe the deletion.
|
||||
engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{"deleted": {ID: "deleted", LastRunAt: 123}})
|
||||
deleted := engine.cfgDeleted
|
||||
taskCount := len(engine.tasks)
|
||||
engine.mu.Unlock()
|
||||
|
||||
if !deleted || taskCount != 0 {
|
||||
t.Fatalf("run completion retained deleted config: tasks=%d deleted=%v", taskCount, deleted)
|
||||
}
|
||||
if _, err := os.Stat(engine.configPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("run completion recreated deleted heartbeat config, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatTaskLeaseIsCrossEngine(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
first := newHeartbeatEngine(nil)
|
||||
second := newHeartbeatEngine(nil)
|
||||
release, err := first.tryAcquireTaskLease("same-task")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := second.tryAcquireTaskLease("same-task"); !errors.Is(err, filelock.ErrHeld) {
|
||||
t.Fatalf("second task lease err=%v, want filelock.ErrHeld", err)
|
||||
}
|
||||
release()
|
||||
retry, err := second.tryAcquireTaskLease("same-task")
|
||||
if err != nil {
|
||||
t.Fatalf("task lease after release: %v", err)
|
||||
}
|
||||
retry()
|
||||
}
|
||||
@@ -139,6 +139,11 @@ type HistorySlice struct {
|
||||
EndTurn int `json:"endTurn"` // newest visible turn in the page (0 when none)
|
||||
Stale bool `json:"stale"` // cursor bound to an older session revision
|
||||
Revision int64 `json:"revision"` // session revision the page was cut from (0 when unknown)
|
||||
// RevisionKnown and Digest expose the complete canonical identity already
|
||||
// carried by cursors. They let same-path resident frontend projections be
|
||||
// invalidated after another process advances or rewrites the session.
|
||||
RevisionKnown bool `json:"revisionKnown,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
// Source names the read path that produced the page, for diagnostics:
|
||||
// "index" (cold, display-index hit), "scan" (cold, streaming rebuild or
|
||||
// legacy event-format decode), "live-index" (live controller + index), or
|
||||
@@ -181,8 +186,14 @@ func emptyHistorySlice() HistorySlice {
|
||||
return HistorySlice{Entries: []HistoryEntry{}}
|
||||
}
|
||||
|
||||
func staleHistorySlice(revision int64) HistorySlice {
|
||||
return HistorySlice{Entries: []HistoryEntry{}, Stale: true, Revision: revision}
|
||||
func staleHistorySlice(revision int64, revisionKnown bool, digest string) HistorySlice {
|
||||
return HistorySlice{
|
||||
Entries: []HistoryEntry{},
|
||||
Stale: true,
|
||||
Revision: revision,
|
||||
RevisionKnown: revisionKnown,
|
||||
Digest: digest,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHistorySliceRequest(req HistorySliceRequest) HistorySliceRequest {
|
||||
@@ -857,16 +868,18 @@ func (a *App) pageHistorySliceSource(src *historySliceSource, req HistorySliceRe
|
||||
// An undecodable cursor is treated like a request for the latest page.
|
||||
hasCursor := req.Cursor != "" && err == nil
|
||||
if hasCursor && !src.identityMatches(cursor.Revision, cursor.RevKnown, cursor.Digest) {
|
||||
return staleHistorySlice(src.revision), nil
|
||||
return staleHistorySlice(src.revision, src.revKnown, src.digest), nil
|
||||
}
|
||||
hi := src.total
|
||||
if hasCursor && cursor.Before < hi {
|
||||
hi = cursor.Before
|
||||
}
|
||||
page := HistorySlice{
|
||||
Entries: []HistoryEntry{},
|
||||
TotalTurns: src.totalTurns,
|
||||
Revision: src.revision,
|
||||
Entries: []HistoryEntry{},
|
||||
TotalTurns: src.totalTurns,
|
||||
Revision: src.revision,
|
||||
RevisionKnown: src.revKnown,
|
||||
Digest: src.digest,
|
||||
}
|
||||
if hi <= 0 || src.total == 0 {
|
||||
return page, nil
|
||||
@@ -1536,7 +1549,7 @@ func pageHistoryEventRows(src *historySliceSource, rows []HistoryMessage, req Hi
|
||||
cursor, err := decodeHistorySliceCursor(req.Cursor)
|
||||
hasCursor := req.Cursor != "" && err == nil
|
||||
if hasCursor && src.digest != cursor.Digest {
|
||||
return staleHistorySlice(0)
|
||||
return staleHistorySlice(0, false, src.digest)
|
||||
}
|
||||
hi := len(rows)
|
||||
if hasCursor && cursor.Before < hi {
|
||||
@@ -1554,7 +1567,7 @@ func pageHistoryEventRows(src *historySliceSource, rows []HistoryMessage, req Hi
|
||||
src.turns = turns
|
||||
src.totalTurns = turn
|
||||
src.total = len(rows)
|
||||
page := HistorySlice{Entries: []HistoryEntry{}, TotalTurns: turn}
|
||||
page := HistorySlice{Entries: []HistoryEntry{}, TotalTurns: turn, Digest: src.digest}
|
||||
if hi <= 0 {
|
||||
return page
|
||||
}
|
||||
|
||||
@@ -581,6 +581,9 @@ func TestHistorySliceCursorStaleOnRevisionBump(t *testing.T) {
|
||||
if !page1.HasOlder || page1.NextCursor == "" {
|
||||
t.Fatalf("page 1 HasOlder=%v cursor=%q", page1.HasOlder, page1.NextCursor)
|
||||
}
|
||||
if !page1.RevisionKnown || page1.Revision <= 0 || page1.Digest == "" {
|
||||
t.Fatalf("page 1 identity = known:%v revision:%d digest:%q, want canonical fingerprint", page1.RevisionKnown, page1.Revision, page1.Digest)
|
||||
}
|
||||
|
||||
sess.Add(historySliceUser(20, "q20"))
|
||||
sess.Add(historySliceAssistant(20, "a20"))
|
||||
@@ -594,6 +597,9 @@ func TestHistorySliceCursorStaleOnRevisionBump(t *testing.T) {
|
||||
if page2.Entries == nil || len(page2.Entries) != 0 {
|
||||
t.Fatalf("stale page entries = %v, want empty non-nil", page2.Entries)
|
||||
}
|
||||
if !page2.RevisionKnown || page2.Revision <= page1.Revision || page2.Digest == "" || page2.Digest == page1.Digest {
|
||||
t.Fatalf("stale page identity = known:%v revision:%d digest:%q, want advanced canonical fingerprint", page2.RevisionKnown, page2.Revision, page2.Digest)
|
||||
}
|
||||
encoded, _ := json.Marshal(page2)
|
||||
if !strings.Contains(string(encoded), `"entries":[]`) {
|
||||
t.Fatalf("stale page JSON must encode entries as []: %s", encoded)
|
||||
@@ -685,6 +691,10 @@ func TestHistorySliceColdTabSizeGuard(t *testing.T) {
|
||||
}
|
||||
_, path := saveHistorySliceSession(t, dir, "cold-size.jsonl", msgs)
|
||||
tab.SessionPath = path
|
||||
// Model a pre-WAL checkpoint. Once a native event log exists it is the
|
||||
// canonical transcript, so direct edits to the compatibility JSONL anchor
|
||||
// must not supersede it.
|
||||
removeHistorySliceNativeState(t, path)
|
||||
|
||||
// Append a message line directly to the .jsonl: the index TranscriptSize
|
||||
// no longer matches the file size and must be treated as stale — the page
|
||||
@@ -746,36 +756,6 @@ func TestHistorySliceColdUsesAuthoritativeEventLogTail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySliceColdDetectsSameSizeAnchorRewrite(t *testing.T) {
|
||||
app := historySliceTestApp(t)
|
||||
tab := newColdHistoryTab(t, app)
|
||||
dir := tabSessionDir(tab)
|
||||
_, path := saveHistorySliceSession(t, dir, "cold-same-size.jsonl", []provider.Message{
|
||||
historySliceUser(0, "old question"), historySliceAssistant(0, "old answer"),
|
||||
})
|
||||
tab.SessionPath = path
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Replace only equal-length content. A size-only guard would keep serving
|
||||
// the old offsets/index; digest validation must force a scan.
|
||||
rewritten := strings.Replace(string(before), "old answer", "new answer", 1)
|
||||
if len(rewritten) != len(before) {
|
||||
t.Fatalf("test fixture rewrite changed size: %d -> %d", len(before), len(rewritten))
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(rewritten), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := app.HistorySliceForTab("cold", HistorySliceRequest{Turns: 12})
|
||||
if page.Source != "scan" {
|
||||
t.Fatalf("Source = %q, want scan after same-size rewrite", page.Source)
|
||||
}
|
||||
if len(page.Entries) == 0 || page.Entries[len(page.Entries)-1].Message.Content != "new answer" {
|
||||
t.Fatalf("latest entry = %+v, want rewritten content", page.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySliceColdEmptySession(t *testing.T) {
|
||||
app := historySliceTestApp(t)
|
||||
tab := newColdHistoryTab(t, app)
|
||||
@@ -1067,7 +1047,7 @@ func TestHistorySliceArraysNeverNull(t *testing.T) {
|
||||
if !strings.Contains(string(encoded), `"entries":[]`) {
|
||||
t.Fatalf("zero HistorySlice must encode entries as []: %s", encoded)
|
||||
}
|
||||
encoded, err = json.Marshal(staleHistorySlice(3))
|
||||
encoded, err = json.Marshal(staleHistorySlice(3, true, "digest"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
func removeHistorySliceNativeState(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
for _, sidecar := range []string{store.SessionEventLog(path), store.SessionEventIndex(path), store.SessionMeta(path)} {
|
||||
if err := os.Remove(sidecar); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySliceColdLegacyDetectsSameSizeAnchorRewrite(t *testing.T) {
|
||||
app := historySliceTestApp(t)
|
||||
tab := newColdHistoryTab(t, app)
|
||||
dir := tabSessionDir(tab)
|
||||
_, path := saveHistorySliceSession(t, dir, "cold-same-size.jsonl", []provider.Message{
|
||||
historySliceUser(0, "old question"), historySliceAssistant(0, "old answer"),
|
||||
})
|
||||
tab.SessionPath = path
|
||||
// Model a pre-WAL checkpoint. Native sessions must recover from the event log
|
||||
// rather than accepting an out-of-band rewrite of the compatibility anchor.
|
||||
removeHistorySliceNativeState(t, path)
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Replace only equal-length content. A size-only guard would keep serving
|
||||
// the old offsets/index; digest validation must force a scan.
|
||||
rewritten := strings.Replace(string(before), "old answer", "new answer", 1)
|
||||
if len(rewritten) != len(before) {
|
||||
t.Fatalf("test fixture rewrite changed size: %d -> %d", len(before), len(rewritten))
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(rewritten), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := app.HistorySliceForTab("cold", HistorySliceRequest{Turns: 12})
|
||||
if page.Source != "scan" {
|
||||
t.Fatalf("Source = %q, want scan after same-size rewrite", page.Source)
|
||||
}
|
||||
if len(page.Entries) == 0 || page.Entries[len(page.Entries)-1].Message.Content != "new answer" {
|
||||
t.Fatalf("latest entry = %+v, want rewritten content", page.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySliceColdNativeRejectsSameSizeAnchorRewrite(t *testing.T) {
|
||||
app := historySliceTestApp(t)
|
||||
tab := newColdHistoryTab(t, app)
|
||||
dir := tabSessionDir(tab)
|
||||
_, path := saveHistorySliceSession(t, dir, "cold-native-same-size.jsonl", []provider.Message{
|
||||
historySliceUser(0, "old question"), historySliceAssistant(0, "old answer"),
|
||||
})
|
||||
tab.SessionPath = path
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewritten := strings.Replace(string(before), "old answer", "bad answer", 1)
|
||||
if len(rewritten) != len(before) {
|
||||
t.Fatalf("test fixture rewrite changed size: %d -> %d", len(before), len(rewritten))
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(rewritten), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page := app.HistorySliceForTab("cold", HistorySliceRequest{Turns: 12})
|
||||
if page.Source != "event-log" {
|
||||
t.Fatalf("Source = %q, want authoritative event-log recovery", page.Source)
|
||||
}
|
||||
if len(page.Entries) == 0 || page.Entries[len(page.Entries)-1].Message.Content != "old answer" {
|
||||
t.Fatalf("latest entry = %+v, want canonical WAL content", page.Entries)
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,26 @@ func TestHistoryPageFromMessagesWindowsByUserTurn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryPageWithFingerprintBindsRevisionToExactContentDigest(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
|
||||
Revision: 7,
|
||||
ContentDigest: "digest-v7",
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch metadata: %v", err)
|
||||
}
|
||||
|
||||
page := historyPageWithFingerprint(HistoryPage{Messages: []HistoryMessage{{Role: "user", Content: "hello"}}}, path, "digest-v7")
|
||||
if page.Revision != 7 || page.Digest != "digest-v7" {
|
||||
t.Fatalf("history fingerprint = revision %d digest %q, want revision 7 digest-v7", page.Revision, page.Digest)
|
||||
}
|
||||
|
||||
stale := historyPageWithFingerprint(HistoryPage{Messages: []HistoryMessage{{Role: "user", Content: "older"}}}, path, "digest-v6")
|
||||
if stale.Revision != 0 || stale.Digest != "digest-v6" {
|
||||
t.Fatalf("stale page fingerprint = revision %d digest %q, want content digest without mismatched revision", stale.Revision, stale.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryPageFromProviderMessagesWindowsVisibleUsers(t *testing.T) {
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/hook"
|
||||
)
|
||||
@@ -180,5 +181,5 @@ func writeHooksSettingsFile(path string, settings hook.Settings) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, body, 0o644)
|
||||
return fileutil.AtomicWriteFile(path, body, 0o644)
|
||||
}
|
||||
@@ -30,10 +30,13 @@ func forkCoveredRecoveryBranch(t *testing.T, dir, name string) (parentPath, bran
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
covering := agent.NewSession("")
|
||||
covering.Messages = append([]provider.Message(nil), stale.Snapshot()...)
|
||||
covering, err := agent.LoadSession(parentPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load covering parent: %v", err)
|
||||
}
|
||||
covering.Replace(append([]provider.Message(nil), stale.Snapshot()...))
|
||||
covering.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
|
||||
if err := covering.Save(parentPath); err != nil {
|
||||
if err := covering.SaveRewrite(parentPath); err != nil {
|
||||
t.Fatalf("Save covering parent: %v", err)
|
||||
}
|
||||
return parentPath, info.Path
|
||||
|
||||
@@ -50,10 +50,13 @@ func forkDesktopRecoveryBranch(t *testing.T, dir, name string) (parentPath, bran
|
||||
|
||||
func coverDesktopRecoveryParent(t *testing.T, parentPath string, branchMsgs []provider.Message) {
|
||||
t.Helper()
|
||||
parent := agent.NewSession("")
|
||||
parent.Messages = append([]provider.Message(nil), branchMsgs...)
|
||||
parent, err := agent.LoadSession(parentPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load covering recovery parent: %v", err)
|
||||
}
|
||||
parent.Replace(append([]provider.Message(nil), branchMsgs...))
|
||||
parent.Add(provider.Message{Role: provider.RoleAssistant, Content: "parent kept the recovery content"})
|
||||
if err := parent.Save(parentPath); err != nil {
|
||||
if err := parent.SaveRewrite(parentPath); err != nil {
|
||||
t.Fatalf("Save covering recovery parent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+40
-14
@@ -2171,6 +2171,8 @@ type TabMeta struct {
|
||||
TopicID string `json:"topicId"`
|
||||
TopicTitle string `json:"topicTitle"`
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
SessionRevision int64 `json:"sessionRevision,omitempty"`
|
||||
SessionDigest string `json:"sessionDigest,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
ProjectColor string `json:"projectColor,omitempty"`
|
||||
Label string `json:"label"`
|
||||
@@ -2215,6 +2217,13 @@ func enrichTabMetas(metas []TabMeta) []TabMeta {
|
||||
|
||||
func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
|
||||
runtimeView := a.sessionRuntimeViewLocked(tab)
|
||||
sessionPath := tab.currentSessionPath()
|
||||
var sessionRevision int64
|
||||
var sessionDigest string
|
||||
if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
|
||||
sessionRevision = meta.Revision
|
||||
sessionDigest = meta.ContentDigest
|
||||
}
|
||||
m := TabMeta{
|
||||
ID: tab.ID,
|
||||
Scope: tab.Scope,
|
||||
@@ -2223,7 +2232,9 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
|
||||
WorkspacePath: tab.WorkspaceRoot,
|
||||
TopicID: tab.TopicID,
|
||||
TopicTitle: a.localizedTopicTitle(tab.TopicTitle, tab.topicTitleSource),
|
||||
SessionPath: tab.currentSessionPath(),
|
||||
SessionPath: sessionPath,
|
||||
SessionRevision: sessionRevision,
|
||||
SessionDigest: sessionDigest,
|
||||
ReadOnly: tab.ReadOnly,
|
||||
Label: tab.Label,
|
||||
Ready: runtimeView.Phase == sessionRuntimeReady && tab.Ctrl != nil,
|
||||
@@ -2923,9 +2934,12 @@ func pinNewEmptySessionBranchMeta(path, scope, workspaceRoot, topicID, topicTitl
|
||||
// pinSessionBranchMeta stores the workspace scope, root, and topic on a newly
|
||||
// created session before a controller can reconcile the tab against it.
|
||||
func pinSessionBranchMeta(sessionPath, scope, workspaceRoot, topicID, topicTitle string) error {
|
||||
unlock := agent.LockSessionMetaPath(sessionPath)
|
||||
unlock, err := agent.LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
m, err := agent.EnsureBranchMeta(sessionPath)
|
||||
m, err := agent.EnsureBranchMetaLocked(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2943,7 +2957,7 @@ func pinSessionBranchMeta(sessionPath, scope, workspaceRoot, topicID, topicTitle
|
||||
m.WorkspaceRoot = workspaceRoot
|
||||
m.TopicID = topicID
|
||||
m.TopicTitle = topicTitle
|
||||
return agent.SaveBranchMetaPreserveUpdated(sessionPath, m)
|
||||
return agent.SaveBranchMetaPreserveUpdatedLocked(sessionPath, m)
|
||||
}
|
||||
|
||||
func blankTabSessionPathHasNoContent(tab *WorkspaceTab) bool {
|
||||
@@ -6710,9 +6724,12 @@ func migrateLegacySessionsIntoGlobalTopics(dir string) []string {
|
||||
// meta lock so agent-side writers (autosave revision bumps,
|
||||
// in-flight markers) can't interleave between the load and save
|
||||
// below and lose their fields.
|
||||
unlock := agent.LockSessionMetaPath(info.Path)
|
||||
unlock, lockErr := agent.LockSessionMetaPath(info.Path)
|
||||
if lockErr != nil {
|
||||
return false, lockErr
|
||||
}
|
||||
defer unlock()
|
||||
meta, err := agent.EnsureBranchMeta(info.Path)
|
||||
meta, err := agent.EnsureBranchMetaLocked(info.Path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -6725,7 +6742,7 @@ func migrateLegacySessionsIntoGlobalTopics(dir string) []string {
|
||||
meta.WorkspaceRoot = workspaceRoot
|
||||
meta.TopicID = topicID
|
||||
meta.TopicTitle = title
|
||||
return true, agent.SaveBranchMetaPreserveUpdated(info.Path, meta)
|
||||
return true, agent.SaveBranchMetaPreserveUpdatedLocked(info.Path, meta)
|
||||
}()
|
||||
if err != nil {
|
||||
deferred = true
|
||||
@@ -7071,7 +7088,10 @@ func restoreSessionTopicIndex(dir, sessionPath string) error {
|
||||
// Read-modify-write on the branch-meta sidecar: re-read and save under the
|
||||
// per-path meta lock so a concurrent save's revision bump can't land in
|
||||
// between and get rolled back by the write at the end.
|
||||
unlock := agent.LockSessionMetaPath(sessionPath)
|
||||
unlock, err := agent.LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
meta, ok, err = agent.LoadBranchMeta(sessionPath)
|
||||
if err != nil {
|
||||
@@ -7120,7 +7140,7 @@ func restoreSessionTopicIndex(dir, sessionPath string) error {
|
||||
if err := prependTopicInProjectsFile(workspaceRoot, topicID, scope == "project"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, meta); err != nil {
|
||||
if err := agent.SaveBranchMetaPreserveUpdatedLocked(sessionPath, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
invalidateTopicSessionIndexForPath(sessionPath)
|
||||
@@ -7445,14 +7465,17 @@ func (a *App) updateTopicSessionTitles(topicID, title string) []string {
|
||||
// Read-modify-write on the branch-meta sidecar: hold the per-path
|
||||
// meta lock so a concurrent save's revision bump can't land between
|
||||
// the load and save below and get rolled back by this write.
|
||||
unlock := agent.LockSessionMetaPath(match.path)
|
||||
unlock, lockErr := agent.LockSessionMetaPath(match.path)
|
||||
if lockErr != nil {
|
||||
continue
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(match.path)
|
||||
if err != nil || !ok {
|
||||
unlock()
|
||||
continue
|
||||
}
|
||||
meta.TopicTitle = title
|
||||
err = agent.SaveBranchMetaPreserveUpdated(match.path, meta)
|
||||
err = agent.SaveBranchMetaPreserveUpdatedLocked(match.path, meta)
|
||||
unlock()
|
||||
if err == nil {
|
||||
invalidateTopicSessionIndex(dir)
|
||||
@@ -8958,9 +8981,12 @@ func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
|
||||
// Read-modify-write on the branch-meta sidecar: hold the per-path meta lock
|
||||
// so agent-side writers (autosave UpdateSessionMeta, in-flight markers)
|
||||
// can't interleave and drop fields.
|
||||
unlock := agent.LockSessionMetaPath(snap.path)
|
||||
unlock, err := agent.LockSessionMetaPath(snap.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
m, err := agent.EnsureBranchMeta(snap.path)
|
||||
m, err := agent.EnsureBranchMetaLocked(snap.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -8986,7 +9012,7 @@ func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
|
||||
m.Mode = persistedTabMode(snap.mode)
|
||||
m.ToolApprovalMode = persistedToolApprovalMode(snap.toolApprovalMode)
|
||||
m.Goal = strings.TrimSpace(snap.goal)
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(snap.path, m); err != nil {
|
||||
if err := agent.SaveBranchMetaPreserveUpdatedLocked(snap.path, m); err != nil {
|
||||
return err
|
||||
}
|
||||
invalidateTopicSessionIndexForPath(snap.path)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
// The desktop is a GUI app: launched from Finder or `open`, it starts with the
|
||||
@@ -43,7 +44,7 @@ func saveWorkspace(dir string) {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(dir), 0o644)
|
||||
_ = fileutil.AtomicWriteFile(p, []byte(dir), 0o644)
|
||||
}
|
||||
|
||||
// clearWorkspace removes the active workspace pointer file so that no stale
|
||||
@@ -117,7 +118,7 @@ func rememberWorkspace(dir string) {
|
||||
return
|
||||
}
|
||||
if b, err := json.MarshalIndent(paths, "", " "); err == nil {
|
||||
_ = os.WriteFile(p, b, 0o644)
|
||||
_ = fileutil.AtomicWriteFile(p, b, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +147,7 @@ func forgetWorkspace(dir string) {
|
||||
return
|
||||
}
|
||||
if b, err := json.MarshalIndent(paths, "", " "); err == nil {
|
||||
_ = os.WriteFile(p, b, 0o644)
|
||||
_ = fileutil.AtomicWriteFile(p, b, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
// DesktopZoomFactor persists the user's WebView2 zoom factor preference across
|
||||
@@ -66,7 +67,7 @@ func (a *App) SetDesktopZoomFactor(factor float64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
return fileutil.AtomicWriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
// RestartApplication saves the zoom and restarts the whole process so the new
|
||||
|
||||
+168
-26
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
@@ -77,9 +78,20 @@ const BranchMetaCountsVersion = 1
|
||||
// has started but not yet reached TurnDone. If the process exits mid-turn, a
|
||||
// later resume can strip the partial assistant/tool tail without guessing.
|
||||
type InFlightTurnMeta struct {
|
||||
// ID makes marker cleanup compare-and-clear. Older sidecars omit it and are
|
||||
// handled by the legacy index/time recovery path.
|
||||
ID string `json:"id,omitempty"`
|
||||
StartMessageIndex int `json:"start_message_index"`
|
||||
PreserveUser bool `json:"preserve_user"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
// StartRevision and StartDigest bind the legacy array boundary to the
|
||||
// persisted transcript that existed when the turn began.
|
||||
StartRevision int64 `json:"start_revision,omitempty"`
|
||||
StartDigest string `json:"start_digest,omitempty"`
|
||||
// CommitDigest is written before the final turn snapshot. If recovery sees
|
||||
// this exact transcript on disk, the snapshot committed and only marker
|
||||
// cleanup was interrupted; no message recovery is necessary.
|
||||
CommitDigest string `json:"commit_digest,omitempty"`
|
||||
}
|
||||
|
||||
func (m BranchMeta) DefaultScope() string {
|
||||
@@ -185,10 +197,24 @@ func loadBranchMetaRetry(sessionPath string) (BranchMeta, bool, error) {
|
||||
}
|
||||
|
||||
func SaveBranchMeta(sessionPath string, m BranchMeta) error {
|
||||
return saveBranchMeta(sessionPath, m, true)
|
||||
return UpdateBranchMeta(sessionPath, true, func(current *BranchMeta) error {
|
||||
preserveBranchMetaPersistence(&m, *current)
|
||||
*current = m
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func SaveBranchMetaPreserveUpdated(sessionPath string, m BranchMeta) error {
|
||||
return UpdateBranchMeta(sessionPath, false, func(current *BranchMeta) error {
|
||||
preserveBranchMetaPersistence(&m, *current)
|
||||
*current = m
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SaveBranchMetaPreserveUpdatedLocked is for callers that already hold
|
||||
// LockSessionMetaPath for a larger read-modify-write transaction.
|
||||
func SaveBranchMetaPreserveUpdatedLocked(sessionPath string, m BranchMeta) error {
|
||||
return saveBranchMeta(sessionPath, m, false)
|
||||
}
|
||||
|
||||
@@ -260,6 +286,20 @@ func preserveBranchMetaPersistence(next *BranchMeta, existing BranchMeta) {
|
||||
}
|
||||
|
||||
func EnsureBranchMeta(sessionPath string) (BranchMeta, error) {
|
||||
var out BranchMeta
|
||||
err := UpdateBranchMeta(sessionPath, false, func(m *BranchMeta) error {
|
||||
out = *m
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// EnsureBranchMetaLocked is for callers that already hold LockSessionMetaPath.
|
||||
func EnsureBranchMetaLocked(sessionPath string) (BranchMeta, error) {
|
||||
return ensureBranchMetaUnlocked(sessionPath)
|
||||
}
|
||||
|
||||
func ensureBranchMetaUnlocked(sessionPath string) (BranchMeta, error) {
|
||||
if sessionPath == "" {
|
||||
return BranchMeta{}, fmt.Errorf("empty session path")
|
||||
}
|
||||
@@ -279,22 +319,52 @@ func EnsureBranchMeta(sessionPath string) (BranchMeta, error) {
|
||||
}
|
||||
|
||||
func TouchBranchMeta(sessionPath string) error {
|
||||
unlock := lockSessionSavePath(sessionPath)
|
||||
defer unlock()
|
||||
m, err := EnsureBranchMeta(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.UpdatedAt = time.Now().UTC()
|
||||
return saveBranchMeta(sessionPath, m, false)
|
||||
return UpdateBranchMeta(sessionPath, false, func(m *BranchMeta) error {
|
||||
m.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func MarkSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) error {
|
||||
return SetSessionInFlightTurn(sessionPath, InFlightTurnMeta{
|
||||
_, err := BeginSessionInFlightTurn(sessionPath, startMessageIndex, preserveUser)
|
||||
return err
|
||||
}
|
||||
|
||||
var inFlightTurnSequence atomic.Uint64
|
||||
|
||||
// BeginSessionInFlightTurn writes a new marker and returns the exact marker so
|
||||
// the owner can later clear only this turn. The baseline fields are learned from
|
||||
// the branch sidecar before replacing its marker.
|
||||
func BeginSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) (InFlightTurnMeta, error) {
|
||||
if sessionPath == "" {
|
||||
return InFlightTurnMeta{}, fmt.Errorf("empty session path")
|
||||
}
|
||||
// Read the baseline and install the marker under the same in-process save
|
||||
// lock. Otherwise an autosave can advance the revision between the read and
|
||||
// SetSessionInFlightTurn, leaving the marker bound to a stale baseline.
|
||||
unlock, err := LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return InFlightTurnMeta{}, err
|
||||
}
|
||||
defer unlock()
|
||||
meta, err := ensureBranchMetaUnlocked(sessionPath)
|
||||
if err != nil {
|
||||
return InFlightTurnMeta{}, err
|
||||
}
|
||||
marker := InFlightTurnMeta{
|
||||
ID: fmt.Sprintf("%s-%d-%d", SessionWriterID(), time.Now().UnixNano(), inFlightTurnSequence.Add(1)),
|
||||
StartMessageIndex: startMessageIndex,
|
||||
PreserveUser: preserveUser,
|
||||
StartedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
marker.StartRevision = meta.Revision
|
||||
marker.StartDigest = strings.TrimSpace(meta.ContentDigest)
|
||||
marker.StartMessageIndex = max(marker.StartMessageIndex, 0)
|
||||
meta.InFlightTurn = &marker
|
||||
if err := saveBranchMeta(sessionPath, meta, false); err != nil {
|
||||
return InFlightTurnMeta{}, err
|
||||
}
|
||||
return marker, nil
|
||||
}
|
||||
|
||||
// SetSessionInFlightTurn writes an existing in-flight marker verbatim. It is
|
||||
@@ -306,9 +376,12 @@ func SetSessionInFlightTurn(sessionPath string, marker InFlightTurnMeta) error {
|
||||
// The sidecar is read-modify-write; the per-path save lock keeps concurrent
|
||||
// writers (autosave's UpdateSessionMeta, listing backfill) from dropping
|
||||
// each other's fields.
|
||||
unlock := lockSessionSavePath(sessionPath)
|
||||
unlock, err := LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
m, err := EnsureBranchMeta(sessionPath)
|
||||
m, err := ensureBranchMetaUnlocked(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -317,21 +390,81 @@ func SetSessionInFlightTurn(sessionPath string, marker InFlightTurnMeta) error {
|
||||
marker.StartedAt = time.Now().UTC()
|
||||
}
|
||||
m.InFlightTurn = &marker
|
||||
return SaveBranchMetaPreserveUpdated(sessionPath, m)
|
||||
return saveBranchMeta(sessionPath, m, false)
|
||||
}
|
||||
|
||||
func ClearSessionInFlightTurn(sessionPath string) error {
|
||||
unlock := lockSessionSavePath(sessionPath)
|
||||
_, err := ClearSessionInFlightTurnIfMatch(sessionPath, InFlightTurnMeta{})
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearSessionInFlightTurnIfMatch clears a marker only when it still matches
|
||||
// expected. A non-empty ID is authoritative; legacy markers without IDs fall
|
||||
// back to the complete persisted marker shape for compatibility.
|
||||
func ClearSessionInFlightTurnIfMatch(sessionPath string, expected InFlightTurnMeta) (bool, error) {
|
||||
unlock, err := LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer unlock()
|
||||
m, ok, err := LoadBranchMeta(sessionPath)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if m.InFlightTurn == nil {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
if expected.ID != "" {
|
||||
if m.InFlightTurn.ID != expected.ID {
|
||||
return false, nil
|
||||
}
|
||||
} else if expected.StartMessageIndex != 0 || expected.PreserveUser || !expected.StartedAt.IsZero() || expected.StartRevision != 0 || expected.StartDigest != "" {
|
||||
if !sameInFlightTurn(*m.InFlightTurn, expected) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
m.InFlightTurn = nil
|
||||
return SaveBranchMetaPreserveUpdated(sessionPath, m)
|
||||
return true, saveBranchMeta(sessionPath, m, false)
|
||||
}
|
||||
|
||||
// PrepareSessionInFlightTurnCommit binds the owned marker to the exact final
|
||||
// transcript before that transcript is saved. Recovery can then distinguish a
|
||||
// crash after the save from a crash during the turn without guessing from roles
|
||||
// or array indexes.
|
||||
func PrepareSessionInFlightTurnCommit(sessionPath string, expected InFlightTurnMeta, digest string) (InFlightTurnMeta, bool, error) {
|
||||
digest = strings.TrimSpace(digest)
|
||||
if sessionPath == "" || expected.ID == "" || digest == "" {
|
||||
return InFlightTurnMeta{}, false, nil
|
||||
}
|
||||
unlock, err := LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return InFlightTurnMeta{}, false, err
|
||||
}
|
||||
defer unlock()
|
||||
m, ok, err := LoadBranchMeta(sessionPath)
|
||||
if err != nil || !ok || m.InFlightTurn == nil {
|
||||
return InFlightTurnMeta{}, false, err
|
||||
}
|
||||
if m.InFlightTurn.ID != expected.ID {
|
||||
return InFlightTurnMeta{}, false, nil
|
||||
}
|
||||
updated := *m.InFlightTurn
|
||||
updated.CommitDigest = digest
|
||||
m.InFlightTurn = &updated
|
||||
if err := saveBranchMeta(sessionPath, m, false); err != nil {
|
||||
return InFlightTurnMeta{}, false, err
|
||||
}
|
||||
return updated, true, nil
|
||||
}
|
||||
|
||||
func sameInFlightTurn(a, b InFlightTurnMeta) bool {
|
||||
return a.ID == b.ID &&
|
||||
a.StartMessageIndex == b.StartMessageIndex &&
|
||||
a.PreserveUser == b.PreserveUser &&
|
||||
a.StartedAt.Equal(b.StartedAt) &&
|
||||
a.StartRevision == b.StartRevision &&
|
||||
a.StartDigest == b.StartDigest &&
|
||||
a.CommitDigest == b.CommitDigest
|
||||
}
|
||||
|
||||
func ListBranches(dir string) ([]BranchInfo, error) {
|
||||
@@ -401,14 +534,17 @@ func RenameSession(sessionPath string, title string) error {
|
||||
// Read-modify-write on the sidecar: hold the per-path meta lock so a
|
||||
// concurrent save (recordSessionContentRevision) can't have its Revision
|
||||
// bump clobbered by a stale read-back here.
|
||||
unlock := lockSessionSavePath(sessionPath)
|
||||
unlock, err := LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
m, err := EnsureBranchMeta(sessionPath)
|
||||
m, err := ensureBranchMetaUnlocked(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.CustomTitle = strings.TrimSpace(title)
|
||||
return SaveBranchMetaPreserveUpdated(sessionPath, m)
|
||||
return saveBranchMeta(sessionPath, m, false)
|
||||
}
|
||||
|
||||
// LoadSessionModel reads the canonical provider/model ref saved beside a
|
||||
@@ -431,14 +567,17 @@ func SetBranchModelPreserveUpdated(sessionPath, model string) error {
|
||||
if sessionPath == "" {
|
||||
return fmt.Errorf("empty session path")
|
||||
}
|
||||
unlock := lockSessionSavePath(sessionPath)
|
||||
unlock, err := LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
meta, err := EnsureBranchMeta(sessionPath)
|
||||
meta, err := ensureBranchMetaUnlocked(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta.Model = strings.TrimSpace(model)
|
||||
return SaveBranchMetaPreserveUpdated(sessionPath, meta)
|
||||
return saveBranchMeta(sessionPath, meta, false)
|
||||
}
|
||||
|
||||
// UpdateSessionMeta refreshes the listing-only sidecar fields (model, preview,
|
||||
@@ -450,9 +589,12 @@ func UpdateSessionMeta(sessionPath, model, preview string, turns int, markActivi
|
||||
if sessionPath == "" {
|
||||
return fmt.Errorf("empty session path")
|
||||
}
|
||||
unlock := lockSessionSavePath(sessionPath)
|
||||
unlock, err := LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
m, err := EnsureBranchMeta(sessionPath)
|
||||
m, err := ensureBranchMetaUnlocked(sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,6 +11,88 @@ import (
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestBranchMetaCrossProcessReadModifyWrite(t *testing.T) {
|
||||
if os.Getenv("REASONIX_META_LOCK_HELPER") == "1" {
|
||||
path := os.Getenv("REASONIX_META_LOCK_PATH")
|
||||
unlock, err := LockSessionMetaPath(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := EnsureBranchMetaLocked(path)
|
||||
if err != nil {
|
||||
unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.Name = "written-by-peer"
|
||||
if err := SaveBranchMetaPreserveUpdatedLocked(path, meta); err != nil {
|
||||
unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(os.Getenv("REASONIX_META_READY"), []byte("ready"), 0o600); err != nil {
|
||||
unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(os.Getenv("REASONIX_META_RELEASE")); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
unlock()
|
||||
return
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
if err := SaveBranchMeta(path, BranchMeta{ID: "session"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := filepath.Join(dir, "ready")
|
||||
release := filepath.Join(dir, "release")
|
||||
cmd := exec.Command(os.Args[0], "-test.run", "^TestBranchMetaCrossProcessReadModifyWrite$")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"REASONIX_META_LOCK_HELPER=1",
|
||||
"REASONIX_META_LOCK_PATH="+path,
|
||||
"REASONIX_META_READY="+ready,
|
||||
"REASONIX_META_RELEASE="+release,
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cmd.Wait()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(ready); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("metadata helper did not acquire lock")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- UpdateBranchMeta(path, false, func(meta *BranchMeta) error {
|
||||
meta.CustomTitle = "written-after-peer"
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if err := os.WriteFile(release, []byte("release"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Name != "written-by-peer" || meta.CustomTitle != "written-after-peer" {
|
||||
t.Fatalf("cross-process update lost a field: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchMetaIgnoresRetiredAutoRecoveryField(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "legacy.jsonl")
|
||||
@@ -150,6 +233,9 @@ func TestSessionInFlightTurnMetaRoundTrip(t *testing.T) {
|
||||
if marked.InFlightTurn == nil {
|
||||
t.Fatal("in-flight turn marker missing")
|
||||
}
|
||||
if marked.InFlightTurn.ID == "" || marked.InFlightTurn.StartRevision == 0 || marked.InFlightTurn.StartDigest == "" {
|
||||
t.Fatalf("marker identity = %+v, want id/revision/digest", marked.InFlightTurn)
|
||||
}
|
||||
if marked.InFlightTurn.StartMessageIndex != 1 || !marked.InFlightTurn.PreserveUser {
|
||||
t.Fatalf("in-flight marker = %+v, want index=1 preserveUser=true", marked.InFlightTurn)
|
||||
}
|
||||
@@ -159,6 +245,20 @@ func TestSessionInFlightTurnMetaRoundTrip(t *testing.T) {
|
||||
if !marked.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("MarkSessionInFlightTurn updated activity time: got %v want %v", marked.UpdatedAt, updatedAt)
|
||||
}
|
||||
oldMarker := *marked.InFlightTurn
|
||||
newMarker, err := BeginSessionInFlightTurn(path, 2, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleared, err := ClearSessionInFlightTurnIfMatch(path, oldMarker); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if cleared {
|
||||
t.Fatal("stale marker unexpectedly cleared a newer marker")
|
||||
}
|
||||
current, ok, err := LoadBranchMeta(path)
|
||||
if err != nil || !ok || current.InFlightTurn == nil || current.InFlightTurn.ID != newMarker.ID {
|
||||
t.Fatalf("new marker after stale clear = %+v ok=%v err=%v", current.InFlightTurn, ok, err)
|
||||
}
|
||||
|
||||
if err := UpdateSessionMeta(path, "model-a", "preview", 1, true); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -170,12 +270,12 @@ func TestSessionInFlightTurnMetaRoundTrip(t *testing.T) {
|
||||
if refreshed.InFlightTurn == nil {
|
||||
t.Fatal("UpdateSessionMeta dropped in-flight marker")
|
||||
}
|
||||
if refreshed.InFlightTurn.StartMessageIndex != 1 || !refreshed.InFlightTurn.PreserveUser {
|
||||
t.Fatalf("refreshed in-flight marker = %+v, want index=1 preserveUser=true", refreshed.InFlightTurn)
|
||||
if refreshed.InFlightTurn.StartMessageIndex != 2 || refreshed.InFlightTurn.PreserveUser {
|
||||
t.Fatalf("refreshed in-flight marker = %+v, want index=2 preserveUser=false", refreshed.InFlightTurn)
|
||||
}
|
||||
updatedAt = refreshed.UpdatedAt
|
||||
|
||||
if err := ClearSessionInFlightTurn(path); err != nil {
|
||||
if _, err := ClearSessionInFlightTurnIfMatch(path, newMarker); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleared, ok, err := LoadBranchMeta(path)
|
||||
|
||||
+27
-21
@@ -194,7 +194,10 @@ func migrateLegacySessionsWithMarkers(srcDir, globalDest, marker, jsonlMarker st
|
||||
continue
|
||||
}
|
||||
s := &Session{Messages: msgs}
|
||||
if err := s.Save(dest); err != nil {
|
||||
if err := s.SaveIfAbsent(dest); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
}
|
||||
if eventsInfo != nil {
|
||||
@@ -460,7 +463,10 @@ func migrateSubDirectory(subDir, globalDest string, projectDir func(string) stri
|
||||
continue
|
||||
}
|
||||
s := &Session{Messages: msgs}
|
||||
if err := s.Save(dest); err != nil {
|
||||
if err := s.SaveIfAbsent(dest); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
}
|
||||
} else if isNativeSessionEventLog(SessionEventLogPath(srcPath)) {
|
||||
@@ -510,7 +516,7 @@ func saveNativeSessionCopy(src, dst string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return session.Save(dst)
|
||||
return session.SaveIfAbsent(dst)
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
@@ -622,7 +628,7 @@ func transformAndCopyJsonl(src, dst string) error {
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
if err := publishFileNoReplace(tmpPath, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
ok = true
|
||||
@@ -648,25 +654,25 @@ func dirExists(path string) bool {
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// moveFlatImport re-homes a session the flat import left in the global dir.
|
||||
// The legacy event log's mtime was stamped onto the imported file, so a match
|
||||
// identifies it; a same-named native v1+ session never matches and stays put.
|
||||
func moveFlatImport(oldPath, newPath string, srcInfo os.FileInfo) bool {
|
||||
if srcInfo == nil {
|
||||
return false
|
||||
// publishFileNoReplace atomically publishes a completed sibling temp file
|
||||
// without replacing a destination another startup/import writer created.
|
||||
// The temp and destination share a directory, so a hard link is atomic and
|
||||
// portable across the filesystems Reasonix supports.
|
||||
func publishFileNoReplace(tmp, dst string) error {
|
||||
if err := linkFileNoReplace(tmp, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(oldPath)
|
||||
if err != nil {
|
||||
return false
|
||||
return os.Remove(tmp)
|
||||
}
|
||||
|
||||
func linkFileNoReplace(src, dst string) error {
|
||||
if err := os.Link(src, dst); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return os.ErrExist
|
||||
}
|
||||
return err
|
||||
}
|
||||
d := info.ModTime().Sub(srcInfo.ModTime())
|
||||
if d < -2*time.Second || d > 2*time.Second {
|
||||
return false
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
||||
return false
|
||||
}
|
||||
return os.Rename(oldPath, newPath) == nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordImportedTitle stores the legacy summary as the session's display title
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// moveFlatImport re-homes a session left in the global directory by a flat
|
||||
// import. An mtime match distinguishes it from a same-named native session.
|
||||
func moveFlatImport(oldPath, newPath string, srcInfo os.FileInfo) bool {
|
||||
if srcInfo == nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(oldPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
d := info.ModTime().Sub(srcInfo.ModTime())
|
||||
if d < -2*time.Second || d > 2*time.Second {
|
||||
return false
|
||||
}
|
||||
if moved, decided := moveNativeFlatImport(oldPath, newPath); decided {
|
||||
return moved
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := linkFileNoReplace(oldPath, newPath); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return false
|
||||
}
|
||||
if err := transformAndCopyJsonl(oldPath, newPath); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := os.Remove(oldPath); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// A native flat import must be re-homed from its WAL-backed view, not its
|
||||
// possibly older compatibility checkpoint.
|
||||
func moveNativeFlatImport(oldPath, newPath string) (moved, decided bool) {
|
||||
unlockOld := lockSessionSavePath(oldPath)
|
||||
defer unlockOld()
|
||||
probe, err := probeSessionEventLog(oldPath)
|
||||
if err != nil || !probe.native || probe.size == 0 {
|
||||
return false, false
|
||||
}
|
||||
oldFileUnlock, err := lockSessionFile(oldPath)
|
||||
if err != nil {
|
||||
return false, true
|
||||
}
|
||||
defer oldFileUnlock()
|
||||
|
||||
unlockNew := lockSessionSavePath(newPath)
|
||||
defer unlockNew()
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
||||
return false, true
|
||||
}
|
||||
newFileUnlock, err := lockSessionFile(newPath)
|
||||
if err != nil {
|
||||
return false, true
|
||||
}
|
||||
defer newFileUnlock()
|
||||
if sessionArtifactExists(newPath) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
session, err := loadSessionUnlocked(oldPath)
|
||||
if err != nil {
|
||||
return false, true
|
||||
}
|
||||
if err := session.saveLocked(newPath, sessionSaveSnapshot); err != nil {
|
||||
return false, true
|
||||
}
|
||||
if meta, ok, metaErr := LoadBranchMeta(oldPath); metaErr != nil {
|
||||
return false, true
|
||||
} else if ok {
|
||||
meta.ID = BranchID(newPath)
|
||||
if err := SaveBranchMeta(newPath, meta); err != nil {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
for _, artifact := range store.SessionSidecarFiles(oldPath) {
|
||||
if err := os.Remove(artifact); err != nil && !os.IsNotExist(err) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
|
||||
return false, true
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
func TestMigrateLegacySessionsRehomesNativeWALAheadOfCheckpoint(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
global := t.TempDir()
|
||||
workspace := t.TempDir()
|
||||
projRoot := t.TempDir()
|
||||
router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
|
||||
srcLog := filepath.Join(src, "chat-1.events.jsonl")
|
||||
if err := os.WriteFile(srcLog, []byte(legacyEventLog), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeLegacyMeta(t, src, "chat-1", workspace, "native flat import")
|
||||
|
||||
flat := filepath.Join(global, "chat-1.jsonl")
|
||||
base := NewSession("system prompt")
|
||||
base.Add(provider.Message{Role: provider.RoleUser, Content: "checkpoint"})
|
||||
if err := base.Save(flat); err != nil {
|
||||
t.Fatalf("seed native flat session: %v", err)
|
||||
}
|
||||
loaded, err := LoadSession(flat)
|
||||
if err != nil {
|
||||
t.Fatalf("load native flat session: %v", err)
|
||||
}
|
||||
loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "WAL tail survives migration"})
|
||||
// Normal append saves make the WAL authoritative before the compatibility
|
||||
// checkpoint catches up. Migration must copy that WAL-backed view.
|
||||
if err := loaded.SaveSnapshot(flat); err != nil {
|
||||
t.Fatalf("append native flat session: %v", err)
|
||||
}
|
||||
info, err := os.Stat(srcLog)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy source log: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(flat, info.ModTime(), info.ModTime()); err != nil {
|
||||
t.Fatalf("align flat import mtime: %v", err)
|
||||
}
|
||||
|
||||
n, err := MigrateLegacySessions(src, global, router)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("migrate native flat session: n=%d err=%v, want 1 nil", n, err)
|
||||
}
|
||||
dest := filepath.Join(projRoot, filepath.Base(workspace), "sessions", "chat-1.jsonl")
|
||||
migrated, err := LoadSession(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("load re-homed native session: %v", err)
|
||||
}
|
||||
if got := migrated.Messages[len(migrated.Messages)-1].Content; got != "WAL tail survives migration" {
|
||||
t.Fatalf("re-homed session lost WAL tail: got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(flat); !os.IsNotExist(err) {
|
||||
t.Errorf("native flat checkpoint should be retired: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventLog(flat)); !os.IsNotExist(err) {
|
||||
t.Errorf("native flat WAL should be retired: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -260,6 +261,49 @@ func TestMigrateLegacySessionsSkipsAlreadyImported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSaveIfAbsentNeverReplacesExistingTranscript(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "imported.jsonl")
|
||||
first := NewSession("sys")
|
||||
first.Add(provider.Message{Role: provider.RoleUser, Content: "first import"})
|
||||
if err := first.SaveIfAbsent(dest); err != nil {
|
||||
t.Fatalf("first SaveIfAbsent: %v", err)
|
||||
}
|
||||
second := NewSession("sys")
|
||||
second.Add(provider.Message{Role: provider.RoleUser, Content: "stale import"})
|
||||
if err := second.SaveIfAbsent(dest); !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("second SaveIfAbsent = %v, want os.ErrExist", err)
|
||||
}
|
||||
loaded, err := LoadSession(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("load preserved destination: %v", err)
|
||||
}
|
||||
if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "first import" {
|
||||
t.Fatalf("destination was replaced: %+v", loaded.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyEventLogCanShareDestinationDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "chat-1.events.jsonl")
|
||||
if err := os.WriteFile(legacy, []byte(legacyEventLog), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := MigrateLegacySessions(dir, dir, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("imported %d sessions, want 1", n)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "chat-1.jsonl")); err != nil {
|
||||
t.Fatalf("migrated destination missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(legacy); err != nil {
|
||||
t.Fatalf("legacy source must remain untouched: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacySessionsNoSrcIsNoop(t *testing.T) {
|
||||
n, err := MigrateLegacySessions(filepath.Join(t.TempDir(), "nope"), t.TempDir(), nil)
|
||||
if err != nil || n != 0 {
|
||||
|
||||
@@ -40,10 +40,13 @@ func forkRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath
|
||||
// fork preserved" shape that makes the fork redundant.
|
||||
func coverBranchInParent(t *testing.T, parentPath string, branchMsgs []provider.Message) {
|
||||
t.Helper()
|
||||
merged := NewSession("")
|
||||
merged.Messages = append([]provider.Message(nil), branchMsgs...)
|
||||
merged, err := LoadSession(parentPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load covering parent: %v", err)
|
||||
}
|
||||
merged.Replace(append([]provider.Message(nil), branchMsgs...))
|
||||
merged.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
|
||||
if err := merged.Save(parentPath); err != nil {
|
||||
if err := merged.SaveRewrite(parentPath); err != nil {
|
||||
t.Fatalf("Save covering parent: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -114,9 +117,9 @@ func TestReclaimableRecoveryBranchesRespectsGraceLeaseAndMissingParent(t *testin
|
||||
}
|
||||
|
||||
// Parent gone: content is no longer covered anywhere — kept.
|
||||
for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
|
||||
if err := os.Remove(parentPath + suffix); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("remove parent artifact %s: %v", suffix, err)
|
||||
for _, artifact := range append([]string{parentPath}, store.SessionSidecarFiles(parentPath)...) {
|
||||
if err := os.Remove(artifact); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("remove parent artifact %s: %v", artifact, err)
|
||||
}
|
||||
}
|
||||
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
|
||||
@@ -165,9 +168,9 @@ func TestRecoveryBranchCoveredByParentReadsActualContent(t *testing.T) {
|
||||
if !RecoveryBranchCoveredByParent(missingBranch, dir) {
|
||||
t.Fatal("missing-parent fixture was not covered before parent removal")
|
||||
}
|
||||
for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
|
||||
if err := os.Remove(missingParent + suffix); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("remove parent artifact %s: %v", suffix, err)
|
||||
for _, artifact := range append([]string{missingParent}, store.SessionSidecarFiles(missingParent)...) {
|
||||
if err := os.Remove(artifact); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("remove parent artifact %s: %v", artifact, err)
|
||||
}
|
||||
}
|
||||
if RecoveryBranchCoveredByParent(missingBranch, dir) {
|
||||
|
||||
+241
-119
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/filelock"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/provider"
|
||||
@@ -54,6 +56,7 @@ var (
|
||||
// Package vars let focused tests shorten the wait without slowing the suite.
|
||||
sessionFileLockWait = 5 * time.Second
|
||||
sessionFileLockPollInterval = 25 * time.Millisecond
|
||||
sessionMetaLockWait = 5 * time.Second
|
||||
ErrSessionSnapshotConflict = errors.New("session snapshot conflicts with newer transcript")
|
||||
ErrSessionRecoveryNotNeeded = errors.New("session recovery not needed")
|
||||
// ErrSessionFileLockHeld reports that another process kept the
|
||||
@@ -101,9 +104,9 @@ type sessionPersistState struct {
|
||||
type sessionSaveMode int
|
||||
|
||||
const (
|
||||
sessionSaveForce sessionSaveMode = iota
|
||||
sessionSaveSnapshot
|
||||
sessionSaveSnapshot sessionSaveMode = iota
|
||||
sessionSaveRewrite
|
||||
sessionSaveRewriteCompact
|
||||
)
|
||||
|
||||
type snapshotWriteDecision struct {
|
||||
@@ -182,11 +185,14 @@ type RecoveryBranchInfo struct {
|
||||
Turns int
|
||||
}
|
||||
|
||||
// Save persists the session using an append-only event log beside path. The
|
||||
// event log is authoritative once present; the .jsonl file is also maintained
|
||||
// as a compatibility checkpoint and random-read model for history paging.
|
||||
// Save persists the session using the normal CAS-protected snapshot protocol.
|
||||
// It is kept as the convenient default for callers that do not need to spell
|
||||
// out rewrite intent; it must never provide a force-overwrite escape hatch.
|
||||
// The .jsonl file remains as a compatibility checkpoint and discovery anchor;
|
||||
// the append-only event log is authoritative once present, while the .jsonl
|
||||
// checkpoint also serves as the random-read model for history paging.
|
||||
func (s *Session) Save(path string) error {
|
||||
return s.save(path, sessionSaveForce)
|
||||
return s.save(path, sessionSaveSnapshot)
|
||||
}
|
||||
|
||||
// SaveSnapshot writes a normal autosave/snapshot only when doing so cannot hide
|
||||
@@ -203,11 +209,40 @@ func (s *Session) SaveRewrite(path string) error {
|
||||
return s.save(path, sessionSaveRewrite)
|
||||
}
|
||||
|
||||
// SaveRewriteCompact performs a CAS-protected rewrite and folds the event log
|
||||
// to one replace record. It is for destructive maintenance such as redaction:
|
||||
// retaining old WAL records would keep the removed bytes recoverable on disk.
|
||||
func (s *Session) SaveRewriteCompact(path string) error {
|
||||
return s.save(path, sessionSaveRewriteCompact)
|
||||
}
|
||||
|
||||
// SaveIfAbsent persists a newly imported or copied session without ever
|
||||
// replacing a destination another runtime created after the caller's scan.
|
||||
// The existence check and the full write share the same in-process and
|
||||
// cross-process save locks, so migration cannot regress a newer destination
|
||||
// during startup or an overlapping import.
|
||||
func (s *Session) SaveIfAbsent(path string) error {
|
||||
return s.withSessionSaveLocks(path, func() error {
|
||||
if sessionArtifactExists(path) {
|
||||
return os.ErrExist
|
||||
}
|
||||
return s.saveLocked(path, sessionSaveSnapshot)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty session path")
|
||||
}
|
||||
baseRevision := int64(0)
|
||||
return s.withSessionSaveLocks(path, func() error {
|
||||
return s.saveLocked(path, mode)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) withSessionSaveLocks(path string, fn func() error) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("empty session path")
|
||||
}
|
||||
unlock := lockSessionSavePath(path)
|
||||
defer unlock()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
@@ -218,6 +253,38 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
return fmt.Errorf("lock session file: %w", err)
|
||||
}
|
||||
defer unlockFile()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func sessionArtifactExists(path string) bool {
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
return true
|
||||
}
|
||||
for _, artifact := range store.SessionSidecarFiles(path) {
|
||||
// A legacy v0 event transcript can share the native
|
||||
// `<id>.events.jsonl` name with the destination being imported. It is
|
||||
// intentionally left in place, and must not make SaveIfAbsent believe
|
||||
// that the v1 `<id>.jsonl` destination already exists. Native event logs
|
||||
// remain owned artifacts and still protect the destination from a second
|
||||
// writer.
|
||||
if artifact == store.SessionEventLog(path) {
|
||||
probe, probeErr := probeSessionEventLog(path)
|
||||
if probeErr != nil {
|
||||
return true
|
||||
}
|
||||
if !probe.native {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(artifact); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
|
||||
baseRevision := int64(0)
|
||||
observeUnleasedSessionWrite(path, mode)
|
||||
if mode == sessionSaveSnapshot && s.snapshotUpToDate(path) {
|
||||
// Nothing changed since the last successful save to this exact path:
|
||||
@@ -259,113 +326,107 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
}
|
||||
}
|
||||
repairLog := false
|
||||
if mode != sessionSaveForce {
|
||||
decision, err := s.checkSnapshotWrite(path, msgs, digest, version, mode == sessionSaveRewrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if decision.upToDate {
|
||||
// Disk already holds exactly this transcript. Rewriting it would only
|
||||
// bump the revision, invalidating the persistence baseline of every
|
||||
// other runtime resumed on this file and turning their next
|
||||
// legitimate save into a stale-runtime conflict. Skip the write and
|
||||
// adopt the current on-disk revision as this session's baseline.
|
||||
if decision.ledgerStale {
|
||||
// ...unless the ledger never learned about this transcript: a
|
||||
// prior save landed its bytes and then failed to record the
|
||||
// revision. Same-content retries are exactly the "later save"
|
||||
// that failure deferred to, and skipping here would strand the
|
||||
// ledger on the old digest forever. Record now, reproducing
|
||||
// the state the interrupted save would have left.
|
||||
revision, err := recordSessionContentRevision(path, digest, decision.revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
displayModelCurrent := true
|
||||
if err := writeSessionMessages(path, msgs); err != nil {
|
||||
displayModelCurrent = false
|
||||
slog.Warn("session: keeping save after display read-model repair failure", "path", path, "err", err)
|
||||
}
|
||||
if probe.native {
|
||||
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
|
||||
// See the append path below: index loss must not fail a
|
||||
// save whose transcript and revision already landed.
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
if displayModelCurrent {
|
||||
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, -1); err != nil {
|
||||
// Warn-only like the event index above: the display index
|
||||
// is a pure derived sidecar and must never fail a save.
|
||||
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
if decision.appendOnly && probe.native {
|
||||
logSize := sessionEventLogSize(path)
|
||||
displayModelCurrent := false
|
||||
switch {
|
||||
case logSize == 0:
|
||||
if err := appendSessionReplaceEvent(path, msgs, digest, decision.revision, "snapshot"); err != nil {
|
||||
return err
|
||||
}
|
||||
displayModelCurrent, err = appendSessionDisplayReadModel(path, msgs, decision.appendFrom, decision.revision)
|
||||
case sessionEventLogOversized(logSize, contentBytes):
|
||||
// Fold history into one replace event and refresh the random-read
|
||||
// model atomically. Normal appends keep it current below too.
|
||||
if err := compactSessionEventLog(path, msgs, digest, decision.revision, "compact"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSessionMessages(path, msgs); err != nil {
|
||||
return err
|
||||
}
|
||||
displayModelCurrent = true
|
||||
default:
|
||||
if err := appendSessionAppendEvent(path, decision.appendFrom, msgs[decision.appendFrom:], digest, decision.revision); err != nil {
|
||||
return err
|
||||
}
|
||||
displayModelCurrent, err = appendSessionDisplayReadModel(path, msgs, decision.appendFrom, decision.revision)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("session: keeping save after display read-model append failure", "path", path, "err", err)
|
||||
}
|
||||
ownedRewrite := mode == sessionSaveRewrite || mode == sessionSaveRewriteCompact
|
||||
decision, err := s.checkSnapshotWrite(path, msgs, digest, version, ownedRewrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if decision.upToDate && mode != sessionSaveRewriteCompact {
|
||||
// Disk already holds exactly this transcript. Rewriting it would only
|
||||
// bump the revision, invalidating the persistence baseline of every
|
||||
// other runtime resumed on this file and turning their next
|
||||
// legitimate save into a stale-runtime conflict. Skip the write and
|
||||
// adopt the current on-disk revision as this session's baseline.
|
||||
if decision.ledgerStale {
|
||||
// ...unless the ledger never learned about this transcript: a
|
||||
// prior save landed its bytes and then failed to record the
|
||||
// revision. Same-content retries are exactly the "later save"
|
||||
// that failure deferred to, and skipping here would strand the
|
||||
// ledger on the old digest forever. Record now, reproducing
|
||||
// the state the interrupted save would have left.
|
||||
revision, err := recordSessionContentRevision(path, digest, decision.revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
|
||||
// The event index is only a listing accelerator; the transcript
|
||||
// and its revision are already durable above. Failing the save
|
||||
// here would skip markPersisted and leave the in-memory baseline
|
||||
// behind the disk state it just wrote, misreading the next save
|
||||
// as a stale-runtime conflict.
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
displayModelCurrent := true
|
||||
if err := writeSessionMessages(path, msgs); err != nil {
|
||||
displayModelCurrent = false
|
||||
slog.Warn("session: keeping save after display read-model repair failure", "path", path, "err", err)
|
||||
}
|
||||
if probe.native {
|
||||
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
|
||||
// See the append path below: index loss must not fail a
|
||||
// save whose transcript and revision already landed.
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
if displayModelCurrent {
|
||||
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, decision.appendFrom); err != nil {
|
||||
// Same warn-only rule as the event index; the append decision
|
||||
// boundary lets the refresh extend the previous index instead
|
||||
// of re-encoding the whole transcript.
|
||||
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, -1); err != nil {
|
||||
// The display index is a derived sidecar; transcript durability
|
||||
// must not depend on rebuilding it successfully.
|
||||
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
baseRevision = decision.revision
|
||||
repairLog = decision.repairLog
|
||||
} else if revision, _, err := sessionContentRevision(path); err != nil {
|
||||
return err
|
||||
} else {
|
||||
baseRevision = revision
|
||||
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
// Full-rewrite path: intentional history rewrites, damage repairs, and
|
||||
// force saves. The event log mutates first so a crash between the two
|
||||
if decision.appendOnly && probe.native && mode != sessionSaveRewriteCompact {
|
||||
logSize := sessionEventLogSize(path)
|
||||
displayModelCurrent := false
|
||||
switch {
|
||||
case logSize == 0:
|
||||
if err := appendSessionReplaceEvent(path, msgs, digest, decision.revision, "snapshot"); err != nil {
|
||||
return err
|
||||
}
|
||||
displayModelCurrent, err = appendSessionDisplayReadModel(path, msgs, decision.appendFrom, decision.revision)
|
||||
case sessionEventLogOversized(logSize, contentBytes):
|
||||
// Fold history into one replace event and refresh the random-read
|
||||
// model atomically. Normal appends keep it current below too.
|
||||
if err := compactSessionEventLog(path, msgs, digest, decision.revision, "compact"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSessionMessages(path, msgs); err != nil {
|
||||
return err
|
||||
}
|
||||
displayModelCurrent = true
|
||||
default:
|
||||
if err := appendSessionAppendEvent(path, decision.appendFrom, msgs[decision.appendFrom:], digest, decision.revision); err != nil {
|
||||
return err
|
||||
}
|
||||
displayModelCurrent, err = appendSessionDisplayReadModel(path, msgs, decision.appendFrom, decision.revision)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("session: keeping save after display read-model append failure", "path", path, "err", err)
|
||||
}
|
||||
revision, err := recordSessionContentRevision(path, digest, decision.revision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
|
||||
// The event index is only a listing accelerator; the transcript
|
||||
// and its revision are already durable above. Failing the save
|
||||
// here would skip markPersisted and leave the in-memory baseline
|
||||
// behind the disk state it just wrote, misreading the next save
|
||||
// as a stale-runtime conflict.
|
||||
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
|
||||
}
|
||||
if displayModelCurrent {
|
||||
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, decision.appendFrom); err != nil {
|
||||
// The append boundary lets the refresh extend the previous index
|
||||
// instead of re-encoding the whole transcript.
|
||||
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
s.markPersisted(path, digest, version, revision, rewriteVersion)
|
||||
return nil
|
||||
}
|
||||
baseRevision = decision.revision
|
||||
repairLog = decision.repairLog
|
||||
// Full-rewrite path: new snapshots, intentional history rewrites, and
|
||||
// damage repairs. The event log mutates first so a crash between the two
|
||||
// writes leaves the newer transcript authoritative; the anchor rewrite
|
||||
// keeps the compatibility .jsonl fresh for direct readers.
|
||||
reason := "save"
|
||||
@@ -374,6 +435,8 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
reason = "snapshot"
|
||||
case sessionSaveRewrite:
|
||||
reason = "rewrite"
|
||||
case sessionSaveRewriteCompact:
|
||||
reason = "rewrite-compact"
|
||||
}
|
||||
if repairLog {
|
||||
reason = "repair"
|
||||
@@ -383,15 +446,11 @@ func (s *Session) save(path string, mode sessionSaveMode) error {
|
||||
case !probe.native:
|
||||
// A foreign file (legacy import leftover) squats the native log path.
|
||||
// Never write into or over it — the session stays checkpoint-only.
|
||||
case mode == sessionSaveForce:
|
||||
// Force saves are one-shot copies (subagents, guardian, migrations,
|
||||
// forks): they never bootstrap an event log, and fold an existing one
|
||||
// into a single replace event so the log cannot disagree with the
|
||||
// anchor.
|
||||
if logSize > 0 {
|
||||
if err := compactSessionEventLog(path, msgs, digest, baseRevision, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
case mode == sessionSaveRewriteCompact:
|
||||
// Maintenance rewrites compact even a short log so redacted or otherwise
|
||||
// removed bytes do not remain recoverable in historical WAL records.
|
||||
if err := compactSessionEventLog(path, msgs, digest, baseRevision, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
case repairLog, sessionEventLogOversized(logSize, contentBytes):
|
||||
if err := compactSessionEventLog(path, msgs, digest, baseRevision, reason); err != nil {
|
||||
@@ -638,6 +697,14 @@ func (s *Session) SaveShutdownRecoveryBranch(opts RecoveryBranchOptions) (Recove
|
||||
return s.saveRecoveryBranch(opts, true)
|
||||
}
|
||||
|
||||
// SaveConflictRecoveryBranch persists an isolated copy when the ordinary
|
||||
// recovery chain has reached its depth cap. It deliberately uses a writer-
|
||||
// specific path; the caller must never force an older in-memory snapshot back
|
||||
// onto the contested canonical branch just to stop creating nested branches.
|
||||
func (s *Session) SaveConflictRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranchInfo, error) {
|
||||
return s.saveRecoveryBranch(opts, true)
|
||||
}
|
||||
|
||||
func (s *Session) saveRecoveryBranch(opts RecoveryBranchOptions, shutdown bool) (RecoveryBranchInfo, error) {
|
||||
originalPath := strings.TrimSpace(opts.OriginalPath)
|
||||
if originalPath == "" {
|
||||
@@ -699,7 +766,8 @@ func (s *Session) saveRecoveryBranch(opts RecoveryBranchOptions, shutdown bool)
|
||||
|
||||
// Refuse to deepen a runaway chain: forking FROM a branch that is already
|
||||
// at the depth cap only multiplies recovery files (#5993 reached 8 nested
|
||||
// levels). The caller falls back to force-writing the branch it owns.
|
||||
// levels). The caller preserves the stale transcript in a writer-specific
|
||||
// isolated branch instead of replacing the contested canonical branch.
|
||||
parentDepth := 0
|
||||
if parentMeta, ok, metaErr := LoadBranchMeta(originalPath); metaErr == nil && ok && parentMeta.Recovered {
|
||||
parentDepth = parentMeta.RecoveryDepth
|
||||
@@ -1099,6 +1167,16 @@ func sessionContentRevision(path string) (int64, string, error) {
|
||||
}
|
||||
|
||||
func recordSessionContentRevision(path string, digest [sha256.Size]byte, baseRevision int64) (int64, error) {
|
||||
// Revision allocation is a read-modify-write transaction. Holding only the
|
||||
// final SaveBranchMeta lock would still let another writer replace the
|
||||
// sidecar between our read and write, so keep the ledger lock across the
|
||||
// increment and read-back as well.
|
||||
unlock, err := LockSessionMetaPath(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
meta, ok, err := loadBranchMetaRetry(path)
|
||||
if err != nil {
|
||||
// Fail the save instead of rebuilding the ledger from a bad read: the
|
||||
@@ -1117,7 +1195,7 @@ func recordSessionContentRevision(path string, digest [sha256.Size]byte, baseRev
|
||||
meta.Revision++
|
||||
meta.ContentDigest = digestString(digest)
|
||||
meta.WriterID = SessionWriterID()
|
||||
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
||||
if err := saveBranchMeta(path, meta, false); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stored, ok, err := loadBranchMetaRetry(path)
|
||||
@@ -1337,12 +1415,53 @@ func lockSessionFile(path string) (func(), error) {
|
||||
}
|
||||
}
|
||||
|
||||
// LockSessionMetaPath serializes a read-modify-write cycle on a session's
|
||||
// sidecar metadata with every other writer in this process (Save, the
|
||||
// UpdateSessionMeta family). Callers outside this package that load, mutate,
|
||||
// and re-save branch meta must hold it for the whole cycle.
|
||||
func LockSessionMetaPath(path string) func() {
|
||||
return lockSessionSavePath(path)
|
||||
// LockSessionMetaPath serializes a complete branch-meta read-modify-write
|
||||
// cycle with both goroutines in this process and other Reasonix processes.
|
||||
// Callers must hold it from the first read through the final replace.
|
||||
func LockSessionMetaPath(path string) (func(), error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, fmt.Errorf("empty session path")
|
||||
}
|
||||
canonical := canonicalSessionSavePath(path)
|
||||
if err := os.MkdirAll(filepath.Dir(canonical), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create session metadata dir: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionMetaLockWait)
|
||||
releaseFile, err := filelock.Acquire(ctx, sessionMetaLockPath(canonical))
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock session metadata: %w", err)
|
||||
}
|
||||
return func() {
|
||||
releaseFile()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sessionMetaLockPath(path string) string {
|
||||
canonical := canonicalSessionSavePath(path)
|
||||
digest := sha256.Sum256([]byte(canonical))
|
||||
return filepath.Join(filepath.Dir(canonical), "."+hex.EncodeToString(digest[:8])+".meta.lock")
|
||||
}
|
||||
|
||||
// UpdateBranchMeta is the owner-level metadata API. The callback runs while
|
||||
// the cross-process metadata lock is held, so callers cannot accidentally
|
||||
// load a stale sidecar and overwrite fields written by another runtime.
|
||||
func UpdateBranchMeta(path string, touchUpdated bool, update func(*BranchMeta) error) error {
|
||||
unlock, err := LockSessionMetaPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
m, err := ensureBranchMetaUnlocked(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if update != nil {
|
||||
if err := update(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return saveBranchMeta(path, m, touchUpdated)
|
||||
}
|
||||
|
||||
func canonicalSessionSavePath(path string) string {
|
||||
@@ -1544,7 +1663,10 @@ func MarkCleanupPending(sessionPath, operation string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
// The marker controls session visibility during delayed cleanup. Publish it
|
||||
// atomically so a crash cannot leave malformed JSON that hides the session
|
||||
// and blocks reconciliation on the next startup.
|
||||
return fileutil.AtomicWriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
// ClearCleanupPending removes a delayed-cleanup marker after physical cleanup.
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
@@ -394,6 +396,32 @@ func (s *Session) NeedsRewriteSave() bool {
|
||||
return s.rewriteVersion > s.persistedRewriteVersion
|
||||
}
|
||||
|
||||
// HasUnsavedChanges reports whether the in-memory transcript contains storage
|
||||
// changes that have not been durably recorded at path. It is intentionally
|
||||
// conservative when no verified baseline exists: an idle controller must not
|
||||
// replace an in-memory conversation with a possibly older disk copy after a
|
||||
// bounded lock failure or an interrupted save.
|
||||
func (s *Session) HasUnsavedChanges(path string) bool {
|
||||
if s == nil || strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
msgs, _, rewriteVersion := s.snapshotWithVersion()
|
||||
digest, err := digestSessionMessages(msgs)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
key := canonicalSessionSavePath(path)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if !s.persisted.ok || s.persisted.path != key {
|
||||
return true
|
||||
}
|
||||
if s.normalizedDirty || s.eventLogDamaged || rewriteVersion > s.persistedRewriteVersion {
|
||||
return true
|
||||
}
|
||||
return !bytes.Equal(digest[:], s.persisted.digest[:])
|
||||
}
|
||||
|
||||
// IncrementRewrite bumps the rewrite version by 1.
|
||||
func (s *Session) IncrementRewrite() {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -10,6 +10,27 @@ import (
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// ContentDigest returns the canonical digest used by the session WAL and
|
||||
// revision ledger for the current in-memory transcript.
|
||||
func (s *Session) ContentDigest() (string, error) {
|
||||
if s == nil {
|
||||
return "", fmt.Errorf("nil session")
|
||||
}
|
||||
return ContentDigestForMessages(s.Snapshot())
|
||||
}
|
||||
|
||||
// ContentDigestForMessages returns the canonical transcript digest for an
|
||||
// immutable message snapshot. Frontends use it to bind a rendered history page
|
||||
// to the exact content it contains instead of sampling a sidecar revision that
|
||||
// may have advanced before or after the page was built.
|
||||
func ContentDigestForMessages(msgs []provider.Message) (string, error) {
|
||||
digest, err := digestSessionMessages(msgs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return digestString(digest), nil
|
||||
}
|
||||
|
||||
// SessionsShareContent reports whether two saved sessions decode to the same
|
||||
// transcript. It replaces byte-comparing the .jsonl checkpoints, which stopped
|
||||
// implying transcript equality once the event log became authoritative: two
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
|
||||
// Crash-consistency model suite: a crash is injected at every durable
|
||||
// boundary and recovery must never lose a durable descendant, pick sides
|
||||
// silently, fabricate a chimera, or change on a second recovery.
|
||||
|
||||
type crashSentinel struct{ n int }
|
||||
|
||||
type durabilityRun struct {
|
||||
t *testing.T
|
||||
dir string
|
||||
path string
|
||||
}
|
||||
|
||||
func newDurabilityRun(t *testing.T) *durabilityRun {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return &durabilityRun{t: t, dir: dir, path: filepath.Join(dir, "session.jsonl")}
|
||||
}
|
||||
|
||||
func (d *durabilityRun) turn(i int) []provider.Message {
|
||||
return []provider.Message{
|
||||
{Role: provider.RoleUser, Content: fmt.Sprintf("ask %d", i)},
|
||||
{Role: provider.RoleAssistant, Content: fmt.Sprintf("answer %d", i)},
|
||||
}
|
||||
}
|
||||
|
||||
// countBoundaries dry-runs fn with a counting hook and returns the ordered op
|
||||
// names of every durable boundary it crossed.
|
||||
func (d *durabilityRun) countBoundaries(fn func()) []string {
|
||||
var ops []string
|
||||
fileutil.CrashPoint = func(op, path string) {
|
||||
if strings.HasPrefix(path, d.dir) {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
}
|
||||
defer func() { fileutil.CrashPoint = nil }()
|
||||
fn()
|
||||
return ops
|
||||
}
|
||||
|
||||
// crashAt injects a panic at the nth durable boundary under the run's dir and
|
||||
// reports whether fn actually crashed there.
|
||||
func (d *durabilityRun) crashAt(n int, fn func()) (crashed bool) {
|
||||
count := 0
|
||||
fileutil.CrashPoint = func(op, path string) {
|
||||
if !strings.HasPrefix(path, d.dir) {
|
||||
return
|
||||
}
|
||||
count++
|
||||
if count == n {
|
||||
panic(crashSentinel{n})
|
||||
}
|
||||
}
|
||||
defer func() { fileutil.CrashPoint = nil }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if _, ok := r.(crashSentinel); !ok {
|
||||
panic(r)
|
||||
}
|
||||
crashed = true
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *durabilityRun) countRecoveryFiles() int {
|
||||
entries, _ := os.ReadDir(d.dir)
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), "recovery") && strings.HasSuffix(e.Name(), ".jsonl") &&
|
||||
!strings.HasSuffix(e.Name(), ".events.jsonl") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func mustDigest(t *testing.T, msgs []provider.Message) string {
|
||||
t.Helper()
|
||||
digest, err := digestSessionMessages(msgs)
|
||||
if err != nil {
|
||||
t.Fatalf("digest: %v", err)
|
||||
}
|
||||
return digestString(digest)
|
||||
}
|
||||
|
||||
// recoverAndCheck loads the session twice (I4) and verifies the recovered
|
||||
// transcript sits between lastSaved and pending in prefix order for appends
|
||||
// (I1: no rollback below the durable floor; I3: never a chimera beyond
|
||||
// pending), or equals one of the two endpoints for rewrites (I2/I3).
|
||||
func (d *durabilityRun) recoverAndCheck(lastSaved, pending []provider.Message, rewrite bool, label string) []provider.Message {
|
||||
d.t.Helper()
|
||||
branchesBefore := d.countRecoveryFiles()
|
||||
s1, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
// A crash before anything ever became durable legitimately leaves no
|
||||
// session file; the empty floor lost nothing.
|
||||
if len(lastSaved) == 0 && os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
d.t.Fatalf("%s: recovery load failed: %v", label, err)
|
||||
}
|
||||
s2, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
d.t.Fatalf("%s: second recovery load failed: %v", label, err)
|
||||
}
|
||||
if mustDigest(d.t, s1.Messages) != mustDigest(d.t, s2.Messages) {
|
||||
d.t.Fatalf("%s: recovery not idempotent — two loads disagree", label)
|
||||
}
|
||||
if after := d.countRecoveryFiles(); after != branchesBefore {
|
||||
d.t.Fatalf("%s: pure loads changed recovery-branch count %d→%d", label, branchesBefore, after)
|
||||
}
|
||||
got := s1.Messages
|
||||
if rewrite {
|
||||
if !messagesEqualForStorageList(got, lastSaved) && !messagesEqualForStorageList(got, pending) {
|
||||
d.t.Fatalf("%s: rewrite recovery produced a state that is neither endpoint (got %d msgs, endpoints %d/%d)",
|
||||
label, len(got), len(lastSaved), len(pending))
|
||||
}
|
||||
return got
|
||||
}
|
||||
if !messagesHavePrefixWithCompatibleSystem(got, lastSaved) {
|
||||
d.t.Fatalf("%s: recovery rolled back below the durable floor (got %d msgs, floor %d) — invariant 1 violated",
|
||||
label, len(got), len(lastSaved))
|
||||
}
|
||||
if !messagesHavePrefixWithCompatibleSystem(pending, got) {
|
||||
d.t.Fatalf("%s: recovery produced a chimera beyond the pending save (got %d msgs, pending %d) — invariant 3 violated",
|
||||
label, len(got), len(pending))
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// buildSaved replays i committed turns into a fresh session file and returns
|
||||
// the live session plus its durable transcript.
|
||||
func (d *durabilityRun) buildSaved(turns int) (*Session, []provider.Message) {
|
||||
d.t.Helper()
|
||||
s := NewSession("system prompt")
|
||||
for i := 1; i <= turns; i++ {
|
||||
for _, m := range d.turn(i) {
|
||||
s.Add(m)
|
||||
}
|
||||
if err := s.SaveSnapshot(d.path); err != nil {
|
||||
d.t.Fatalf("seed save %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
return s, append([]provider.Message(nil), s.Messages...)
|
||||
}
|
||||
|
||||
func TestDurabilityCrashSweepAppendSave(t *testing.T) {
|
||||
probe := newDurabilityRun(t)
|
||||
s, _ := probe.buildSaved(1)
|
||||
for _, m := range probe.turn(2) {
|
||||
s.Add(m)
|
||||
}
|
||||
ops := probe.countBoundaries(func() {
|
||||
if err := s.SaveSnapshot(probe.path); err != nil {
|
||||
t.Fatalf("probe save: %v", err)
|
||||
}
|
||||
})
|
||||
if len(ops) == 0 {
|
||||
t.Fatal("save crossed no durable boundaries — seam broken")
|
||||
}
|
||||
walIdx := -1
|
||||
for i, op := range ops {
|
||||
if op == "wal-append" {
|
||||
walIdx = i
|
||||
}
|
||||
}
|
||||
t.Logf("append-save boundaries: %v (wal at %d)", ops, walIdx)
|
||||
|
||||
for n := 1; n <= len(ops); n++ {
|
||||
d := newDurabilityRun(t)
|
||||
live, saved := d.buildSaved(1)
|
||||
for _, m := range d.turn(2) {
|
||||
live.Add(m)
|
||||
}
|
||||
pending := append([]provider.Message(nil), live.Messages...)
|
||||
if !d.crashAt(n, func() { _ = live.SaveSnapshot(d.path) }) {
|
||||
t.Fatalf("boundary %d: crash did not fire", n)
|
||||
}
|
||||
got := d.recoverAndCheck(saved, pending, false, fmt.Sprintf("boundary %d/%d (%s)", n, len(ops), ops[n-1]))
|
||||
// The WAL is authoritative: once the append event is durable, recovery
|
||||
// must yield the pending transcript even if the checkpoint never landed.
|
||||
if walIdx >= 0 && n > walIdx+1 && !messagesEqualForStorageList(got, pending) {
|
||||
t.Fatalf("boundary %d (%s): WAL was durable but recovery returned %d msgs instead of pending %d",
|
||||
n, ops[n-1], len(got), len(pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityCheckpointWithoutLedgerHeals(t *testing.T) {
|
||||
probe := newDurabilityRun(t)
|
||||
s, _ := probe.buildSaved(1)
|
||||
for _, m := range probe.turn(2) {
|
||||
s.Add(m)
|
||||
}
|
||||
ops := probe.countBoundaries(func() { _ = s.SaveSnapshot(probe.path) })
|
||||
// Crash on the LAST boundary: everything before it (WAL + checkpoint) is
|
||||
// durable, the trailing ledger/index write is not.
|
||||
n := len(ops)
|
||||
d := newDurabilityRun(t)
|
||||
live, _ := d.buildSaved(1)
|
||||
for _, m := range d.turn(2) {
|
||||
live.Add(m)
|
||||
}
|
||||
pending := append([]provider.Message(nil), live.Messages...)
|
||||
if !d.crashAt(n, func() { _ = live.SaveSnapshot(d.path) }) {
|
||||
t.Fatalf("crash at final boundary did not fire (ops=%v)", ops)
|
||||
}
|
||||
branches := d.countRecoveryFiles()
|
||||
loaded, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("recovery load: %v", err)
|
||||
}
|
||||
if !messagesEqualForStorageList(loaded.Messages, pending) {
|
||||
t.Fatalf("recovery after ledger-less checkpoint returned %d msgs, want pending %d", len(loaded.Messages), len(pending))
|
||||
}
|
||||
// Healing save: continue on the recovered session without forking a branch.
|
||||
for _, m := range d.turn(3) {
|
||||
loaded.Add(m)
|
||||
}
|
||||
if err := loaded.SaveSnapshot(d.path); err != nil {
|
||||
t.Fatalf("post-recovery save must heal, got: %v", err)
|
||||
}
|
||||
if got := d.countRecoveryFiles(); got != branches {
|
||||
t.Fatalf("post-recovery save forked a recovery branch (%d→%d) instead of healing", branches, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityTornWALTailReplaysToLastCommit(t *testing.T) {
|
||||
d := newDurabilityRun(t)
|
||||
_, saved := d.buildSaved(2)
|
||||
wal := d.path[:len(d.path)-len(".jsonl")] + ".events.jsonl"
|
||||
if _, err := os.Stat(wal); err != nil {
|
||||
// Resolve the actual event-log path via the store layout if it differs.
|
||||
matches, _ := filepath.Glob(filepath.Join(d.dir, "*.events.jsonl"))
|
||||
if len(matches) != 1 {
|
||||
t.Fatalf("cannot locate WAL (stat %v, glob %v)", err, matches)
|
||||
}
|
||||
wal = matches[0]
|
||||
}
|
||||
f, err := os.OpenFile(wal, os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("open WAL: %v", err)
|
||||
}
|
||||
if _, err := f.WriteString(`{"schema_version":1,"type":"append","messages":[{"role":"u`); err != nil {
|
||||
t.Fatalf("tear WAL: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
got := d.recoverAndCheck(saved, saved, false, "torn WAL tail")
|
||||
if !messagesEqualForStorageList(got, saved) {
|
||||
t.Fatalf("torn tail recovery returned %d msgs, want last clean commit %d", len(got), len(saved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityStaleWriterCannotClobber(t *testing.T) {
|
||||
d := newDurabilityRun(t)
|
||||
_, _ = d.buildSaved(1)
|
||||
|
||||
a, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("load A: %v", err)
|
||||
}
|
||||
b, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("load B: %v", err)
|
||||
}
|
||||
for _, m := range d.turn(2) {
|
||||
b.Add(m)
|
||||
}
|
||||
if err := b.SaveSnapshot(d.path); err != nil {
|
||||
t.Fatalf("B save: %v", err)
|
||||
}
|
||||
winner := append([]provider.Message(nil), b.Messages...)
|
||||
|
||||
a.Add(provider.Message{Role: provider.RoleUser, Content: "diverged ask"})
|
||||
a.Add(provider.Message{Role: provider.RoleAssistant, Content: "diverged answer"})
|
||||
saveErr := a.SaveSnapshot(d.path)
|
||||
|
||||
loaded, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if saveErr == nil {
|
||||
// A stale diverged writer may be redirected, never silently accepted
|
||||
// over B: the main path must still be B's descendant.
|
||||
if !messagesHavePrefixWithCompatibleSystem(loaded.Messages, winner) {
|
||||
t.Fatalf("stale writer clobbered the newer transcript: main path %d msgs no longer extends winner %d",
|
||||
len(loaded.Messages), len(winner))
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, ok := SnapshotConflictKind(saveErr); !ok {
|
||||
t.Fatalf("stale save failed with a non-conflict error: %v", saveErr)
|
||||
}
|
||||
if !messagesEqualForStorageList(loaded.Messages, winner) {
|
||||
t.Fatalf("conflict was reported but main path changed anyway (%d msgs, want %d)", len(loaded.Messages), len(winner))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityBareSaveBootstrapsWAL(t *testing.T) {
|
||||
d := newDurabilityRun(t)
|
||||
s := NewSession("system prompt")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "bare save"})
|
||||
if err := s.Save(d.path); err != nil {
|
||||
t.Fatalf("bare Save: %v", err)
|
||||
}
|
||||
probe, err := probeSessionEventLog(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("probe WAL: %v", err)
|
||||
}
|
||||
if !probe.native || probe.size == 0 {
|
||||
t.Fatalf("bare Save did not bootstrap a native WAL: %+v", probe)
|
||||
}
|
||||
loaded, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload bare Save: %v", err)
|
||||
}
|
||||
if !messagesEqualForStorageList(loaded.Messages, s.Messages) {
|
||||
t.Fatalf("bare Save round trip changed transcript: got %d want %d messages", len(loaded.Messages), len(s.Messages))
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventLog(d.path)); err != nil {
|
||||
t.Fatalf("bare Save WAL missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityCrossWriterIDCannotClobber(t *testing.T) {
|
||||
originalWriterID := sessionWriterID
|
||||
t.Cleanup(func() { sessionWriterID = originalWriterID })
|
||||
|
||||
d := newDurabilityRun(t)
|
||||
sessionWriterID = "writer-a"
|
||||
a := NewSession("system prompt")
|
||||
a.Add(provider.Message{Role: provider.RoleUser, Content: "base"})
|
||||
if err := a.SaveSnapshot(d.path); err != nil {
|
||||
t.Fatalf("writer A seed save: %v", err)
|
||||
}
|
||||
a, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("writer A load: %v", err)
|
||||
}
|
||||
|
||||
sessionWriterID = "writer-b"
|
||||
b, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("writer B load: %v", err)
|
||||
}
|
||||
b.Add(provider.Message{Role: provider.RoleAssistant, Content: "newer writer B"})
|
||||
if err := b.SaveSnapshot(d.path); err != nil {
|
||||
t.Fatalf("writer B save: %v", err)
|
||||
}
|
||||
winner := b.Snapshot()
|
||||
|
||||
sessionWriterID = "writer-a"
|
||||
a.Add(provider.Message{Role: provider.RoleAssistant, Content: "stale writer A"})
|
||||
err = a.SaveSnapshot(d.path)
|
||||
if err == nil {
|
||||
t.Fatal("cross-writer stale save unexpectedly succeeded")
|
||||
}
|
||||
if _, ok := SnapshotConflictKind(err); !ok {
|
||||
t.Fatalf("cross-writer stale save error = %v, want snapshot conflict", err)
|
||||
}
|
||||
loaded, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload cross-writer winner: %v", err)
|
||||
}
|
||||
if !messagesEqualForStorageList(loaded.Messages, winner) {
|
||||
t.Fatalf("cross-writer stale save clobbered winner: got %d want %d messages", len(loaded.Messages), len(winner))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityStaleCompactRewriteCannotClobber(t *testing.T) {
|
||||
d := newDurabilityRun(t)
|
||||
_, _ = d.buildSaved(1)
|
||||
|
||||
stale, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("load stale session: %v", err)
|
||||
}
|
||||
newer, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("load newer session: %v", err)
|
||||
}
|
||||
newer.Add(provider.Message{Role: provider.RoleUser, Content: "newer durable turn"})
|
||||
if err := newer.SaveSnapshot(d.path); err != nil {
|
||||
t.Fatalf("newer save: %v", err)
|
||||
}
|
||||
winner := append([]provider.Message(nil), newer.Messages...)
|
||||
|
||||
stale.Replace(append([]provider.Message(nil), stale.Messages...))
|
||||
err = stale.SaveRewriteCompact(d.path)
|
||||
if err == nil {
|
||||
t.Fatal("stale compact rewrite unexpectedly succeeded")
|
||||
}
|
||||
if _, ok := SnapshotConflictKind(err); !ok {
|
||||
t.Fatalf("stale compact rewrite error = %v, want snapshot conflict", err)
|
||||
}
|
||||
loaded, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload winner: %v", err)
|
||||
}
|
||||
if !messagesEqualForStorageList(loaded.Messages, winner) {
|
||||
t.Fatalf("stale compact rewrite clobbered winner: got %d want %d messages", len(loaded.Messages), len(winner))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityRewindSuffixDoesNotResurrect(t *testing.T) {
|
||||
d := newDurabilityRun(t)
|
||||
_, _ = d.buildSaved(3)
|
||||
|
||||
a, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("load A: %v", err)
|
||||
}
|
||||
b, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("load B: %v", err)
|
||||
}
|
||||
// B performs an intentional rewind to one turn and commits it.
|
||||
short := append([]provider.Message(nil), b.Messages[:3]...) // system + turn 1
|
||||
b.Rewrite(short, "rewind")
|
||||
if err := b.SaveRewrite(d.path); err != nil {
|
||||
t.Fatalf("B rewind save: %v", err)
|
||||
}
|
||||
// A, still holding the long pre-rewind transcript, keeps appending.
|
||||
a.Add(provider.Message{Role: provider.RoleUser, Content: "stale continuation"})
|
||||
_ = a.SaveSnapshot(d.path)
|
||||
|
||||
loaded, err := LoadSession(d.path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if messagesHavePrefixWithCompatibleSystem(loaded.Messages, a.Messages) && len(loaded.Messages) >= len(a.Messages) {
|
||||
t.Fatalf("rewound suffix resurrected on the main path (%d msgs)", len(loaded.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityStaleInFlightCompareAndClear(t *testing.T) {
|
||||
d := newDurabilityRun(t)
|
||||
_, _ = d.buildSaved(1)
|
||||
old, err := BeginSessionInFlightTurn(d.path, 1, false)
|
||||
if err != nil {
|
||||
t.Fatalf("begin old turn: %v", err)
|
||||
}
|
||||
fresh, err := BeginSessionInFlightTurn(d.path, 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("begin fresh turn: %v", err)
|
||||
}
|
||||
cleared, err := ClearSessionInFlightTurnIfMatch(d.path, old)
|
||||
if err != nil {
|
||||
t.Fatalf("compare-and-clear: %v", err)
|
||||
}
|
||||
if cleared {
|
||||
t.Fatal("stale turn cleared the fresh turn's marker — compare-and-clear broken")
|
||||
}
|
||||
cleared, err = ClearSessionInFlightTurnIfMatch(d.path, fresh)
|
||||
if err != nil || !cleared {
|
||||
t.Fatalf("owner clear failed: cleared=%v err=%v", cleared, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurabilityFuzzCrashConsistency(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("fuzz sweep skipped in -short")
|
||||
}
|
||||
for seed := int64(1); seed <= 20; seed++ {
|
||||
t.Run(fmt.Sprintf("seed%02d", seed), func(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
steps := 2 + rng.Intn(5)
|
||||
crashStep := 1 + rng.Intn(steps)
|
||||
|
||||
type stepKind int
|
||||
const (
|
||||
kindAppend stepKind = iota
|
||||
kindRewrite
|
||||
)
|
||||
kinds := make([]stepKind, steps)
|
||||
for i := range kinds {
|
||||
if rng.Intn(10) < 8 || i == 0 {
|
||||
kinds[i] = kindAppend
|
||||
} else {
|
||||
kinds[i] = kindRewrite
|
||||
}
|
||||
}
|
||||
|
||||
apply := func(s *Session, i int) {
|
||||
switch kinds[i] {
|
||||
case kindAppend:
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("s%d ask %d", seed, i)})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("s%d answer %d", seed, i)})
|
||||
case kindRewrite:
|
||||
keep := 1 + len(s.Messages)/2
|
||||
s.Rewrite(append([]provider.Message(nil), s.Messages[:keep]...), "compact")
|
||||
}
|
||||
}
|
||||
save := func(s *Session, i int, path string) error {
|
||||
if kinds[i] == kindRewrite {
|
||||
return s.SaveRewrite(path)
|
||||
}
|
||||
return s.SaveSnapshot(path)
|
||||
}
|
||||
|
||||
// Dry run to count the crash step's boundaries.
|
||||
probe := newDurabilityRun(t)
|
||||
ps := NewSession("system prompt")
|
||||
for i := range crashStep - 1 {
|
||||
apply(ps, i)
|
||||
if err := save(ps, i, probe.path); err != nil {
|
||||
t.Fatalf("probe step %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
apply(ps, crashStep-1)
|
||||
ops := probe.countBoundaries(func() { _ = save(ps, crashStep-1, probe.path) })
|
||||
if len(ops) == 0 {
|
||||
t.Skip("crash step crossed no boundaries")
|
||||
}
|
||||
boundary := 1 + rng.Intn(len(ops))
|
||||
|
||||
d := newDurabilityRun(t)
|
||||
s := NewSession("system prompt")
|
||||
for i := range crashStep - 1 {
|
||||
apply(s, i)
|
||||
if err := save(s, i, d.path); err != nil {
|
||||
t.Fatalf("step %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
var lastSaved []provider.Message
|
||||
if crashStep > 1 {
|
||||
lastSaved = append(lastSaved, s.Messages...)
|
||||
}
|
||||
apply(s, crashStep-1)
|
||||
pending := append([]provider.Message(nil), s.Messages...)
|
||||
if !d.crashAt(boundary, func() { _ = save(s, crashStep-1, d.path) }) {
|
||||
t.Fatalf("crash at boundary %d/%d did not fire", boundary, len(ops))
|
||||
}
|
||||
d.recoverAndCheck(lastSaved, pending, kinds[crashStep-1] == kindRewrite,
|
||||
fmt.Sprintf("seed %d step %d boundary %d/%d (%s)", seed, crashStep, boundary, len(ops), ops[boundary-1]))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -714,6 +714,7 @@ func appendSessionEvent(sessionPath string, rec sessionEventRecord, sync bool) e
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty session event log path")
|
||||
}
|
||||
fileutil.Crash("wal-append", path)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -838,7 +839,7 @@ func writeSessionEventIndex(path string, msgs []provider.Message, digest [sha256
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// No log means nothing for the index to describe; drop a stale
|
||||
// index (e.g. after a force save folded the log away).
|
||||
// index left by migration or manual sidecar cleanup.
|
||||
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestLoadSessionIgnoresForeignEventLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceSaveLeavesLegacyEventTranscriptUntouched(t *testing.T) {
|
||||
func TestSaveLeavesLegacyEventTranscriptUntouched(t *testing.T) {
|
||||
// The v0.x migration reconstructs sessions from legacy Claude-style
|
||||
// ".events.jsonl" transcripts that live in the SAME directory the native
|
||||
// session is imported into — i.e. exactly at the native event-log path.
|
||||
@@ -154,7 +154,7 @@ func TestForceSaveLeavesLegacyEventTranscriptUntouched(t *testing.T) {
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "hello from v0.x"})
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("force Save beside legacy transcript: %v", err)
|
||||
t.Fatalf("Save beside legacy transcript: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(logPath)
|
||||
@@ -516,47 +516,36 @@ func TestLoadSessionMessagesAcceptsReorderedEventHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceSaveDoesNotBootstrapEventLog(t *testing.T) {
|
||||
func TestDefaultSaveBootstrapsEventLog(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "one-shot"})
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventLog(path)); !os.IsNotExist(err) {
|
||||
t.Fatalf("force save created an event log (err=%v); one-shot copies must stay single-file", err)
|
||||
if _, err := os.Stat(store.SessionEventLog(path)); err != nil {
|
||||
t.Fatalf("default save did not create an event log: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventIndex(path)); !os.IsNotExist(err) {
|
||||
t.Fatalf("force save created an event index (err=%v)", err)
|
||||
if _, err := os.Stat(store.SessionEventIndex(path)); err != nil {
|
||||
t.Fatalf("default save did not create an event index: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceSaveCompactsExistingEventLog(t *testing.T) {
|
||||
func TestDefaultSaveRejectsDivergedOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
sessionWithTurns(t, path, 3)
|
||||
winner := sessionWithTurns(t, path, 3).Snapshot()
|
||||
|
||||
forced := NewSession("sys")
|
||||
forced.Add(provider.Message{Role: provider.RoleUser, Content: "forced state"})
|
||||
if err := forced.Save(path); err != nil {
|
||||
t.Fatalf("force Save: %v", err)
|
||||
}
|
||||
events := readSessionEventsForTest(t, path)
|
||||
if len(events) != 1 || events[0].Type != sessionEventTypeReplace {
|
||||
t.Fatalf("events after force save = %+v, want single replace", events)
|
||||
stale := NewSession("sys")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "stale state"})
|
||||
if err := stale.Save(path); !errors.Is(err, ErrSessionSnapshotConflict) {
|
||||
t.Fatalf("diverged Save error = %v, want ErrSessionSnapshotConflict", err)
|
||||
}
|
||||
loaded, err := LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "forced state" {
|
||||
t.Fatalf("loaded after force save = %+v, want forced transcript", loaded.Messages)
|
||||
}
|
||||
anchor, err := loadSessionMessagesFromJSONL(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read anchor: %v", err)
|
||||
}
|
||||
if len(anchor) != 2 || anchor[1].Content != "forced state" {
|
||||
t.Fatalf("anchor after force save = %+v, want refreshed", anchor)
|
||||
if !messagesEqualForStorageList(loaded.Messages, winner) {
|
||||
t.Fatalf("default Save replaced newer transcript: got %d messages, want %d", len(loaded.Messages), len(winner))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -455,9 +455,8 @@ func TestNewSessionPathEmptyModel(t *testing.T) {
|
||||
|
||||
// TestNeedsRewriteSaveFollowsSaves pins the baseline's lifecycle on the
|
||||
// session object itself: an in-memory rewrite demands a rewrite save, every
|
||||
// full save re-anchors (including the plain force Save the depth-cap recovery
|
||||
// path uses), and the baseline never moves backwards when a slower save
|
||||
// reports an older capture.
|
||||
// successful save re-anchors, and the baseline never moves backwards when a
|
||||
// slower save reports an older capture.
|
||||
func TestNeedsRewriteSaveFollowsSaves(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
@@ -474,7 +473,7 @@ func TestNeedsRewriteSaveFollowsSaves(t *testing.T) {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if s.NeedsRewriteSave() {
|
||||
t.Fatal("force save must re-anchor the rewrite baseline")
|
||||
t.Fatal("Save must re-anchor the rewrite baseline")
|
||||
}
|
||||
s.IncrementRewrite()
|
||||
if err := s.SaveRewrite(path); err != nil {
|
||||
@@ -602,6 +601,35 @@ func TestRewriteBaselineStaysWithClones(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUnsavedChangesProtectsIdleHistoryAfterSaveFailure(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
s := NewSession("sys")
|
||||
s.Add(provider.Message{Role: provider.RoleUser, Content: "durable"})
|
||||
if !s.HasUnsavedChanges(path) {
|
||||
t.Fatal("new transcript without a baseline must be considered unsaved")
|
||||
}
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("initial save: %v", err)
|
||||
}
|
||||
if s.HasUnsavedChanges(path) {
|
||||
t.Fatal("saved transcript still reported unsaved")
|
||||
}
|
||||
|
||||
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "pending"})
|
||||
if !s.HasUnsavedChanges(path) {
|
||||
t.Fatal("in-memory suffix was not reported as unsaved")
|
||||
}
|
||||
// A future retry can persist the suffix; until then an idle history refresh
|
||||
// must keep rendering the controller's copy instead of replacing it from the
|
||||
// older checkpoint/WAL state.
|
||||
if err := s.SaveSnapshot(path); err != nil {
|
||||
t.Fatalf("retry save: %v", err)
|
||||
}
|
||||
if s.HasUnsavedChanges(path) {
|
||||
t.Fatal("successful retry left the transcript marked unsaved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageRangeReturnsClampedCopy: the window is clamped to the log bounds
|
||||
// and detached from the live slice.
|
||||
func TestMessageRangeReturnsClampedCopy(t *testing.T) {
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestDeleteSubagentsByParentSweepsEventLogLeftovers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubagentForceSaveStaysSingleFile(t *testing.T) {
|
||||
func TestSubagentSaveUsesDurableEventLog(t *testing.T) {
|
||||
sessionDir := t.TempDir()
|
||||
subDir := filepath.Join(sessionDir, "subagents")
|
||||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||||
@@ -63,7 +63,10 @@ func TestSubagentForceSaveStaysSingleFile(t *testing.T) {
|
||||
if err := s.Save(path); err != nil {
|
||||
t.Fatalf("second Save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventLog(path)); !os.IsNotExist(err) {
|
||||
t.Fatalf("repeated force saves created an event log (err=%v)", err)
|
||||
if _, err := os.Stat(store.SessionEventLog(path)); err != nil {
|
||||
t.Fatalf("subagent event log missing after save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.SessionEventIndex(path)); err != nil {
|
||||
t.Fatalf("subagent event index missing after save: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
@@ -283,7 +284,7 @@ func (a *adapter) saveContextTokens() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
if err := fileutil.AtomicWriteFile(path, data, 0o600); err != nil {
|
||||
a.logger.Warn("failed to save weixin context tokens", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,10 +119,13 @@ func TestRunResumeKeepsCompletedIndexStableAcrossRecoveryGC(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("save recovery branch: %v", err)
|
||||
}
|
||||
covered := agent.NewSession("")
|
||||
covered.Messages = append([]provider.Message(nil), stale.Snapshot()...)
|
||||
covered, err := agent.LoadSession(parentPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load recovery parent: %v", err)
|
||||
}
|
||||
covered.Replace(append([]provider.Message(nil), stale.Snapshot()...))
|
||||
covered.Add(provider.Message{Role: provider.RoleUser, Content: "later parent turn"})
|
||||
if err := covered.Save(parentPath); err != nil {
|
||||
if err := covered.SaveRewrite(parentPath); err != nil {
|
||||
t.Fatalf("cover recovery branch in parent: %v", err)
|
||||
}
|
||||
recoveryMeta, ok, err := agent.LoadBranchMeta(recovery.Path)
|
||||
|
||||
@@ -70,9 +70,10 @@ func cliSessionRecoveredHandler(leases *control.SessionLeaseKeeper) func(control
|
||||
// copySessionForWriting duplicates the session at src into a fresh session
|
||||
// file beside it and returns the new path. It backs the --copy escape hatch:
|
||||
// when src is held by another runtime, the copy gives this process a session
|
||||
// it can own. The duplicate is written through Session.Save, so it is
|
||||
// event-log aware (authoritative event log plus .jsonl checkpoint) and starts
|
||||
// with no lease/lock sidecars of its own; src is only read. When src is being
|
||||
// it can own. The duplicate is written through Session.SaveIfAbsent, so it is
|
||||
// event-log aware (authoritative event log plus .jsonl checkpoint), cannot
|
||||
// replace a destination another runtime created, and starts with no
|
||||
// lease/lock sidecars of its own; src is only read. When src is being
|
||||
// written concurrently, the copy captures the transcript as of the load — an
|
||||
// append-only prefix, the same view a resume would see.
|
||||
func copySessionForWriting(src string) (string, error) {
|
||||
@@ -94,7 +95,7 @@ func copySessionForWriting(src string) (string, error) {
|
||||
newPath := agent.NewSessionPath(filepath.Dir(src), label)
|
||||
copySess := agent.NewSession("")
|
||||
copySess.Messages = msgs
|
||||
if err := copySess.Save(newPath); err != nil {
|
||||
if err := copySess.SaveIfAbsent(newPath); err != nil {
|
||||
return "", fmt.Errorf("copy session: %w", err)
|
||||
}
|
||||
preview, turns := agent.SessionPreviewFromMessages(msgs)
|
||||
|
||||
+157
-52
@@ -1904,7 +1904,8 @@ func (c *Controller) Run(ctx context.Context, input string) (err error) {
|
||||
return nil
|
||||
}
|
||||
startMessages := c.messageCount()
|
||||
defer c.snapshotActivityIfChanged(startMessages)
|
||||
var marker agent.InFlightTurnMeta
|
||||
defer func() { c.finishInFlightTurn(startMessages, marker) }()
|
||||
c.beginCheckpoint(ctx, input)
|
||||
if c.guardianSess != nil {
|
||||
c.guardianSess.ResetTurn()
|
||||
@@ -1919,8 +1920,7 @@ func (c *Controller) Run(ctx context.Context, input string) (err error) {
|
||||
}
|
||||
defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
|
||||
}
|
||||
c.markInFlightTurn(startMessages, true)
|
||||
defer c.clearInFlightTurn()
|
||||
marker = c.markInFlightTurn(startMessages, true)
|
||||
ctx = c.withPlannerTurnMetadata(ctx, rawInput, false, startMessages)
|
||||
err = c.runner.Run(ctx, c.withCapabilityRoute(ctx, input, rawInput))
|
||||
return err
|
||||
@@ -3178,7 +3178,7 @@ func (c *Controller) forkNamed(turn int, name string, switchToFork bool) (string
|
||||
sess.Messages = forked
|
||||
|
||||
newPath := agent.NewSessionPath(c.sessionDir, c.label)
|
||||
if err := sess.Save(newPath); err != nil {
|
||||
if err := sess.SaveIfAbsent(newPath); err != nil {
|
||||
return "", c.rewindFail(err)
|
||||
}
|
||||
forkPreview, forkTurns := agent.SessionPreviewFromMessages(forked)
|
||||
@@ -3266,7 +3266,7 @@ func (c *Controller) Branch(name string) (string, error) {
|
||||
sess.Messages = branched
|
||||
|
||||
newPath := agent.NewSessionPath(c.sessionDir, c.label)
|
||||
if err := sess.Save(newPath); err != nil {
|
||||
if err := sess.SaveIfAbsent(newPath); err != nil {
|
||||
return "", c.rewindFail(err)
|
||||
}
|
||||
branchPreview, branchTurns := agent.SessionPreviewFromMessages(branched)
|
||||
@@ -3598,6 +3598,11 @@ func (c *Controller) SnapshotRewrite() error {
|
||||
return c.snapshot(false, true, false)
|
||||
}
|
||||
|
||||
func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool) error {
|
||||
_, err := c.snapshotWithDurability(markActivity, forceRewrite, shutdownRecovery)
|
||||
return err
|
||||
}
|
||||
|
||||
// midTurnSnapshotInterval is atomic (nanoseconds) so a test shrinking it
|
||||
// cannot race a previous test's still-parking autosave goroutine.
|
||||
var midTurnSnapshotInterval atomic.Int64
|
||||
@@ -3623,7 +3628,11 @@ func (c *Controller) autosaveWhileRunning(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool) error {
|
||||
// snapshotWithDurability reports whether the canonical transcript reached disk
|
||||
// even when a later sidecar update failed. Callers that guard a crash marker
|
||||
// need this distinction: a metadata error must not make a complete transcript
|
||||
// look like an in-memory-only turn.
|
||||
func (c *Controller) snapshotWithDurability(markActivity, forceRewrite, shutdownRecovery bool) (bool, error) {
|
||||
c.snapshotMu.Lock()
|
||||
defer c.snapshotMu.Unlock()
|
||||
|
||||
@@ -3632,13 +3641,13 @@ func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool)
|
||||
modelRef := c.modelRef
|
||||
c.mu.Unlock()
|
||||
if c.executor == nil {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
s := c.executor.Session()
|
||||
if !s.HasContent() {
|
||||
// Nothing to persist yet (e.g. a fresh session with only a system
|
||||
// prompt) — staying quiet here is correct, not a data-loss path.
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
if !s.HasSystemMessage() {
|
||||
// The session has user/assistant/tool messages but no leading system
|
||||
@@ -3650,7 +3659,7 @@ func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool)
|
||||
// diagnosed, then refuse to write a corrupted transcript.
|
||||
slog.Warn("controller: refusing to snapshot session with content but no system message",
|
||||
"label", c.Label(), "session_dir", c.SessionDir(), "message_count", len(s.Snapshot()))
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
if path == "" {
|
||||
// There IS content but nowhere to write it: this silently dropped whole
|
||||
@@ -3658,7 +3667,7 @@ func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool)
|
||||
// so the missing session path can be diagnosed and fixed at the source.
|
||||
slog.Warn("controller: session has content but no session path; conversation will not be persisted",
|
||||
"label", c.Label(), "session_dir", c.SessionDir())
|
||||
return errNoSessionPath
|
||||
return false, errNoSessionPath
|
||||
}
|
||||
// session.save: the session_policy owner rules on the impending save; a
|
||||
// failure (required-class) vetoes the write. The event goes out after a
|
||||
@@ -3668,7 +3677,7 @@ func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool)
|
||||
// the save targeted.
|
||||
savePayload, strategyErr := c.extensionSessionStrategy(context.Background(), extension.PointSessionSave, dispatch.PhaseSave, path)
|
||||
if strategyErr != nil {
|
||||
return strategyErr
|
||||
return false, strategyErr
|
||||
}
|
||||
forceRewrite = forceRewrite || s.NeedsRewriteSave()
|
||||
var err error
|
||||
@@ -3691,7 +3700,7 @@ func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool)
|
||||
if shutdownRecovery && errors.Is(err, agent.ErrSessionFileLockHeld) {
|
||||
recoveredPath, recoverErr := c.recoverShutdownSnapshot(path, err)
|
||||
if recoverErr != nil {
|
||||
return recoverErr
|
||||
return false, recoverErr
|
||||
}
|
||||
path = recoveredPath
|
||||
s = c.executor.Session()
|
||||
@@ -3700,26 +3709,26 @@ func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool)
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
recoveredPath, outcome, recoverErr := c.recoverSnapshotConflict(path, err, forceRewrite)
|
||||
if recoverErr != nil {
|
||||
if shutdownRecovery && errors.Is(recoverErr, agent.ErrSessionFileLockHeld) {
|
||||
recoveredPath, recoverErr = c.recoverShutdownSnapshot(path, recoverErr)
|
||||
if recoverErr != nil {
|
||||
return recoverErr
|
||||
return false, recoverErr
|
||||
}
|
||||
path = recoveredPath
|
||||
s = c.executor.Session()
|
||||
} else {
|
||||
return recoverErr
|
||||
return false, recoverErr
|
||||
}
|
||||
} else {
|
||||
if outcome == conflictDropped {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
// Whatever recovery did — adopted the disk transcript, force-saved
|
||||
// the depth-capped branch, or forked — the rewrite baseline lives on
|
||||
// Whatever recovery did — adopted the disk transcript, isolated the
|
||||
// depth-capped copy, or forked — the rewrite baseline lives on
|
||||
// the session object and was advanced by the save that succeeded, so
|
||||
// there is nothing to re-anchor here.
|
||||
path = recoveredPath
|
||||
@@ -3735,6 +3744,7 @@ func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool)
|
||||
}
|
||||
}
|
||||
}
|
||||
transcriptDurable := true
|
||||
// Persist recovery gate state so unresolved checkpoints survive restart.
|
||||
c.saveRecoveryState(path)
|
||||
// Record the listing-only sidecar fields (model, preview, user-turn count)
|
||||
@@ -3745,10 +3755,10 @@ func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool)
|
||||
// EnsureBranchMeta / SetBranchModel / TouchBranchMeta sequence.
|
||||
preview, turns := agent.SessionPreviewFromMessages(s.Snapshot())
|
||||
if err := agent.UpdateSessionMeta(path, modelRef, preview, turns, markActivity); err != nil {
|
||||
return err
|
||||
return transcriptDurable, err
|
||||
}
|
||||
c.extensionSessionPayloadEvent(extension.PointSessionSave, savePayload)
|
||||
return nil
|
||||
return transcriptDurable, nil
|
||||
}
|
||||
|
||||
// snapshotConflictLogAttrs flattens a snapshot-conflict error into slog attrs.
|
||||
@@ -3836,16 +3846,12 @@ const (
|
||||
// conflictAdoptedDisk: the executor session object was replaced by the
|
||||
// newer disk transcript; adoptDiskSession already reset its baselines.
|
||||
conflictAdoptedDisk
|
||||
// conflictForceSavedBranch: recovery depth was exhausted and the same
|
||||
// in-memory session was force-saved onto the same branch; that save
|
||||
// advanced the session-owned rewrite baseline like any other full save.
|
||||
conflictForceSavedBranch
|
||||
// conflictForkedBranch: the same in-memory session moved to a freshly
|
||||
// forked recovery branch path.
|
||||
conflictForkedBranch
|
||||
)
|
||||
|
||||
const recoveryDepthCapNoticeText = "repeated save conflicts were detected; saved the current conflict copy in place"
|
||||
const recoveryDepthCapNoticeText = "repeated save conflicts were detected; saved the current conflict copy in an isolated recovery branch"
|
||||
|
||||
func sessionRecoveryNotice(code, text string) event.Event {
|
||||
return event.Event{
|
||||
@@ -3906,19 +3912,25 @@ func (c *Controller) recoverSnapshotConflict(path string, saveErr error, forceRe
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, agent.ErrSessionRecoveryDepthExceeded) {
|
||||
// Saves keep conflicting on recovery branches this runtime itself
|
||||
// created; forking again multiplies session files without
|
||||
// converging (#5993 reached 8 nested levels). This runtime is the
|
||||
// only writer of its own recovery branches, so force-writing the
|
||||
// transcript back onto the current branch keeps the data and
|
||||
// stops the chain.
|
||||
if forceErr := c.executor.Session().Save(path); forceErr != nil {
|
||||
return "", conflictDropped, fmt.Errorf("recovery chain depth exceeded; force save failed: %w", forceErr)
|
||||
// The canonical branch may have advanced since this runtime loaded it.
|
||||
// Never force-write the stale in-memory snapshot back onto that path just
|
||||
// to stop a recovery chain. Preserve it in a writer-specific isolated
|
||||
// branch instead; the depth cap limits lineage fan-out, not data safety.
|
||||
isolated, isolatedErr := c.executor.Session().SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{
|
||||
OriginalPath: path,
|
||||
Reason: reason,
|
||||
BranchMeta: meta,
|
||||
})
|
||||
if isolatedErr != nil {
|
||||
return "", conflictDropped, fmt.Errorf("recovery chain depth exceeded; isolated copy failed: %w", isolatedErr)
|
||||
}
|
||||
appendSnapshotConflictDiagnostic(path, mode, "recovery_depth_cap_force_saved", saveErr, path, false)
|
||||
slog.Warn("controller: snapshot conflict; recovery depth cap reached, force-saved onto current branch", logAttrs...)
|
||||
if err := c.commitRecoveredSession(path, reason, isolated); err != nil {
|
||||
return "", conflictDropped, err
|
||||
}
|
||||
appendSnapshotConflictDiagnostic(path, mode, "recovery_depth_cap_isolated", saveErr, isolated.Path, isolated.Existing)
|
||||
slog.Warn("controller: snapshot conflict; recovery depth cap reached, isolated stale transcript", append(logAttrs, "recovery", isolated.Path)...)
|
||||
c.emitRecoveryDepthCapNotice(path)
|
||||
return path, conflictForceSavedBranch, nil
|
||||
return isolated.Path, conflictForkedBranch, nil
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionRecoveryNotNeeded) {
|
||||
if c.adoptDiskSession(path) {
|
||||
@@ -4023,24 +4035,62 @@ func (c *Controller) messageCount() int {
|
||||
return c.executor.Session().Len()
|
||||
}
|
||||
|
||||
func (c *Controller) markInFlightTurn(startMessageIndex int, preserveUser bool) {
|
||||
func (c *Controller) markInFlightTurn(startMessageIndex int, preserveUser bool) agent.InFlightTurnMeta {
|
||||
path := c.SessionPath()
|
||||
if path == "" {
|
||||
return agent.InFlightTurnMeta{}
|
||||
}
|
||||
marker, err := agent.BeginSessionInFlightTurn(path, startMessageIndex, preserveUser)
|
||||
if err != nil {
|
||||
slog.Warn("controller: mark in-flight turn", "err", err)
|
||||
return agent.InFlightTurnMeta{}
|
||||
}
|
||||
return marker
|
||||
}
|
||||
|
||||
func (c *Controller) clearInFlightTurn(marker agent.InFlightTurnMeta) {
|
||||
path := c.SessionPath()
|
||||
if path == "" || marker.ID == "" {
|
||||
return
|
||||
}
|
||||
if err := agent.MarkSessionInFlightTurn(path, startMessageIndex, preserveUser); err != nil {
|
||||
slog.Warn("controller: mark in-flight turn", "err", err)
|
||||
if _, err := agent.ClearSessionInFlightTurnIfMatch(path, marker); err != nil {
|
||||
slog.Warn("controller: clear in-flight turn", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) clearInFlightTurn() {
|
||||
path := c.SessionPath()
|
||||
if path == "" {
|
||||
// finishInFlightTurn persists the completed transcript before removing the
|
||||
// crash marker. A crash can therefore leave either a recoverable marker or a
|
||||
// durable completed transcript, never an unmarked in-memory-only suffix.
|
||||
func (c *Controller) finishInFlightTurn(startMessages int, marker agent.InFlightTurnMeta) {
|
||||
commitPrepared := marker.ID == ""
|
||||
if marker.ID != "" && c.executor != nil {
|
||||
digest, digestErr := c.executor.Session().ContentDigest()
|
||||
if digestErr != nil {
|
||||
slog.Warn("controller: compute completed turn digest", "err", digestErr)
|
||||
} else if prepared, matched, prepareErr := agent.PrepareSessionInFlightTurnCommit(c.SessionPath(), marker, digest); prepareErr != nil {
|
||||
slog.Warn("controller: prepare in-flight turn commit", "err", prepareErr)
|
||||
} else if matched {
|
||||
marker = prepared
|
||||
commitPrepared = true
|
||||
}
|
||||
}
|
||||
durable, err := c.snapshotActivityIfChanged(startMessages)
|
||||
if err != nil && !durable {
|
||||
// Keep the marker when the transcript did not become durable. Resume can
|
||||
// then retry recovery instead of treating an in-memory-only tail as done.
|
||||
slog.Warn("controller: keeping in-flight marker after failed turn snapshot", "err", err)
|
||||
return
|
||||
}
|
||||
if err := agent.ClearSessionInFlightTurn(path); err != nil {
|
||||
slog.Warn("controller: clear in-flight turn", "err", err)
|
||||
if err != nil {
|
||||
slog.Warn("controller: turn transcript saved before metadata update failed", "err", err)
|
||||
}
|
||||
if !commitPrepared {
|
||||
// Do not clear an unprepared marker: a crash between the snapshot and this
|
||||
// point would otherwise leave recovery without exact commit evidence.
|
||||
slog.Warn("controller: keeping in-flight marker without commit digest", "marker_id", marker.ID)
|
||||
return
|
||||
}
|
||||
c.clearInFlightTurn(marker)
|
||||
}
|
||||
|
||||
// transplantInFlightTurnMarker moves a pending in-flight-turn marker from the
|
||||
@@ -4067,7 +4117,7 @@ func (c *Controller) transplantInFlightTurnMarker(fromPath, toPath string) {
|
||||
slog.Warn("controller: transplant in-flight turn marker", "path", toPath, "err", err)
|
||||
return
|
||||
}
|
||||
if err := agent.ClearSessionInFlightTurn(fromPath); err != nil {
|
||||
if _, err := agent.ClearSessionInFlightTurnIfMatch(fromPath, *marker); err != nil {
|
||||
slog.Warn("controller: clear in-flight turn marker on forked-from branch", "path", fromPath, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -4091,13 +4141,36 @@ func (c *Controller) recoverInterruptedTurn(path string) {
|
||||
// transplant in recoverSnapshotConflict left the marker behind on the
|
||||
// forked-from branch; stripping now would truncate a transcript the
|
||||
// completed turn already superseded. Clear the stale marker instead.
|
||||
if err := agent.ClearSessionInFlightTurn(path); err != nil {
|
||||
if _, err := agent.ClearSessionInFlightTurnIfMatch(path, *marker); err != nil {
|
||||
slog.Warn("controller: clear fork-orphaned in-flight turn", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
msgs := c.executor.Session().Snapshot()
|
||||
if marker.CommitDigest != "" {
|
||||
if digest, digestErr := c.executor.Session().ContentDigest(); digestErr != nil {
|
||||
slog.Warn("controller: digest resumed in-flight turn", "err", digestErr)
|
||||
} else if digest == marker.CommitDigest {
|
||||
// The exact transcript named before the final snapshot is present. The
|
||||
// process died after commit and before CAS cleanup; preserve everything.
|
||||
if _, err := agent.ClearSessionInFlightTurnIfMatch(path, *marker); err != nil {
|
||||
slog.Warn("controller: clear committed in-flight turn marker", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
start, found := resolveInterruptedTurnStart(msgs, marker.StartMessageIndex, marker.PreserveUser, marker.StartedAt, provider.Message{})
|
||||
if found && interruptedTurnCrossesLaterTurn(msgs, start) {
|
||||
slog.Warn("controller: preserving WAL transcript after stale in-flight marker",
|
||||
"path", path, "messages", len(msgs), "marker_index", marker.StartMessageIndex, "resolved_index", start,
|
||||
"marker_revision", marker.StartRevision, "current_revision", meta.Revision)
|
||||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
|
||||
Text: "Session recovery found completed turns after a stale interruption marker; the full WAL history was preserved."})
|
||||
if _, err := agent.ClearSessionInFlightTurnIfMatch(path, *marker); err != nil {
|
||||
slog.Warn("controller: clear stale multi-turn in-flight marker", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
changed := found && len(msgs) > start
|
||||
if changed {
|
||||
if marker.PreserveUser {
|
||||
@@ -4109,11 +4182,34 @@ func (c *Controller) recoverInterruptedTurn(path string) {
|
||||
slog.Warn("controller: post-interrupted-turn snapshot", "err", err)
|
||||
}
|
||||
}
|
||||
if err := agent.ClearSessionInFlightTurn(path); err != nil {
|
||||
if _, err := agent.ClearSessionInFlightTurnIfMatch(path, *marker); err != nil {
|
||||
slog.Warn("controller: clear stale in-flight turn", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// interruptedTurnCrossesLaterTurn detects the data-loss shape where an old
|
||||
// marker survived while one or more later turns were durably appended. A
|
||||
// compaction summary and mid-turn steer are not new foreground turn boundaries.
|
||||
func interruptedTurnCrossesLaterTurn(msgs []provider.Message, start int) bool {
|
||||
if start < 0 || start >= len(msgs) {
|
||||
return false
|
||||
}
|
||||
turns := 0
|
||||
for _, msg := range msgs[start:] {
|
||||
if msg.Role != provider.RoleUser || agent.IsCompactionSummary(msg) {
|
||||
continue
|
||||
}
|
||||
if _, ok := agent.SteerText(msg.Content); ok {
|
||||
continue
|
||||
}
|
||||
turns++
|
||||
if turns > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// interruptedTurnContinuedOnRecoveryBranch reports whether a recovery branch
|
||||
// forked off path after its in-flight-turn marker was set. Markers only exist
|
||||
// while a turn runs and recovery forks happen on saves, so a child recovery
|
||||
@@ -4516,13 +4612,11 @@ func (c *Controller) replaceSessionAfterCancel(msgs []provider.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) snapshotActivityIfChanged(startMessages int) {
|
||||
func (c *Controller) snapshotActivityIfChanged(startMessages int) (bool, error) {
|
||||
if c.messageCount() <= startMessages {
|
||||
return
|
||||
}
|
||||
if err := c.SnapshotActivity(); err != nil {
|
||||
slog.Warn("controller: activity snapshot", "err", err)
|
||||
return true, nil
|
||||
}
|
||||
return c.snapshotWithDurability(true, false, false)
|
||||
}
|
||||
|
||||
// SetSessionPath rebinds auto-save without changing the current session
|
||||
@@ -4640,6 +4734,17 @@ func (c *Controller) History() []provider.Message {
|
||||
return c.executor.Session().Snapshot() // copy — a turn may be appending concurrently
|
||||
}
|
||||
|
||||
// SessionHasUnsavedChanges tells desktop history whether it may safely refresh
|
||||
// an idle view from the durable WAL. A failed or contended save can leave the
|
||||
// controller with a newer in-memory transcript; replacing that view from disk
|
||||
// would hide the user's latest turn until the next retry.
|
||||
func (c *Controller) SessionHasUnsavedChanges() bool {
|
||||
if c == nil || c.executor == nil {
|
||||
return false
|
||||
}
|
||||
return c.executor.Session().HasUnsavedChanges(c.SessionPath())
|
||||
}
|
||||
|
||||
// HistoryLen returns the number of messages in the live log.
|
||||
func (c *Controller) HistoryLen() int {
|
||||
if c.executor == nil {
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/skill"
|
||||
"reasonix/internal/store"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -435,6 +436,96 @@ func TestRunTurnSnapshotsActivityWhenTranscriptChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishInFlightTurnKeepsMarkerUntilSnapshotSucceeds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
sess := agent.NewSession("sys")
|
||||
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
|
||||
c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
|
||||
|
||||
start := sess.Len()
|
||||
marker := c.markInFlightTurn(start, true)
|
||||
if marker.ID == "" {
|
||||
t.Fatal("in-flight marker was not created")
|
||||
}
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "must become durable"})
|
||||
if err := os.WriteFile(store.SessionEventLog(path), []byte(`{"schema_version":99,"type":"replace","messages":[]}`+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c.finishInFlightTurn(start, marker)
|
||||
meta, ok, err := agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok || meta.InFlightTurn == nil || meta.InFlightTurn.ID != marker.ID {
|
||||
t.Fatalf("marker after failed snapshot = %+v ok=%v err=%v, want original marker", meta.InFlightTurn, ok, err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("failed snapshot unexpectedly wrote transcript: stat err=%v", err)
|
||||
}
|
||||
|
||||
if err := os.Remove(store.SessionEventLog(path)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.finishInFlightTurn(start, marker)
|
||||
meta, ok, err = agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta after successful snapshot ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.InFlightTurn != nil {
|
||||
t.Fatalf("marker survived successful snapshot: %+v", meta.InFlightTurn)
|
||||
}
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loaded.Snapshot(); len(got) != 2 || got[1].Content != "must become durable" {
|
||||
t.Fatalf("durable transcript = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumePreservesTranscriptWhenCrashFollowsFinalSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "post-snapshot-crash.jsonl")
|
||||
sess := agent.NewSession("sys")
|
||||
if err := sess.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
|
||||
c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
|
||||
marker := c.markInFlightTurn(sess.Len(), true)
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "completed prompt"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "completed answer"})
|
||||
digest, err := sess.ContentDigest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
committedMarker, matched, err := agent.PrepareSessionInFlightTurnCommit(path, marker, digest)
|
||||
if err != nil || !matched {
|
||||
t.Fatalf("PrepareSessionInFlightTurnCommit matched=%v err=%v", matched, err)
|
||||
}
|
||||
if err := sess.SaveSnapshot(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoveredExec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
|
||||
recovered := New(Options{Executor: recoveredExec, SessionDir: dir, SessionPath: path, Label: "test"})
|
||||
recovered.recoverInterruptedTurn(path)
|
||||
msgs := recoveredExec.Session().Snapshot()
|
||||
if len(msgs) != 3 || msgs[2].Content != "completed answer" || msgs[2].LocalOnly {
|
||||
t.Fatalf("post-snapshot recovery changed durable transcript: %+v", msgs)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.InFlightTurn != nil {
|
||||
t.Fatalf("committed marker survived recovery: %+v (expected %+v)", meta.InFlightTurn, committedMarker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInjectsParentSessionForJobs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
@@ -1038,18 +1129,24 @@ func TestRecoverInterruptedTurnAfterCompactionRelocatesVisibleTurn(t *testing.T)
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v meta=%+v", ok, err, meta)
|
||||
}
|
||||
|
||||
compacted := agent.NewSession("sys")
|
||||
compacted.Add(provider.Message{Role: provider.RoleUser, Content: "<compaction-summary>\nold work\n</compaction-summary>"})
|
||||
compacted.Add(provider.Message{Role: provider.RoleUser, Content: "update a.txt", CreatedAt: meta.InFlightTurn.StartedAt.UnixMilli() + 1})
|
||||
compacted.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
|
||||
ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`,
|
||||
}}})
|
||||
compacted.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"})
|
||||
compacted.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial final answer", ReasoningContent: "private partial reasoning"})
|
||||
compacted, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load compacting owner: %v", err)
|
||||
}
|
||||
compacted.Replace([]provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "<compaction-summary>\nold work\n</compaction-summary>"},
|
||||
{Role: provider.RoleUser, Content: "update a.txt", CreatedAt: meta.InFlightTurn.StartedAt.UnixMilli() + 1},
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
|
||||
ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`,
|
||||
}}},
|
||||
{Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"},
|
||||
{Role: provider.RoleAssistant, Content: "partial final answer", ReasoningContent: "private partial reasoning"},
|
||||
})
|
||||
if compacted.Len() >= staleStart {
|
||||
t.Fatalf("test setup did not stale boundary: compacted=%d start=%d", compacted.Len(), staleStart)
|
||||
}
|
||||
if err := compacted.Save(path); err != nil {
|
||||
if err := compacted.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("Save compacted: %v", err)
|
||||
}
|
||||
|
||||
@@ -1087,6 +1184,85 @@ func TestRecoverInterruptedTurnAfterCompactionRelocatesVisibleTurn(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResumePreservesNewerWALAfterStaleMarker reproduces the destructive shape
|
||||
// from issue #7956: the compatibility anchor is still at 1149, while the native
|
||||
// event log has a newer 1315-message replace snapshot and an old marker at the
|
||||
// anchor boundary. Recovery must keep the WAL state and clear only the stale
|
||||
// marker instead of writing a backwards replace event.
|
||||
func TestResumePreservesNewerWALAfterStaleMarker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "wal-newer-than-anchor.jsonl")
|
||||
|
||||
sess := agent.NewSession("sys")
|
||||
for sess.Len() < 1149 {
|
||||
role := provider.RoleAssistant
|
||||
if sess.Len()%2 == 1 {
|
||||
role = provider.RoleUser
|
||||
}
|
||||
sess.Add(provider.Message{Role: role, Content: fmt.Sprintf("base-%d", sess.Len())})
|
||||
}
|
||||
anchor := sess.Snapshot()
|
||||
if err := sess.Save(path); err != nil {
|
||||
t.Fatalf("Save anchor: %v", err)
|
||||
}
|
||||
if err := agent.MarkSessionInFlightTurn(path, 1149, false); err != nil {
|
||||
t.Fatalf("MarkSessionInFlightTurn: %v", err)
|
||||
}
|
||||
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "synthetic-start"})
|
||||
for i := 1; i < 166; i++ {
|
||||
role := provider.RoleAssistant
|
||||
if i == 40 || i == 90 || i == 140 {
|
||||
role = provider.RoleUser
|
||||
}
|
||||
sess.Add(provider.Message{Role: role, Content: fmt.Sprintf("tail-%d", i)})
|
||||
}
|
||||
if sess.Len() != 1315 {
|
||||
t.Fatalf("test setup length = %d, want 1315", sess.Len())
|
||||
}
|
||||
if err := sess.SaveRewrite(path); err != nil {
|
||||
t.Fatalf("Save newer WAL snapshot: %v", err)
|
||||
}
|
||||
|
||||
var anchorJSON strings.Builder
|
||||
for _, msg := range anchor {
|
||||
encoded, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal anchor message: %v", err)
|
||||
}
|
||||
anchorJSON.Write(encoded)
|
||||
anchorJSON.WriteByte('\n')
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(anchorJSON.String()), 0o600); err != nil {
|
||||
t.Fatalf("restore stale compatibility anchor: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if loaded.Len() != 1315 {
|
||||
t.Fatalf("WAL replay length = %d, want 1315", loaded.Len())
|
||||
}
|
||||
exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
|
||||
c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
|
||||
c.recoverInterruptedTurn(path)
|
||||
|
||||
if got := exec.Session().Len(); got != 1315 {
|
||||
t.Fatalf("recovery shrank newer WAL transcript to %d, want 1315", got)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.InFlightTurn != nil {
|
||||
t.Fatalf("stale marker survived safe recovery: %+v", meta.InFlightTurn)
|
||||
}
|
||||
if meta.Revision != 2 {
|
||||
t.Fatalf("safe recovery rewrote WAL revision to %d, want unchanged 2", meta.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRewriteRecoversStaleControllerTranscript(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
@@ -2081,7 +2257,7 @@ func (s *noticeSink) lastNotice() (event.Event, bool) {
|
||||
return event.Event{}, false
|
||||
}
|
||||
|
||||
func TestSnapshotConflictAtRecoveryDepthCapForceSavesCurrentBranch(t *testing.T) {
|
||||
func TestSnapshotConflictAtRecoveryDepthCapIsolatesCurrentBranch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
disk := agent.NewSession("sys")
|
||||
@@ -2113,60 +2289,49 @@ func TestSnapshotConflictAtRecoveryDepthCapForceSavesCurrentBranch(t *testing.T)
|
||||
if err := c.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
if got := c.SessionPath(); got != path {
|
||||
t.Fatalf("session path = %q, want unchanged %q (no new fork)", got, path)
|
||||
if got := c.SessionPath(); got == path || !strings.Contains(got, "-recovery-") {
|
||||
t.Fatalf("session path = %q, want an isolated recovery branch", got)
|
||||
}
|
||||
forks, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob: %v", err)
|
||||
}
|
||||
if len(forks) != 0 {
|
||||
t.Fatalf("depth cap still forked: %v", forks)
|
||||
filteredForks := forks[:0]
|
||||
for _, fork := range forks {
|
||||
if !strings.HasSuffix(fork, ".events.jsonl") {
|
||||
filteredForks = append(filteredForks, fork)
|
||||
}
|
||||
}
|
||||
forks = filteredForks
|
||||
if len(forks) != 1 {
|
||||
t.Fatalf("depth cap should preserve one isolated fork: %v", forks)
|
||||
}
|
||||
loaded, err := agent.LoadSession(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "local second" {
|
||||
t.Fatalf("disk tail = %q, want force-saved local transcript", got)
|
||||
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "disk second" {
|
||||
t.Fatalf("canonical disk tail = %q, want newer disk transcript", got)
|
||||
}
|
||||
notices := sink.notices()
|
||||
if len(notices) == 0 || !strings.Contains(notices[len(notices)-1], "saved the current conflict copy in place") {
|
||||
if len(notices) == 0 || !strings.Contains(notices[len(notices)-1], "saved the current conflict copy in an isolated recovery branch") {
|
||||
t.Fatalf("notices = %v, want depth-cap notice", notices)
|
||||
}
|
||||
notice, ok := sink.lastNotice()
|
||||
if !ok || notice.Code != event.NoticeCodeSessionRecoveryDepthCap || notice.Audience != event.NoticeAudienceOperator {
|
||||
t.Fatalf("depth-cap notice = %+v, want typed operator recovery notice", notice)
|
||||
}
|
||||
if stale.NeedsRewriteSave() {
|
||||
t.Fatal("rewrite baseline not re-anchored by depth-cap force save")
|
||||
}
|
||||
|
||||
foreign := agent.NewSession("sys")
|
||||
foreign.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
foreign.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
foreign.Add(provider.Message{Role: provider.RoleUser, Content: "foreign second"})
|
||||
if err := foreign.Save(path); err != nil {
|
||||
t.Fatalf("Save foreign: %v", err)
|
||||
}
|
||||
meta, ok, err = agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta foreign ok=%v err=%v", ok, err)
|
||||
}
|
||||
meta.Recovered = true
|
||||
meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
|
||||
if err := agent.SaveBranchMeta(path, meta); err != nil {
|
||||
t.Fatalf("SaveBranchMeta foreign: %v", err)
|
||||
}
|
||||
// The isolated branch was saved with its own verified baseline. Defensive
|
||||
// snapshots on that branch must be no-ops rather than starting another
|
||||
// recovery chain or repeating the operator notice.
|
||||
if err := c.Snapshot(); err != nil {
|
||||
t.Fatalf("repeated depth-cap Snapshot: %v", err)
|
||||
t.Fatalf("snapshot on isolated branch: %v", err)
|
||||
}
|
||||
if got := sink.notices(); len(got) != len(notices) {
|
||||
t.Fatalf("repeated depth-cap snapshot emitted duplicate notice: %v", got)
|
||||
}
|
||||
|
||||
// The force save re-anchored the baseline: the next snapshot must not
|
||||
// conflict again.
|
||||
// New suffixes continue normally on the isolated branch.
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
|
||||
if err := c.Snapshot(); err != nil {
|
||||
t.Fatalf("follow-up Snapshot: %v", err)
|
||||
|
||||
@@ -209,6 +209,10 @@ type SessionPersistence interface {
|
||||
Snapshot() error
|
||||
SnapshotForShutdown() error
|
||||
SnapshotActivity() error
|
||||
// SessionHasUnsavedChanges reports whether the in-memory transcript is
|
||||
// newer than the durable session file. Frontends use this to avoid
|
||||
// replacing a failed/contended save with stale disk history.
|
||||
SessionHasUnsavedChanges() bool
|
||||
SessionCache() (hit, miss int)
|
||||
BeginDestroySession(sessionPath string) SessionDestroyHandle
|
||||
CloseAfterDestroy()
|
||||
|
||||
@@ -117,7 +117,8 @@ func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []s
|
||||
|
||||
input := c.compose(task, raw, true)
|
||||
startMessages := c.messageCount()
|
||||
defer c.snapshotActivityIfChanged(startMessages)
|
||||
var marker agent.InFlightTurnMeta
|
||||
defer func() { c.finishInFlightTurn(startMessages, marker) }()
|
||||
defer c.recordDisplayForNewUser(startMessages, display)
|
||||
// The checkpoint prompt labels the turn in the rewind picker (and is
|
||||
// prefilled into the composer after a conversation rewind), so it must be
|
||||
@@ -138,13 +139,7 @@ func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []s
|
||||
defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
|
||||
}
|
||||
|
||||
c.markInFlightTurn(startMessages, true)
|
||||
inFlight := true
|
||||
defer func() {
|
||||
if inFlight {
|
||||
c.clearInFlightTurn()
|
||||
}
|
||||
}()
|
||||
marker = c.markInFlightTurn(startMessages, true)
|
||||
c.sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
if c.executor == nil {
|
||||
return fmt.Errorf("subagent slash invocation requires an active session")
|
||||
@@ -182,8 +177,6 @@ func (o *turnOrchestrator) runSubagentSkillTurns(ctx context.Context, skills []s
|
||||
c.sink.Emit(event.Event{Kind: event.Message, Text: display})
|
||||
}
|
||||
|
||||
c.clearInFlightTurn()
|
||||
inFlight = false
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -222,7 +215,8 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
return nil
|
||||
}
|
||||
startMessages := c.messageCount()
|
||||
defer c.snapshotActivityIfChanged(startMessages)
|
||||
var marker agent.InFlightTurnMeta
|
||||
defer func() { c.finishInFlightTurn(startMessages, marker) }()
|
||||
defer c.recordDisplayForNewUser(startMessages, turn.display)
|
||||
if turn.editedOriginal != "" {
|
||||
defer c.markEditedForNewUser(startMessages, turn.editedOriginal)
|
||||
@@ -254,7 +248,7 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
}
|
||||
defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
|
||||
}
|
||||
c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw))
|
||||
marker = c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw))
|
||||
if continuation != nil {
|
||||
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{
|
||||
ID: continuation.scopeID,
|
||||
@@ -289,9 +283,7 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
}
|
||||
err = c.runner.Run(ctx, modelInput)
|
||||
c.persistGoalDeliveryCheckpoint()
|
||||
if err == nil {
|
||||
c.clearInFlightTurn()
|
||||
} else {
|
||||
if err != nil {
|
||||
// When the user explicitly cancels, keep the real prompt and any fully
|
||||
// paired tool work. Partial reasoning/output remains durable for display
|
||||
// but is marked local-only, and a bounded recovery summary is folded into
|
||||
@@ -320,7 +312,6 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
c.clearInFlightTurn()
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
@@ -356,8 +347,8 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
c.approval.setPlanAutoApprove(true)
|
||||
defer c.approval.setPlanAutoApprove(false)
|
||||
err = func() error {
|
||||
c.markInFlightTurn(execStart, false)
|
||||
defer c.clearInFlightTurn()
|
||||
marker := c.markInFlightTurn(execStart, false)
|
||||
defer c.finishInFlightTurn(execStart, marker)
|
||||
return o.runComposedSyntheticTurn(ctx, planApprovedMessage)
|
||||
}()
|
||||
if err != nil {
|
||||
|
||||
@@ -223,7 +223,7 @@ func redactSessionTranscript(path string, dryRun bool) (int64, int64, error) {
|
||||
// The replayed view alone is not enough: a replace event supersedes
|
||||
// earlier records without erasing them, so a raw secret can survive in a
|
||||
// stale event while the current messages are already clean. Scan every
|
||||
// record; Session.Save compacts the whole log into a single clean replace
|
||||
// record; SaveRewriteCompact folds the whole log into one clean replace
|
||||
// event, which erases the stale bytes.
|
||||
if !messagesNeedRedaction(s.Messages) && !(eventLogExists && eventLogNeedsRedaction(eventLog)) {
|
||||
return 0, 0, nil
|
||||
@@ -232,7 +232,11 @@ func redactSessionTranscript(path string, dryRun bool) (int64, int64, error) {
|
||||
return files, redactedEncodedSize(s.Messages), nil
|
||||
}
|
||||
s.Replace(secrets.RedactMessages(s.Messages))
|
||||
if err := s.Save(path); err != nil {
|
||||
// Redaction is an intentional rewrite, but it must still be CAS-protected:
|
||||
// the loaded transcript may have gone stale while the doctor inspected it.
|
||||
// SaveRewrite preserves the newer external transcript and reports a conflict
|
||||
// instead of force-replacing it with an older pre-redaction snapshot.
|
||||
if err := s.SaveRewriteCompact(path); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var rewritten int64
|
||||
@@ -300,7 +304,10 @@ func redactedEncodedSize(msgs []provider.Message) int64 {
|
||||
// (preview, titles, goal, recovery reason) through the typed load/save pair so
|
||||
// revisions, digests, and timestamps survive untouched.
|
||||
func redactBranchMeta(sessionPath string, dryRun bool) (int64, int64, error) {
|
||||
unlock := agent.LockSessionMetaPath(sessionPath)
|
||||
unlock, err := agent.LockSessionMetaPath(sessionPath)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer unlock()
|
||||
meta, ok, err := agent.LoadBranchMeta(sessionPath)
|
||||
if err != nil || !ok {
|
||||
@@ -319,7 +326,7 @@ func redactBranchMeta(sessionPath string, dryRun bool) (int64, int64, error) {
|
||||
if dryRun {
|
||||
return 1, 0, nil
|
||||
}
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, meta); err != nil {
|
||||
if err := agent.SaveBranchMetaPreserveUpdatedLocked(sessionPath, meta); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var rewritten int64
|
||||
|
||||
@@ -17,6 +17,18 @@ var (
|
||||
renameFile = os.Rename
|
||||
)
|
||||
|
||||
// CrashPoint, when non-nil, runs before every durable write/replace. Tests use
|
||||
// it to inject a process-crash panic at persistence boundaries; production
|
||||
// leaves it nil.
|
||||
var CrashPoint func(op, path string)
|
||||
|
||||
// Crash invokes the optional crash-consistency fault-injection hook.
|
||||
func Crash(op, path string) {
|
||||
if CrashPoint != nil {
|
||||
CrashPoint(op, path)
|
||||
}
|
||||
}
|
||||
|
||||
// AtomicWriteFile writes data to a sibling temporary file, fsyncs it, then
|
||||
// publishes it via ReplaceFile. On filesystems that support replacement rename,
|
||||
// readers see either the old file or the complete new file. ReplaceFile retains
|
||||
@@ -36,6 +48,7 @@ func AtomicWriteFileStrict(path string, data []byte, perm os.FileMode) error {
|
||||
}
|
||||
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode, allowCrossDeviceCopy bool) error {
|
||||
Crash("atomic-write", path)
|
||||
tmpPath, err := writeAtomicTemp(path, data, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -150,6 +163,7 @@ func writeAtomicTemp(path string, data []byte, perm os.FileMode) (string, error)
|
||||
//
|
||||
// A missing tmp means the write itself failed and no retry can help.
|
||||
func ReplaceFile(tmp, dest string) error {
|
||||
Crash("replace", dest)
|
||||
return replaceFile(tmp, dest, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/fileutil"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/nilutil"
|
||||
"reasonix/internal/provider"
|
||||
@@ -286,7 +286,7 @@ func (gs *Session) Save(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(cp, data, 0o644); err != nil {
|
||||
if err := fileutil.AtomicWriteFile(cp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ func SessionRecoveryState(sessionPath string) string {
|
||||
|
||||
// SessionContext is the context-projection / compaction-state sidecar
|
||||
// (<id>.context.json). It holds the model-visible projection and cache
|
||||
// telemetry; the primary .jsonl remains the canonical transcript.
|
||||
// telemetry; transcript authority remains with the native event log once one
|
||||
// exists, with the primary .jsonl retained as its compatibility checkpoint.
|
||||
func SessionContext(sessionPath string) string {
|
||||
sessionPath = strings.TrimSpace(sessionPath)
|
||||
if sessionPath == "" {
|
||||
|
||||
@@ -2,6 +2,8 @@ package taskmonitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -427,6 +429,32 @@ func TestInMemoryStore_IdempotencyClaimIsPendingUntilFinalized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStore_IdempotencyClaimQuarantinesCorruptRecord(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := NewFileStore(filepath.Join(".reasonix", "tasks"))
|
||||
key := "broken-key"
|
||||
idemDir := filepath.Join(root, ".reasonix", "tasks", ".idempotency")
|
||||
if err := os.MkdirAll(idemDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(idemDir, key+".json")
|
||||
if err := os.WriteFile(target, []byte(`{"pending":`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := IdempotencyRecord{Key: key, Op: "stop", TaskID: "t1", Version: 1}
|
||||
claimed, err := store.ClaimIdempotency(context.Background(), root, rec)
|
||||
if err != nil || claimed != nil {
|
||||
t.Fatalf("claim = %+v, err=%v; want fresh claim", claimed, err)
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("fresh claim was not published: %v", err)
|
||||
}
|
||||
backups, err := filepath.Glob(target + ".corrupt-*")
|
||||
if err != nil || len(backups) != 1 {
|
||||
t.Fatalf("corrupt record backups = %v, err=%v; want one quarantined record", backups, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlService_AuditSequenceMonotonic(t *testing.T) {
|
||||
s := NewInMemoryStore()
|
||||
cs := NewControlService(s)
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
// FileStore is a Store backed by a JSON file tree under a project-local
|
||||
@@ -568,6 +570,21 @@ func (s *FileStore) idempotencyPaths(projectDir, key string) (string, string, st
|
||||
return dir, target, lock, nil
|
||||
}
|
||||
|
||||
// quarantineCorruptIdempotency moves an unreadable record out of the active
|
||||
// key path without deleting it. A corrupt record cannot safely describe either
|
||||
// a pending or finalized operation; keeping it as evidence prevents it from
|
||||
// permanently blocking future claims while preserving forensic data.
|
||||
func quarantineCorruptIdempotency(target string) error {
|
||||
backup := fmt.Sprintf("%s.corrupt-%d", target, timeNow().UnixNano())
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileStore) ClaimIdempotency(ctx context.Context, projectDir string, r IdempotencyRecord) (*IdempotencyRecord, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -590,9 +607,11 @@ func (s *FileStore) ClaimIdempotency(ctx context.Context, projectDir string, r I
|
||||
if err == nil {
|
||||
var existing IdempotencyRecord
|
||||
if jsonErr := json.Unmarshal(data, &existing); jsonErr != nil {
|
||||
return nil, fmt.Errorf("idempotency claim: parse existing record: %w", jsonErr)
|
||||
}
|
||||
if existing.Pending && timeNow().Sub(existing.ClaimedAt) > 5*time.Minute {
|
||||
if quarantineErr := quarantineCorruptIdempotency(target); quarantineErr != nil {
|
||||
return nil, fmt.Errorf("idempotency claim: parse existing record: %w (quarantine: %w)", jsonErr, quarantineErr)
|
||||
}
|
||||
// Continue with a fresh claim after preserving the corrupt record.
|
||||
} else if existing.Pending && timeNow().Sub(existing.ClaimedAt) > 5*time.Minute {
|
||||
_ = os.Remove(target)
|
||||
} else {
|
||||
return &existing, nil
|
||||
@@ -608,7 +627,7 @@ func (s *FileStore) ClaimIdempotency(ctx context.Context, projectDir string, r I
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o600); err != nil {
|
||||
if err := fileutil.AtomicWriteFile(target, data, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Chmod(target, 0o600)
|
||||
@@ -648,7 +667,7 @@ func (s *FileStore) FinalizeIdempotency(ctx context.Context, projectDir string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0o600); err != nil {
|
||||
if err := fileutil.AtomicWriteFile(target, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(target, 0o600)
|
||||
@@ -717,30 +736,23 @@ func (s *FileStore) RecordIdempotency(ctx context.Context, projectDir string, r
|
||||
if err := rejectSymlink(target); err != nil {
|
||||
return err
|
||||
}
|
||||
// Atomic claim via O_EXCL: fail if file already exists
|
||||
f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
// File exists — read and compare
|
||||
existing, rdErr := os.ReadFile(target)
|
||||
if rdErr != nil {
|
||||
return fmt.Errorf("idempotency conflict: cannot read existing record: %w", rdErr)
|
||||
}
|
||||
var prev IdempotencyRecord
|
||||
if err := json.Unmarshal(existing, &prev); err != nil {
|
||||
return fmt.Errorf("idempotency conflict: cannot parse existing record: %w", err)
|
||||
}
|
||||
if prev.Op != r.Op || prev.TaskID != r.TaskID || prev.Version != r.Version {
|
||||
return fmt.Errorf("idempotency key conflict: different params")
|
||||
}
|
||||
return nil // idempotent
|
||||
}
|
||||
// Publish the complete record only when the key is still absent. A crash
|
||||
// during the old direct write could leave a permanently unparsable record.
|
||||
if err := fileutil.AtomicCreateFile(target, data, 0o600); err == nil {
|
||||
return nil
|
||||
} else if !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
os.Remove(target)
|
||||
return err
|
||||
existing, rdErr := os.ReadFile(target)
|
||||
if rdErr != nil {
|
||||
return fmt.Errorf("idempotency conflict: cannot read existing record: %w", rdErr)
|
||||
}
|
||||
return f.Close()
|
||||
var prev IdempotencyRecord
|
||||
if err := json.Unmarshal(existing, &prev); err != nil {
|
||||
return fmt.Errorf("idempotency conflict: cannot parse existing record: %w", err)
|
||||
}
|
||||
if prev.Op != r.Op || prev.TaskID != r.TaskID || prev.Version != r.Version {
|
||||
return fmt.Errorf("idempotency key conflict: different params")
|
||||
}
|
||||
return nil // idempotent
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
"limits": {
|
||||
"banner": 15,
|
||||
"commented-code": 0,
|
||||
"complexity": 2079,
|
||||
"essay": 4141,
|
||||
"file-size": 110524,
|
||||
"function-size": 9191,
|
||||
"complexity": 2078,
|
||||
"essay": 4130,
|
||||
"file-size": 110559,
|
||||
"function-size": 9161,
|
||||
"layering": 1,
|
||||
"marker": 0,
|
||||
"narrative": 64,
|
||||
"test-file-size": 68776
|
||||
"test-file-size": 68678
|
||||
},
|
||||
"files": {
|
||||
"cmd/e2ebench/main.go": {
|
||||
@@ -28,7 +28,7 @@
|
||||
"desktop/app.go": {
|
||||
"complexity": 63,
|
||||
"essay": 96,
|
||||
"file-size": 11751,
|
||||
"file-size": 11590,
|
||||
"function-size": 381
|
||||
},
|
||||
"desktop/app_autosave_test.go": {
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"desktop/app_test.go": {
|
||||
"essay": 2,
|
||||
"test-file-size": 10215
|
||||
"test-file-size": 10216
|
||||
},
|
||||
"desktop/bot_bridge.go": {
|
||||
"essay": 11
|
||||
@@ -84,7 +84,7 @@
|
||||
"essay": 2
|
||||
},
|
||||
"desktop/frontend/src/App.tsx": {
|
||||
"file-size": 4760
|
||||
"file-size": 4758
|
||||
},
|
||||
"desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": {
|
||||
"test-file-size": 19
|
||||
@@ -108,7 +108,7 @@
|
||||
"test-file-size": 352
|
||||
},
|
||||
"desktop/frontend/src/__tests__/tab-switch-hydration.test.tsx": {
|
||||
"test-file-size": 11
|
||||
"test-file-size": 162
|
||||
},
|
||||
"desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx": {
|
||||
"test-file-size": 106
|
||||
@@ -156,42 +156,42 @@
|
||||
"file-size": 1356
|
||||
},
|
||||
"desktop/frontend/src/custom/features/heartbeat/HeartbeatPanel.tsx": {
|
||||
"file-size": 413
|
||||
"file-size": 415
|
||||
},
|
||||
"desktop/frontend/src/lib/bridge.ts": {
|
||||
"file-size": 4722
|
||||
"file-size": 4644
|
||||
},
|
||||
"desktop/frontend/src/lib/crash.ts": {
|
||||
"file-size": 225
|
||||
},
|
||||
"desktop/frontend/src/lib/transcriptStore.ts": {
|
||||
"file-size": 263
|
||||
"file-size": 307
|
||||
},
|
||||
"desktop/frontend/src/lib/types.ts": {
|
||||
"file-size": 1462
|
||||
"file-size": 1419
|
||||
},
|
||||
"desktop/frontend/src/lib/useController.ts": {
|
||||
"file-size": 3808
|
||||
"file-size": 3916
|
||||
},
|
||||
"desktop/heartbeat.go": {
|
||||
"essay": 18,
|
||||
"file-size": 86,
|
||||
"essay": 20,
|
||||
"file-size": 349,
|
||||
"function-size": 23
|
||||
},
|
||||
"desktop/history_slice.go": {
|
||||
"banner": 2,
|
||||
"complexity": 8,
|
||||
"essay": 37,
|
||||
"file-size": 1012,
|
||||
"function-size": 18
|
||||
"file-size": 1025,
|
||||
"function-size": 20
|
||||
},
|
||||
"desktop/history_slice_test.go": {
|
||||
"banner": 12,
|
||||
"essay": 1,
|
||||
"test-file-size": 391
|
||||
"test-file-size": 371
|
||||
},
|
||||
"desktop/history_test.go": {
|
||||
"test-file-size": 1094
|
||||
"test-file-size": 1114
|
||||
},
|
||||
"desktop/internal/update/manifest.go": {
|
||||
"essay": 1
|
||||
@@ -301,10 +301,10 @@
|
||||
"essay": 1
|
||||
},
|
||||
"desktop/tabs.go": {
|
||||
"complexity": 73,
|
||||
"complexity": 74,
|
||||
"essay": 133,
|
||||
"file-size": 8848,
|
||||
"function-size": 627
|
||||
"file-size": 8872,
|
||||
"function-size": 630
|
||||
},
|
||||
"desktop/tabs_order_test.go": {
|
||||
"test-file-size": 395
|
||||
@@ -517,13 +517,13 @@
|
||||
"test-file-size": 289
|
||||
},
|
||||
"internal/agent/migrate.go": {
|
||||
"complexity": 30,
|
||||
"complexity": 32,
|
||||
"essay": 24,
|
||||
"file-size": 192,
|
||||
"function-size": 70
|
||||
"file-size": 198,
|
||||
"function-size": 73
|
||||
},
|
||||
"internal/agent/migrate_test.go": {
|
||||
"test-file-size": 297
|
||||
"test-file-size": 341
|
||||
},
|
||||
"internal/agent/normalize.go": {
|
||||
"essay": 14
|
||||
@@ -576,10 +576,10 @@
|
||||
"function-size": 46
|
||||
},
|
||||
"internal/agent/save.go": {
|
||||
"complexity": 51,
|
||||
"essay": 108,
|
||||
"file-size": 1428,
|
||||
"function-size": 144
|
||||
"complexity": 49,
|
||||
"essay": 113,
|
||||
"file-size": 1550,
|
||||
"function-size": 124
|
||||
},
|
||||
"internal/agent/save_test.go": {
|
||||
"essay": 9,
|
||||
@@ -599,11 +599,11 @@
|
||||
},
|
||||
"internal/agent/session_events.go": {
|
||||
"essay": 9,
|
||||
"file-size": 85
|
||||
"file-size": 86
|
||||
},
|
||||
"internal/agent/session_events_test.go": {
|
||||
"essay": 3,
|
||||
"test-file-size": 44
|
||||
"test-file-size": 33
|
||||
},
|
||||
"internal/agent/session_lease.go": {
|
||||
"essay": 18
|
||||
@@ -631,15 +631,15 @@
|
||||
},
|
||||
"internal/agent/subagent_store.go": {
|
||||
"essay": 5,
|
||||
"file-size": 171
|
||||
"file-size": 160
|
||||
},
|
||||
"internal/agent/subagent_store_test.go": {
|
||||
"test-file-size": 250
|
||||
},
|
||||
"internal/agent/task.go": {
|
||||
"complexity": 25,
|
||||
"essay": 56,
|
||||
"file-size": 1377,
|
||||
"essay": 55,
|
||||
"file-size": 1334,
|
||||
"function-size": 114
|
||||
},
|
||||
"internal/agent/task_test.go": {
|
||||
@@ -664,10 +664,6 @@
|
||||
"internal/agent/width.go": {
|
||||
"essay": 1
|
||||
},
|
||||
"internal/autoresearch/store.go": {
|
||||
"essay": 3,
|
||||
"file-size": 216
|
||||
},
|
||||
"internal/boot/boot.go": {
|
||||
"complexity": 293,
|
||||
"essay": 102,
|
||||
@@ -677,7 +673,7 @@
|
||||
},
|
||||
"internal/boot/boot_test.go": {
|
||||
"essay": 10,
|
||||
"test-file-size": 4331
|
||||
"test-file-size": 4254
|
||||
},
|
||||
"internal/boot/extension_dispatch_test.go": {
|
||||
"essay": 3,
|
||||
@@ -807,7 +803,7 @@
|
||||
"internal/cli/chat_tui.go": {
|
||||
"complexity": 300,
|
||||
"essay": 110,
|
||||
"file-size": 4551,
|
||||
"file-size": 4546,
|
||||
"function-size": 1196
|
||||
},
|
||||
"internal/cli/chat_tui_paste.go": {
|
||||
@@ -918,7 +914,7 @@
|
||||
"function-size": 153
|
||||
},
|
||||
"internal/cli/session_lease.go": {
|
||||
"essay": 3
|
||||
"essay": 4
|
||||
},
|
||||
"internal/cli/setup_manager.go": {
|
||||
"essay": 2,
|
||||
@@ -1059,22 +1055,19 @@
|
||||
"internal/control/approval.go": {
|
||||
"essay": 19
|
||||
},
|
||||
"internal/control/autoresearch_manager.go": {
|
||||
"essay": 2
|
||||
},
|
||||
"internal/control/checkpoint.go": {
|
||||
"essay": 9
|
||||
},
|
||||
"internal/control/controller.go": {
|
||||
"complexity": 11,
|
||||
"essay": 167,
|
||||
"file-size": 5339,
|
||||
"function-size": 77,
|
||||
"essay": 165,
|
||||
"file-size": 5453,
|
||||
"function-size": 78,
|
||||
"narrative": 4
|
||||
},
|
||||
"internal/control/controller_test.go": {
|
||||
"essay": 10,
|
||||
"test-file-size": 4507
|
||||
"test-file-size": 4671
|
||||
},
|
||||
"internal/control/errmsg.go": {
|
||||
"essay": 2
|
||||
@@ -1092,15 +1085,15 @@
|
||||
},
|
||||
"internal/control/goal.go": {
|
||||
"complexity": 7,
|
||||
"essay": 20,
|
||||
"file-size": 318,
|
||||
"essay": 11,
|
||||
"file-size": 287,
|
||||
"function-size": 7
|
||||
},
|
||||
"internal/control/goal_runtime_test.go": {
|
||||
"test-file-size": 14
|
||||
},
|
||||
"internal/control/goal_test.go": {
|
||||
"test-file-size": 607
|
||||
"test-file-size": 243
|
||||
},
|
||||
"internal/control/goalusage.go": {
|
||||
"essay": 2
|
||||
@@ -1113,7 +1106,7 @@
|
||||
},
|
||||
"internal/control/input_test.go": {
|
||||
"essay": 4,
|
||||
"test-file-size": 779
|
||||
"test-file-size": 773
|
||||
},
|
||||
"internal/control/mcp.go": {
|
||||
"essay": 7
|
||||
@@ -1164,9 +1157,9 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/control/turn_orchestrator.go": {
|
||||
"complexity": 4,
|
||||
"complexity": 3,
|
||||
"essay": 13,
|
||||
"function-size": 78
|
||||
"function-size": 61
|
||||
},
|
||||
"internal/control/turn_orchestrator_test.go": {
|
||||
"essay": 1,
|
||||
@@ -1182,7 +1175,7 @@
|
||||
"essay": 1
|
||||
},
|
||||
"internal/doctor/session_redact.go": {
|
||||
"essay": 11
|
||||
"essay": 12
|
||||
},
|
||||
"internal/environment/probe.go": {
|
||||
"essay": 5
|
||||
@@ -1425,15 +1418,15 @@
|
||||
"essay": 2
|
||||
},
|
||||
"internal/memory/store.go": {
|
||||
"essay": 9,
|
||||
"file-size": 94
|
||||
"essay": 6,
|
||||
"file-size": 22
|
||||
},
|
||||
"internal/memory/store_test.go": {
|
||||
"test-file-size": 138
|
||||
},
|
||||
"internal/memory/store_v2.go": {
|
||||
"complexity": 17,
|
||||
"function-size": 18
|
||||
"complexity": 16,
|
||||
"function-size": 16
|
||||
},
|
||||
"internal/permission/bash_decompose.go": {
|
||||
"essay": 23
|
||||
|
||||
Reference in new issue
Block a user