diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 71f14c14a9..ae2c27bce7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,11 +1,11 @@ # Default reviewers -* @lidge-jun @Ingwannu @Wibias +* @lidge-jun @Ingwannu # High-impact runtime behavior -/src/adapters/ @lidge-jun @Ingwannu @Wibias -/src/providers/ @lidge-jun @Ingwannu @Wibias -/src/codex/ @lidge-jun @Ingwannu @Wibias -/src/server/ @lidge-jun @Ingwannu @Wibias +/src/adapters/ @lidge-jun @Ingwannu +/src/providers/ @lidge-jun @Ingwannu +/src/codex/ @lidge-jun @Ingwannu +/src/server/ @lidge-jun @Ingwannu # Repository automation and release security /.github/ @lidge-jun @Ingwannu diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 3214adfdf1..43377c093d 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -9,7 +9,6 @@ review and merge policy. | --- | --- | --- | | [@lidge-jun](https://github.com/lidge-jun) | Project owner | Project direction, releases, repository administration, and final governance decisions | | [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` integration, security review, and repository maintenance | -| [@Wibias](https://github.com/Wibias) | Maintainer | Issue and pull-request triage, `dev` integration, and provider/CI maintenance | The table describes project responsibilities. Actual repository permissions remain controlled through GitHub repository settings. @@ -17,6 +16,16 @@ through GitHub repository settings. `dev` is the only integration line. The former `dev2-go` carry duty is retired; see [The retired `dev2-go` line](#the-retired-dev2-go-line). +## Former maintainers + +| GitHub account | Project role | Period | +| --- | --- | --- | +| [@Wibias](https://github.com/Wibias) | Maintainer | 2026-07-27 – 2026-08-19 | + +Former maintainers keep contributor standing and are welcome to open issues and pull requests like +anyone else. Authorship credit in git history, release notes, and code comments is not rewritten +when a maintainer steps down. + ## Review and merge policy - Pull requests target `dev`. It is the only integration line, and promotion to @@ -98,6 +107,24 @@ Adding or removing a maintainer requires: ### Change log +- 2026-08-19 — [@Wibias](https://github.com/Wibias) stepped down as a maintainer + and is now a contributor. This follows his own decision to stop developing + opencodex; it is not a disciplinary action, and it was made with the owner's + agreement (requirement 1). Requirement 2 does not apply to a maintainer's own + resignation, which needs no second maintainer to ratify it. Requirement 3 is + met by this file and `.github/CODEOWNERS`, where the default-reviewer line + and the four runtime paths that listed him (`/src/adapters/`, + `/src/providers/`, `/src/codex/`, `/src/server/`) drop back to the two + remaining maintainers. Repository permission was reduced to read access at + the same time, so the roster and the GitHub settings agree again. + + Nothing he authored is being unwound. His commits, the pull requests he + merged, the release-note attributions, and the code comments citing his + reviews stay exactly as they are, and the trust-lane gate derived from his + work in `.github/scripts/pr-sponsored-surface.cjs` keeps its attribution. + Returning to the maintainer table later would go through the same three + requirements that govern every addition. + - 2026-07-27 — [@Wibias](https://github.com/Wibias) added as a maintainer. Requirement 1 (agreement from the project owner) is met: the owner requested the addition. **Requirement 2 (review by another current maintainer) was @@ -105,10 +132,10 @@ Adding or removing a maintainer requires: carried the addition (`a2693c02`, `dc3a4ade`, `02bbd47a`) landed on `dev` as direct owner pushes with no associated pull request, so no second maintainer reviewed them. Requirement 3 is met by this file and `.github/CODEOWNERS`. - The addition is in effect regardless: @Wibias holds write access on the - repository and has been merging pull requests since 2026-07-26. This entry - records the gap rather than papering over it — a later maintainer change - should go through a reviewed pull request. + The addition took effect regardless: @Wibias held write access on the + repository and merged pull requests from 2026-07-26 until he stepped down on + 2026-08-19. This entry records the gap rather than papering over it — a later + maintainer change should go through a reviewed pull request. Scope covers issue and pull-request triage, `dev` integration, and provider/CI maintenance. (This entry originally also described carrying diff --git a/src/responses/thought-signature-replay.ts b/src/responses/thought-signature-replay.ts index 7df65d25e6..08e30bf103 100644 --- a/src/responses/thought-signature-replay.ts +++ b/src/responses/thought-signature-replay.ts @@ -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(); let totalBytes = 0; let loaded = false; let persistChain: Promise = 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; } } catch { // Corrupt store: ignore it; a later remember() rewrites a clean snapshot. @@ -190,7 +200,7 @@ 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; @@ -198,21 +208,29 @@ function prune(nowMs: number): void { 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 { - 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; }); 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 { diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index 499c7c8c3f..e3f4e0738f 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -14,6 +14,7 @@ import { lookupReplayThoughtSignature, rememberThoughtSignatureForReplay, resetThoughtSignatureReplayForTests, + thoughtSignatureReplayCountForTests, } from "../src/responses/thought-signature-replay"; import { durableReplayDestinationIdentity } from "../src/responses/reasoning-replay-cache"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; @@ -300,6 +301,30 @@ describe("#1735 thought signature survives history replay", () => { await durable; expect(lookupReplayThoughtSignature("call_durable", scopeFor())).toBe(SIGNATURE); }); + + test("bursty remembers share one coalesced persistence worker", async () => { + const first = rememberThoughtSignatureForReplay("call_batch_1", SIGNATURE, scopeFor()); + const second = rememberThoughtSignatureForReplay("call_batch_2", SIGNATURE_B, scopeFor()); + + expect(second.durable).toBe(first.durable); + await second.durable; + expect(lookupReplayThoughtSignature("call_batch_1", scopeFor())).toBe(SIGNATURE); + expect(lookupReplayThoughtSignature("call_batch_2", scopeFor())).toBe(SIGNATURE_B); + }); + + test("bounds the serialized snapshot even when signatures approach the wire limit", async () => { + const largeSignature = "s".repeat(64 * 1024); + let durable = Promise.resolve(); + for (let index = 0; index < 70; index++) { + ({ durable } = rememberThoughtSignatureForReplay(`call_large_${index}`, largeSignature, scopeFor())); + } + + await durable; + expect(thoughtSignatureReplayCountForTests()).toBeLessThan(70); + expect(lookupReplayThoughtSignature("call_large_0", scopeFor())).toBeUndefined(); + expect(lookupReplayThoughtSignature("call_large_69", scopeFor())).toBe(largeSignature); + }); + test("the proxy-side store survives a process restart via its snapshot", async () => { rememberThoughtSignatureForReplay("call_disk_1", SIGNATURE, scopeFor()); await flushThoughtSignatureReplayForTests();