-
Notifications
You must be signed in to change notification settings - Fork 0
[WRONG BRANCH] Bound thought-signature replay persistence and coalesce snapshot writes #305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,16 +41,16 @@ const STORE_VERSION = 4; | |
| /** Bound on remembered entries; real signatures are a few hundred bytes, so this stays small. */ | ||
| const MAX_ENTRIES = 16_384; | ||
| /** | ||
| * Total bytes of remembered signature material. | ||
| * Total serialized bytes of remembered entries. | ||
| * | ||
| * An entry count alone is not a memory bound: a single signature may be 64KiB, so 16,384 | ||
| * entries is a ~1GiB ceiling. This is the bound that actually holds. | ||
| */ | ||
| const MAX_TOTAL_BYTES = 32 * 1024 * 1024; | ||
| const MAX_TOTAL_BYTES = 4 * 1024 * 1024; | ||
| /** A signature is needed for the immediate next turn; a long TTL also covers resumed threads. */ | ||
| const TTL_MS = 7 * 24 * 60 * 60 * 1000; | ||
|
|
||
| type StoredEntry = { sig: string; savedAt: number }; | ||
| type StoredEntry = { sig: string; savedAt: number; storageBytes: number }; | ||
|
|
||
| /** Outcome of a remember attempt. `conflict` is a real signal, not a no-op. */ | ||
| export type ThoughtSignatureRememberResult = | ||
|
|
@@ -64,6 +64,8 @@ let entries = new Map<string, StoredEntry>(); | |
| let totalBytes = 0; | ||
| let loaded = false; | ||
| let persistChain: Promise<void> = Promise.resolve(); | ||
| let persistRunning = false; | ||
| let persistDirty = false; | ||
|
|
||
| function storePath(): string { | ||
| return join(getConfigDir(), STORE_FILE_NAME); | ||
|
|
@@ -112,6 +114,12 @@ function nonEmpty(value: unknown): value is string { | |
| return typeof value === "string" && value.trim().length > 0; | ||
| } | ||
|
|
||
| function storageBytes(key: string, sig: string, savedAt: number): number { | ||
| // Count the serialized representation rather than string code units. This also bounds | ||
| // provider/client-controlled key fields and JSON escaping overhead in the disk snapshot. | ||
| return Buffer.byteLength(JSON.stringify({ key, sig, savedAt }), "utf8") + 1; | ||
| } | ||
|
|
||
| /** | ||
| * Durable key for one call, or `undefined` when the scope is incomplete. | ||
| * | ||
|
|
@@ -175,8 +183,10 @@ function load(): void { | |
| if (typeof key !== "string" || typeof sig !== "string" || typeof savedAt !== "number") continue; | ||
| if (savedAt <= nowMs - TTL_MS) continue; | ||
| if (!isCarryableSignature(sig)) continue; | ||
| entries.set(key, { sig, savedAt }); | ||
| totalBytes += sig.length; | ||
| const entryBytes = storageBytes(key, sig, savedAt); | ||
| if (entryBytes > MAX_TOTAL_BYTES) continue; | ||
| entries.set(key, { sig, savedAt, storageBytes: entryBytes }); | ||
| totalBytes += entryBytes; | ||
|
Comment on lines
+186
to
+189
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Persist entries removed during
Make 🤖 Prompt for AI Agents |
||
| } | ||
| } catch { | ||
| // Corrupt store: ignore it; a later remember() rewrites a clean snapshot. | ||
|
|
@@ -190,29 +200,37 @@ function prune(nowMs: number): void { | |
| for (const [key, entry] of entries) { | ||
| if (nowMs - entry.savedAt > TTL_MS) { | ||
| entries.delete(key); | ||
| totalBytes -= entry.sig.length; | ||
| totalBytes -= entry.storageBytes; | ||
| } | ||
| } | ||
| if (entries.size <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) return; | ||
| const sorted = [...entries.entries()].sort((a, b) => a[1].savedAt - b[1].savedAt); | ||
| for (const [key, entry] of sorted) { | ||
| if (entries.size <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) break; | ||
| entries.delete(key); | ||
| totalBytes -= entry.sig.length; | ||
| totalBytes -= entry.storageBytes; | ||
| } | ||
| } | ||
|
|
||
| function persist(): Promise<void> { | ||
| persistChain = persistChain | ||
| .then(async () => { | ||
| persistDirty = true; | ||
| if (persistRunning) return persistChain; | ||
| persistRunning = true; | ||
| persistChain = (async () => { | ||
| while (persistDirty) { | ||
| persistDirty = false; | ||
| const snapshot = JSON.stringify({ | ||
| version: STORE_VERSION, | ||
| entries: [...entries].map(([key, entry]) => ({ key, sig: entry.sig, savedAt: entry.savedAt })), | ||
| }); | ||
| await atomicWriteFileAsync(storePath(), snapshot); | ||
| }) | ||
| } | ||
| })() | ||
| .catch(() => { | ||
| // Best-effort persistence: the in-memory store still serves the running process. | ||
| }) | ||
| .finally(() => { | ||
| persistRunning = false; | ||
|
Comment on lines
+232
to
+233
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a concurrent response calls Useful? React with 👍 / 👎. |
||
| }); | ||
| return persistChain; | ||
| } | ||
|
|
@@ -241,8 +259,11 @@ export function rememberThoughtSignatureForReplay( | |
| if (existing.sig === signature) return { result: "already-equal", durable: Promise.resolve() }; | ||
| return { result: "conflict", durable: Promise.resolve() }; | ||
| } | ||
| entries.set(key, { sig: signature, savedAt: Date.now() }); | ||
| totalBytes += signature.length; | ||
| const savedAt = Date.now(); | ||
| const entryBytes = storageBytes(key, signature, savedAt); | ||
| if (entryBytes > MAX_TOTAL_BYTES) return { result: "ignored", durable: Promise.resolve() }; | ||
| entries.set(key, { sig: signature, savedAt, storageBytes: entryBytes }); | ||
| totalBytes += entryBytes; | ||
| prune(Date.now()); | ||
| return { result: "stored", durable: persist() }; | ||
| } | ||
|
|
@@ -302,7 +323,7 @@ export function lookupReplayThoughtSignature( | |
| if (!entry) return undefined; | ||
| if (Date.now() - entry.savedAt > TTL_MS) { | ||
| entries.delete(key); | ||
| totalBytes -= entry.sig.length; | ||
| totalBytes -= entry.storageBytes; | ||
| return undefined; | ||
| } | ||
| return entry.sig; | ||
|
|
@@ -316,6 +337,8 @@ export function resetThoughtSignatureReplayForTests(): void { | |
| persistChain = Promise.resolve(); | ||
| cachedSalt = undefined; | ||
| saltLoaded = false; | ||
| persistRunning = false; | ||
| persistDirty = false; | ||
| } | ||
|
|
||
| export function thoughtSignatureReplayCountForTests(): number { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Include the JSON snapshot envelope in the byte limit.
storageBytes()counts each entry and one separator byte.persist()also writes the fixed{"version":2,"entries":[prefix and]}suffix. For every non-empty snapshot, the serialized payload is 25 bytes larger thantotalBytes, so Line 141 can accept a snapshot larger than the 4 MiB limit.Track the fixed envelope overhead in the size predicate. Apply the same predicate to the single-entry rejection at Line 199. Add a test that checks the UTF-8 byte length of the persisted JSON file.
🤖 Prompt for AI Agents