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:
SivanCola authored and GitHub committed 2026-08-09 09:24:25 +08:00
commit 118d10c44c
55 files changed
+3344 -607

No files matched your search

+49 -5
View File
@@ -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
View File
@@ -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> {
+4
View File
@@ -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 []; },
+46 -2
View File
@@ -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 {
+9 -1
View File
@@ -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;
+132 -20
View File
@@ -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;
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+21 -8
View File
@@ -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
}
+11 -31
View File
@@ -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)
}
+81
View File
@@ -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)
}
}
+20
View File
@@ -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"},
+2 -1
View File
@@ -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)
}
+6 -3
View File
@@ -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
+6 -3
View File
@@ -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
View File
@@ -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)
+4 -3
View File
@@ -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)
}
}
+2 -1
View File
@@ -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
View File
@@ -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
}
+103 -3
View File
@@ -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
View File
@@ -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
+99
View File
@@ -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
}
+66
View File
@@ -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)
}
}
+44
View File
@@ -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 {
+12 -9
View File
@@ -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
View File
@@ -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.
+28
View File
@@ -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()
+21
View File
@@ -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
+554
View File
@@ -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]))
})
}
}
+2 -1
View File
@@ -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
}
+15 -26
View File
@@ -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))
}
}
+32 -4
View File
@@ -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) {
+6 -3
View File
@@ -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)
}
}
+2 -1
View File
@@ -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)
}
}
+6 -3
View File
@@ -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)
+5 -4
View File
@@ -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
View File
@@ -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 {
+205 -40
View File
@@ -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)
+4
View File
@@ -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()
+9 -18
View File
@@ -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 {
+11 -4
View File
@@ -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
+14
View File
@@ -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)
}
+2 -2
View File
@@ -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
}
}
+2 -1
View File
@@ -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 == "" {
+28
View File
@@ -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)
+40 -28
View File
@@ -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
}
+54 -61
View File
@@ -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