Skip to content

Commit fba9f32

Browse files
committed
fix: rehydration now derives epoch purely from persisted history instead of stale in-memory state
1 parent 12fac50 commit fba9f32

3 files changed

Lines changed: 108 additions & 10 deletions

File tree

agenticoding.test.ts

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1832,7 +1832,7 @@ test("notebook rehydration rebuilds the latest epoch and enables notebook tools"
18321832
});
18331833

18341834

1835-
test("notebook rehydration respects a preset epoch and avoids duplicate active tools", async () => {
1835+
test("notebook rehydration rebuilds from the latest persisted epoch and avoids duplicate active tools", async () => {
18361836
const pi = new MockPi();
18371837
pi.activeTools = ["read", "notebook_read", "notebook_index"];
18381838
const state = createState();
@@ -1847,17 +1847,87 @@ test("notebook rehydration respects a preset epoch and avoids duplicate active t
18471847
getBranch: () => [
18481848
{ type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } },
18491849
{ type: "custom", customType: "notebook-entry", data: { epoch: 7, name: "keep", content: "fresh" } },
1850-
{ type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "future", content: "skip" } },
1850+
{ type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "future", content: "latest" } },
18511851
],
18521852
},
18531853
},
18541854
);
18551855

1856-
assert.equal(state.epoch, 7);
1857-
assert.deepEqual(Array.from(state.notebookPages.entries()), [["keep", "fresh"]]);
1856+
assert.equal(state.epoch, 8);
1857+
assert.deepEqual(Array.from(state.notebookPages.entries()), [["future", "latest"]]);
18581858
assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]);
18591859
});
18601860

1861+
1862+
test("notebook rehydration clears stale in-memory notebook state when persisted history is empty", async () => {
1863+
const pi = new MockPi();
1864+
const state = createState();
1865+
state.epoch = 7;
1866+
state.notebookPages.set("stale", "stale body");
1867+
registerNotebookRehydration(pi as any, state);
1868+
const [handler] = pi.handlers.get("session_start")!;
1869+
1870+
await handler(
1871+
{},
1872+
{
1873+
sessionManager: {
1874+
getBranch: () => [],
1875+
},
1876+
},
1877+
);
1878+
1879+
assert.equal(state.epoch, 0);
1880+
assert.deepEqual(Array.from(state.notebookPages.entries()), []);
1881+
assert.deepEqual(pi.activeTools, ["notebook_read", "notebook_index"]);
1882+
});
1883+
1884+
1885+
test("session_start rehydrates the latest persisted notebook state through the full hook chain", async () => {
1886+
resetNotebookWriteLock();
1887+
const pi = new MockPi();
1888+
pi.activeTools = ["read", "notebook_read"];
1889+
registerAgenticoding(pi as any);
1890+
1891+
try {
1892+
const notebookWrite = pi.tools.get("notebook_write");
1893+
await notebookWrite.execute(
1894+
"seed",
1895+
{ name: "stale-page", content: "stale body" },
1896+
undefined,
1897+
undefined,
1898+
makeTUICtx({ hasUI: false }),
1899+
);
1900+
1901+
const sessionStartHandlers = pi.handlers.get("session_start")!;
1902+
const ctx = {
1903+
hasUI: false,
1904+
getContextUsage: () => null,
1905+
sessionManager: {
1906+
getBranch: () => [
1907+
{ type: "custom", customType: "notebook-entry", data: { epoch: 6, name: "stale", content: "old" } },
1908+
{ type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "fresh" } },
1909+
{ type: "custom", customType: "notebook-entry", data: { epoch: 8, name: "keep", content: "newer" } },
1910+
],
1911+
},
1912+
};
1913+
for (const sessionStart of sessionStartHandlers) {
1914+
await sessionStart({ reason: "resume" }, ctx as any);
1915+
}
1916+
1917+
const notebookIndex = pi.tools.get("notebook_index");
1918+
const notebookRead = pi.tools.get("notebook_read");
1919+
const indexResult = await notebookIndex.execute("1", {}, undefined, undefined, {} as any);
1920+
assert.deepEqual(indexResult.details.entries, ["keep"]);
1921+
1922+
const readResult = await notebookRead.execute("2", { name: "keep" }, undefined, undefined, {} as any);
1923+
assert.equal(readResult.details.found, true);
1924+
assert.equal(readResult.details.body, "newer");
1925+
assert.deepEqual(pi.activeTools, ["read", "notebook_read", "notebook_index"]);
1926+
} finally {
1927+
resetNotebookWriteLock();
1928+
}
1929+
});
1930+
18611931
test("notebook tools add/get/list return stable contract details", async () => {
18621932
const pi = new MockPi();
18631933
const state = createState();
@@ -2189,6 +2259,34 @@ test("saveNotebookPage truncates oversized content before persisting", async ()
21892259
resetNotebookWriteLock();
21902260
});
21912261

2262+
2263+
test("resetState clears epoch and the next notebook write starts a fresh generation", async () => {
2264+
resetNotebookWriteLock();
2265+
const pi = new MockPi();
2266+
const state = createState();
2267+
const originalNow = Date.now;
2268+
2269+
try {
2270+
Date.now = () => 1000;
2271+
await saveNotebookPage(pi as any, state, "entry-a", "first");
2272+
await saveNotebookPage(pi as any, state, "entry-b", "second");
2273+
assert.equal(state.epoch, 1000);
2274+
assert.equal(pi.appendedEntries[0].data.epoch, 1000);
2275+
assert.equal(pi.appendedEntries[1].data.epoch, 1000);
2276+
2277+
resetState(state);
2278+
assert.equal(state.epoch, 0);
2279+
2280+
Date.now = () => 2000;
2281+
await saveNotebookPage(pi as any, state, "entry-c", "third");
2282+
assert.equal(state.epoch, 2000);
2283+
assert.equal(pi.appendedEntries[2].data.epoch, 2000);
2284+
} finally {
2285+
Date.now = originalNow;
2286+
resetNotebookWriteLock();
2287+
}
2288+
});
2289+
21922290
test("nested spawn invalidate rebuilds from the attached session transcript", () => {
21932291
const state = createState();
21942292
const childSpawnTool = createChildSpawnTool(state);

notebook/rehydration.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,16 @@ export function registerNotebookRehydration(
6262
});
6363
}
6464

65-
// Pick the epoch from the newest candidate across all names
66-
let currentEpoch = state.epoch;
65+
// Rehydrate from persisted history: branch entries are the durable
66+
// notebook source of truth. Pick the latest persisted epoch across the
67+
// surviving names, then rebuild the in-memory view from that generation.
68+
let currentEpoch = 0;
6769
for (const candidate of candidates.values()) {
6870
if (candidate.epoch > currentEpoch) {
6971
currentEpoch = candidate.epoch;
7072
}
7173
}
72-
if (currentEpoch > state.epoch) {
73-
state.epoch = currentEpoch;
74-
}
74+
state.epoch = currentEpoch;
7575

7676
// Rebuild state.notebookPages, filtering by epoch
7777
state.notebookPages.clear();

state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function createState(): AgenticodingState {
104104
export function resetState(state: AgenticodingState): void {
105105
state.childSessionEpoch++;
106106
state.notebookPages.clear();
107-
state.epoch = 0;
107+
state.epoch = 0; // sentinel: 0 = not yet initialized; set to Date.now() on first write
108108
state.activeNotebookTopic = null;
109109
state.activeNotebookTopicSource = null;
110110
state.pendingTopicBoundaryHint = null;

0 commit comments

Comments
 (0)