@bitkyc08/opencodex 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/responses/state.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { atomicWriteFileAsync, getConfigDir } from "../config";
|
|
4
|
+
import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
|
|
4
5
|
import type { OcxProviderContinuationState } from "../types";
|
|
6
|
+
import {
|
|
7
|
+
deleteResponseSpill,
|
|
8
|
+
noteStubSwapForTest,
|
|
9
|
+
readResponseSpill,
|
|
10
|
+
recoverOrphanedResponseSpills,
|
|
11
|
+
responseSpillDirectory,
|
|
12
|
+
type ResponseSpillRef,
|
|
13
|
+
writeResponseSpillDurably,
|
|
14
|
+
} from "./spill-store";
|
|
5
15
|
|
|
6
16
|
const MAX_STORED_RESPONSES = 1_000;
|
|
7
17
|
const RESPONSE_TTL_MS = 60 * 60 * 1_000;
|
|
@@ -9,31 +19,65 @@ const SNAPSHOT_DEBOUNCE_MS = 2_000;
|
|
|
9
19
|
/** In-memory high-water byte cap across all entries. Forced store:false retention (kiro/cursor
|
|
10
20
|
* continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
|
|
11
21
|
* so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
|
|
12
|
-
const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
13
|
-
/**
|
|
14
|
-
* carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */
|
|
22
|
+
export const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
23
|
+
/** Legacy snapshot selection only. Spill demotion is governed solely by the RAM cap above. */
|
|
15
24
|
const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
|
|
16
25
|
const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
|
|
17
26
|
const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
|
|
18
27
|
const STALE_TEMP_MAX_ENTRIES = 4_096;
|
|
19
28
|
const STALE_TEMP_MAX_CLEANUPS = 512;
|
|
20
29
|
const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
|
|
30
|
+
const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
|
|
21
31
|
|
|
22
|
-
interface
|
|
32
|
+
interface ResidentResponseState {
|
|
33
|
+
kind: "resident";
|
|
23
34
|
createdAt: number;
|
|
24
35
|
items: unknown[];
|
|
25
|
-
/** v2 provider-keyed continuation metadata. */
|
|
26
36
|
providers?: OcxProviderContinuationState;
|
|
27
|
-
|
|
28
|
-
conversationId?: string;
|
|
29
|
-
cursorCheckpointUsable?: boolean;
|
|
30
|
-
/** Approximate in-memory size, computed locally at insert time (never trusted from disk). */
|
|
31
|
-
sizeBytes?: number;
|
|
37
|
+
sizeBytes: number;
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
interface SpilledResponseState {
|
|
41
|
+
kind: "spill";
|
|
42
|
+
createdAt: number;
|
|
43
|
+
providers?: OcxProviderContinuationState;
|
|
44
|
+
spill: ResponseSpillRef;
|
|
45
|
+
sizeBytes: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SpillFailedResponseState {
|
|
49
|
+
kind: "spill-failed";
|
|
50
|
+
createdAt: number;
|
|
51
|
+
sizeBytes: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type StoredResponseState = ResidentResponseState | SpilledResponseState | SpillFailedResponseState;
|
|
55
|
+
type ResidentInput = Omit<ResidentResponseState, "kind" | "sizeBytes">;
|
|
56
|
+
|
|
57
|
+
export type PreviousResponseReplayFailure = {
|
|
58
|
+
code: "previous_response_not_found";
|
|
59
|
+
reason: "spill_missing" | "spill_corrupt" | "spill_failed";
|
|
60
|
+
};
|
|
61
|
+
|
|
34
62
|
const states = new Map<string, StoredResponseState>();
|
|
35
63
|
let storedResponseBytes = 0;
|
|
64
|
+
let residentResponseBytes = 0;
|
|
65
|
+
let oldestResidentId: string | undefined;
|
|
66
|
+
let oldestResidentAt: number | null = null;
|
|
36
67
|
let byteCapOverride: number | null = null;
|
|
68
|
+
let stateRevision = 0;
|
|
69
|
+
const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
|
|
70
|
+
// Superseded spill generations awaiting a durable snapshot before unlink
|
|
71
|
+
// (review C1-1: unlinking at swap time races a crash against the debounced
|
|
72
|
+
// snapshot — the reloaded OLD stub would point at a deleted file).
|
|
73
|
+
const pendingSpillUnlinks: ResponseSpillRef[] = [];
|
|
74
|
+
// The queue itself must stay bounded (review C2-2: repeated replacements with
|
|
75
|
+
// a persistently failing snapshot write would otherwise grow it without
|
|
76
|
+
// limit). Beyond the cap the OLDEST superseded generation is unlinked
|
|
77
|
+
// immediately: the accepted worst case is that a crash inside that window
|
|
78
|
+
// reloads a stub whose file is gone, which fails replay with the explicit
|
|
79
|
+
// structured 400 — bounded-loss, never silent corruption or unbounded disk.
|
|
80
|
+
const PENDING_SPILL_UNLINKS_MAX = 128;
|
|
37
81
|
|
|
38
82
|
function byteCap(): number {
|
|
39
83
|
return byteCapOverride ?? MAX_STORED_RESPONSE_BYTES;
|
|
@@ -49,42 +93,184 @@ export function getStoredResponseBytesForTests(): number {
|
|
|
49
93
|
return storedResponseBytes;
|
|
50
94
|
}
|
|
51
95
|
|
|
52
|
-
|
|
53
|
-
function measuredEntry(entry: Omit<StoredResponseState, "sizeBytes">): StoredResponseState {
|
|
54
|
-
let sizeBytes = 0;
|
|
96
|
+
function serializedBytes(value: unknown): number | null {
|
|
55
97
|
try {
|
|
56
|
-
|
|
98
|
+
const serialized = JSON.stringify(value);
|
|
99
|
+
return serialized === undefined ? null : Buffer.byteLength(serialized, "utf8");
|
|
57
100
|
} catch {
|
|
58
|
-
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function measureResidentEntry(id: string, entry: ResidentInput): ResidentResponseState | null {
|
|
106
|
+
const sizeBytes = serializedBytes({
|
|
107
|
+
responseId: id,
|
|
108
|
+
createdAt: entry.createdAt,
|
|
109
|
+
items: entry.items,
|
|
110
|
+
...(entry.providers ? { providers: entry.providers } : {}),
|
|
111
|
+
});
|
|
112
|
+
return sizeBytes === null ? null : { kind: "resident", ...entry, sizeBytes };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function recomputeOldestResident(): void {
|
|
116
|
+
oldestResidentId = undefined;
|
|
117
|
+
oldestResidentAt = null;
|
|
118
|
+
for (const [id, state] of states) {
|
|
119
|
+
if (state.kind !== "resident") continue;
|
|
120
|
+
if (oldestResidentAt !== null && state.createdAt >= oldestResidentAt) continue;
|
|
121
|
+
oldestResidentId = id;
|
|
122
|
+
oldestResidentAt = state.createdAt;
|
|
59
123
|
}
|
|
60
|
-
return { ...entry, sizeBytes };
|
|
61
124
|
}
|
|
62
125
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
storedResponseBytes +=
|
|
68
|
-
|
|
126
|
+
function replaceMapEntry(id: string, next: StoredResponseState, expected?: StoredResponseState): boolean {
|
|
127
|
+
const existing = states.get(id);
|
|
128
|
+
if (expected && existing !== expected) return false;
|
|
129
|
+
storedResponseBytes -= existing?.sizeBytes ?? 0;
|
|
130
|
+
storedResponseBytes += next.sizeBytes;
|
|
131
|
+
if (existing?.kind === "resident") {
|
|
132
|
+
residentResponseBytes -= existing.sizeBytes;
|
|
133
|
+
}
|
|
134
|
+
if (next.kind === "resident") {
|
|
135
|
+
residentResponseBytes += next.sizeBytes;
|
|
136
|
+
}
|
|
137
|
+
if (storedResponseBytes < 0) storedResponseBytes = 0;
|
|
138
|
+
if (residentResponseBytes < 0) residentResponseBytes = 0;
|
|
139
|
+
if (existing) states.delete(id);
|
|
140
|
+
states.set(id, next);
|
|
141
|
+
if (oldestResidentId === id) {
|
|
142
|
+
recomputeOldestResident();
|
|
143
|
+
} else if (next.kind === "resident" && (oldestResidentAt === null || next.createdAt < oldestResidentAt)) {
|
|
144
|
+
oldestResidentId = id;
|
|
145
|
+
oldestResidentAt = next.createdAt;
|
|
146
|
+
}
|
|
147
|
+
stateRevision += 1;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stubSize(id: string, entry: Omit<SpilledResponseState, "sizeBytes">): number {
|
|
152
|
+
return serializedBytes({ responseId: id, ...entry }) ?? 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function tombstone(id: string, createdAt: number): SpillFailedResponseState {
|
|
156
|
+
const base = { kind: "spill-failed" as const, createdAt };
|
|
157
|
+
return { ...base, sizeBytes: serializedBytes({ responseId: id, ...base }) ?? 0 };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function deleteOwnedSpills(entry: StoredResponseState): void {
|
|
161
|
+
if (entry.kind === "spill") deleteResponseSpill(entry.spill);
|
|
69
162
|
}
|
|
70
163
|
|
|
71
164
|
/** The ONLY deletion point: TTL, count, byte, and explicit deletes all route here. */
|
|
72
|
-
function deleteEntry(id: string): void {
|
|
165
|
+
function deleteEntry(id: string, options: { deleteSpill?: boolean } = {}): void {
|
|
73
166
|
const existing = states.get(id);
|
|
74
167
|
if (!existing) return;
|
|
75
|
-
storedResponseBytes -= existing.sizeBytes
|
|
168
|
+
storedResponseBytes -= existing.sizeBytes;
|
|
169
|
+
if (existing.kind === "resident") {
|
|
170
|
+
residentResponseBytes -= existing.sizeBytes;
|
|
171
|
+
}
|
|
76
172
|
if (storedResponseBytes < 0) storedResponseBytes = 0;
|
|
173
|
+
if (residentResponseBytes < 0) residentResponseBytes = 0;
|
|
77
174
|
states.delete(id);
|
|
175
|
+
if (oldestResidentId === id) recomputeOldestResident();
|
|
176
|
+
stateRevision += 1;
|
|
177
|
+
if (options.deleteSpill !== false) deleteOwnedSpills(existing);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function replaceWithSpillFailure(id: string, expected?: StoredResponseState): void {
|
|
181
|
+
const existing = states.get(id);
|
|
182
|
+
if (expected && existing !== expected) return;
|
|
183
|
+
const failed = tombstone(id, expected?.createdAt ?? existing?.createdAt ?? now());
|
|
184
|
+
if (replaceMapEntry(id, failed, expected)) {
|
|
185
|
+
if (existing) deleteOwnedSpills(existing);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function swapResidentForSpill(id: string, expected: ResidentResponseState, ref: ResponseSpillRef): boolean {
|
|
190
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
191
|
+
kind: "spill",
|
|
192
|
+
createdAt: expected.createdAt,
|
|
193
|
+
...(expected.providers ? { providers: expected.providers } : {}),
|
|
194
|
+
spill: ref,
|
|
195
|
+
};
|
|
196
|
+
const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
|
|
197
|
+
if (!replaceMapEntry(id, next, expected)) {
|
|
198
|
+
deleteResponseSpill(ref);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
noteStubSwapForTest();
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function replaceSpillEntryAtomically(
|
|
206
|
+
id: string,
|
|
207
|
+
expected: SpilledResponseState,
|
|
208
|
+
candidate: ResidentResponseState,
|
|
209
|
+
): void {
|
|
210
|
+
try {
|
|
211
|
+
const ref = writeResponseSpillDurably(id, {
|
|
212
|
+
createdAt: candidate.createdAt,
|
|
213
|
+
items: candidate.items,
|
|
214
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
215
|
+
});
|
|
216
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
217
|
+
kind: "spill",
|
|
218
|
+
createdAt: candidate.createdAt,
|
|
219
|
+
...(candidate.providers ? { providers: candidate.providers } : {}),
|
|
220
|
+
spill: ref,
|
|
221
|
+
};
|
|
222
|
+
const next: SpilledResponseState = { ...base, sizeBytes: stubSize(id, base) };
|
|
223
|
+
if (!replaceMapEntry(id, next, expected)) {
|
|
224
|
+
deleteResponseSpill(ref);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
spillCounters.writes += 1;
|
|
228
|
+
noteStubSwapForTest();
|
|
229
|
+
// The old generation is NOT unlinked here (review C1-1): the new stub is
|
|
230
|
+
// only durable once the debounced snapshot flushes — a crash before that
|
|
231
|
+
// reloads the OLD stub, which must still find its file. Queue the unlink;
|
|
232
|
+
// persistNow() drains the queue only after the snapshot write succeeds.
|
|
233
|
+
pendingSpillUnlinks.push(expected.spill);
|
|
234
|
+
while (pendingSpillUnlinks.length > PENDING_SPILL_UNLINKS_MAX) {
|
|
235
|
+
deleteResponseSpill(pendingSpillUnlinks.shift()!);
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
spillCounters.writeFailures += 1;
|
|
239
|
+
replaceWithSpillFailure(id, expected);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function setResidentEntry(id: string, entry: ResidentInput): void {
|
|
244
|
+
const expected = states.get(id);
|
|
245
|
+
const candidate = measureResidentEntry(id, entry);
|
|
246
|
+
if (!candidate) {
|
|
247
|
+
replaceWithSpillFailure(id, expected);
|
|
248
|
+
// A tombstone is tiny but still resident state: the hard-cap invariant
|
|
249
|
+
// must hold on EVERY mutation path (review C2-1 — with a test cap below
|
|
250
|
+
// tombstone size, skipping the prune leaves the store over cap).
|
|
251
|
+
pruneResponses();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (expected?.kind === "spill") {
|
|
255
|
+
replaceSpillEntryAtomically(id, expected, candidate);
|
|
256
|
+
pruneResponses();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!replaceMapEntry(id, candidate, expected)) return;
|
|
260
|
+
pruneResponses();
|
|
78
261
|
}
|
|
262
|
+
|
|
79
263
|
// Expansion provenance must stay proxy-private: a WeakMap distinguishes replayed history from the
|
|
80
264
|
// newly appended input suffix without adding an unknown field that native passthrough could send
|
|
81
265
|
// upstream. The parser uses this boundary to acknowledge historical compaction markers exactly once.
|
|
82
266
|
const replayedInputPrefixLengths = new WeakMap<object, number>();
|
|
267
|
+
const replayFailures = new WeakMap<object, PreviousResponseReplayFailure>();
|
|
83
268
|
let loaded = false;
|
|
84
269
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
270
|
let pendingPersistPath: string | null = null;
|
|
86
271
|
/** Single-flight gate: overlapping response-state writes serialize (#612). */
|
|
87
272
|
let persistGate: Promise<void> = Promise.resolve();
|
|
273
|
+
let persistAttemptHookForTests: (() => void) | null = null;
|
|
88
274
|
|
|
89
275
|
function now(): number {
|
|
90
276
|
return Date.now();
|
|
@@ -94,6 +280,64 @@ function snapshotPath(): string {
|
|
|
94
280
|
return join(getConfigDir(), "responses-state.json");
|
|
95
281
|
}
|
|
96
282
|
|
|
283
|
+
interface LegacySnapshotState {
|
|
284
|
+
createdAt?: unknown;
|
|
285
|
+
items?: unknown;
|
|
286
|
+
providers?: OcxProviderContinuationState;
|
|
287
|
+
conversationId?: unknown;
|
|
288
|
+
cursorCheckpointUsable?: unknown;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isSpillRef(value: unknown): value is ResponseSpillRef {
|
|
292
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
293
|
+
const ref = value as ResponseSpillRef;
|
|
294
|
+
return ref.version === 1
|
|
295
|
+
&& typeof ref.fileName === "string"
|
|
296
|
+
&& /^[0-9a-f]{64}$/.test(ref.digest)
|
|
297
|
+
&& Number.isSafeInteger(ref.payloadBytes)
|
|
298
|
+
&& ref.payloadBytes >= 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function loadSnapshotEntry(id: string, value: unknown): void {
|
|
302
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
303
|
+
const rec = value as LegacySnapshotState & { kind?: unknown; spill?: unknown };
|
|
304
|
+
if (typeof rec.createdAt !== "number" || !Number.isFinite(rec.createdAt)) return;
|
|
305
|
+
if (rec.kind === "spill") {
|
|
306
|
+
if (!isSpillRef(rec.spill)) return;
|
|
307
|
+
const base: Omit<SpilledResponseState, "sizeBytes"> = {
|
|
308
|
+
kind: "spill",
|
|
309
|
+
createdAt: rec.createdAt,
|
|
310
|
+
...(rec.providers ? { providers: rec.providers } : {}),
|
|
311
|
+
spill: rec.spill,
|
|
312
|
+
};
|
|
313
|
+
replaceMapEntry(id, { ...base, sizeBytes: stubSize(id, base) });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (rec.kind === "spill-failed") {
|
|
317
|
+
replaceMapEntry(id, tombstone(id, rec.createdAt));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (rec.kind !== undefined && rec.kind !== "resident") return;
|
|
321
|
+
if (!Array.isArray(rec.items)) return;
|
|
322
|
+
const providers = rec.providers ?? (typeof rec.conversationId === "string"
|
|
323
|
+
? {
|
|
324
|
+
cursor: {
|
|
325
|
+
conversationId: rec.conversationId,
|
|
326
|
+
...(typeof rec.cursorCheckpointUsable === "boolean"
|
|
327
|
+
? { checkpointUsable: rec.cursorCheckpointUsable }
|
|
328
|
+
: {}),
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
: undefined);
|
|
332
|
+
const resident = measureResidentEntry(id, {
|
|
333
|
+
createdAt: rec.createdAt,
|
|
334
|
+
items: rec.items,
|
|
335
|
+
...(providers ? { providers } : {}),
|
|
336
|
+
});
|
|
337
|
+
if (resident) replaceMapEntry(id, resident);
|
|
338
|
+
else replaceMapEntry(id, tombstone(id, rec.createdAt));
|
|
339
|
+
}
|
|
340
|
+
|
|
97
341
|
export interface ResponseStateTempRecoveryResult {
|
|
98
342
|
matched: number;
|
|
99
343
|
removed: number;
|
|
@@ -216,97 +460,121 @@ function ensureLoaded(): void {
|
|
|
216
460
|
/* best-effort cleanup only; snapshot loading must remain independent */
|
|
217
461
|
}
|
|
218
462
|
try {
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (typeof rec.createdAt !== "number" || !Array.isArray(rec.items)) continue;
|
|
228
|
-
const providers = rec.providers ?? (rec.conversationId
|
|
229
|
-
? {
|
|
230
|
-
cursor: {
|
|
231
|
-
conversationId: rec.conversationId,
|
|
232
|
-
...(rec.cursorCheckpointUsable !== undefined
|
|
233
|
-
? { checkpointUsable: rec.cursorCheckpointUsable }
|
|
234
|
-
: {}),
|
|
235
|
-
},
|
|
236
|
-
}
|
|
237
|
-
: undefined);
|
|
238
|
-
// Recompute sizes locally while loading — persisted sizeBytes (absent in v1/v2
|
|
239
|
-
// snapshots anyway) is never trusted.
|
|
240
|
-
setEntry(id, {
|
|
241
|
-
createdAt: rec.createdAt,
|
|
242
|
-
items: rec.items,
|
|
243
|
-
...(providers ? { providers } : {}),
|
|
244
|
-
});
|
|
463
|
+
if (existsSync(path)) {
|
|
464
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
|
|
465
|
+
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.states)) {
|
|
466
|
+
for (const entry of raw.states) {
|
|
467
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
|
|
468
|
+
loadSnapshotEntry(entry[0], entry[1]);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
245
471
|
}
|
|
246
|
-
pruneResponses();
|
|
247
472
|
} catch {
|
|
248
473
|
/* missing/corrupt snapshot: start empty */
|
|
249
474
|
}
|
|
475
|
+
const referenced = new Set<string>();
|
|
476
|
+
for (const state of states.values()) {
|
|
477
|
+
if (state.kind === "spill") referenced.add(state.spill.fileName);
|
|
478
|
+
}
|
|
479
|
+
try { recoverOrphanedResponseSpills(referenced); } catch { /* best effort */ }
|
|
480
|
+
pruneResponses();
|
|
250
481
|
}
|
|
251
482
|
|
|
252
|
-
|
|
253
|
-
if (persistTimer) {
|
|
254
|
-
clearTimeout(persistTimer);
|
|
255
|
-
persistTimer = null;
|
|
256
|
-
}
|
|
257
|
-
pendingPersistPath = null;
|
|
483
|
+
type SnapshotWriteOutcome = "stable" | "unstable" | "failed";
|
|
258
484
|
|
|
485
|
+
async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome> {
|
|
259
486
|
// Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612).
|
|
260
487
|
const previous = persistGate;
|
|
261
488
|
let release!: () => void;
|
|
262
489
|
persistGate = new Promise<void>(resolve => { release = resolve; });
|
|
263
490
|
await previous;
|
|
264
491
|
try {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
//
|
|
270
|
-
const [id, state]
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
492
|
+
for (let attempt = 0; attempt < MAX_SNAPSHOT_REWRITE_ATTEMPTS; attempt += 1) {
|
|
493
|
+
const revision = stateRevision;
|
|
494
|
+
const entries: Array<[string, unknown]> = [];
|
|
495
|
+
let total = 0;
|
|
496
|
+
// Newest-first so the most recent chains survive both legacy snapshot caps.
|
|
497
|
+
for (const [id, state] of [...states].reverse()) {
|
|
498
|
+
let persistable: unknown;
|
|
499
|
+
if (state.kind === "resident") {
|
|
500
|
+
const { sizeBytes: _sizeBytes, kind: _kind, ...resident } = state;
|
|
501
|
+
persistable = resident;
|
|
502
|
+
} else {
|
|
503
|
+
const { sizeBytes: _sizeBytes, ...smallState } = state;
|
|
504
|
+
persistable = smallState;
|
|
505
|
+
}
|
|
506
|
+
const persistEntry: [string, unknown] = [id, persistable];
|
|
507
|
+
const size = JSON.stringify(persistEntry).length;
|
|
508
|
+
if (state.kind === "resident" && size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
|
|
509
|
+
if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
|
|
510
|
+
total += size;
|
|
511
|
+
entries.push(persistEntry);
|
|
512
|
+
}
|
|
513
|
+
entries.reverse();
|
|
514
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
515
|
+
try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
|
|
516
|
+
await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
|
|
517
|
+
persistAttemptHookForTests?.();
|
|
518
|
+
if (revision === stateRevision) return "stable";
|
|
278
519
|
}
|
|
279
|
-
|
|
280
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
281
|
-
// mkdirSync's mode only applies on creation — re-harden an existing config dir so the
|
|
282
|
-
// conversation-content snapshot never lands in a group/world-readable directory.
|
|
283
|
-
try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
|
|
284
|
-
await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
|
|
520
|
+
return "unstable";
|
|
285
521
|
} catch {
|
|
286
|
-
|
|
522
|
+
return "failed";
|
|
287
523
|
} finally {
|
|
288
524
|
release();
|
|
289
525
|
}
|
|
290
526
|
}
|
|
291
527
|
|
|
528
|
+
function drainPendingSpillUnlinks(): void {
|
|
529
|
+
while (pendingSpillUnlinks.length > 0) {
|
|
530
|
+
const ref = pendingSpillUnlinks.shift()!;
|
|
531
|
+
deleteResponseSpill(ref);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function schedulePersistAt(path: string, replace = false): void {
|
|
536
|
+
if (persistTimer && !replace) return;
|
|
537
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
538
|
+
pendingPersistPath = path;
|
|
539
|
+
persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
|
|
540
|
+
(persistTimer as { unref?: () => void }).unref?.();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function persistNow(path: string, awaitFollowUp = false): Promise<void> {
|
|
544
|
+
if (persistTimer) {
|
|
545
|
+
clearTimeout(persistTimer);
|
|
546
|
+
persistTimer = null;
|
|
547
|
+
}
|
|
548
|
+
pendingPersistPath = null;
|
|
549
|
+
let outcome = await writeBoundedSnapshot(path);
|
|
550
|
+
if (outcome === "unstable" && awaitFollowUp) {
|
|
551
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
552
|
+
persistTimer = null;
|
|
553
|
+
pendingPersistPath = null;
|
|
554
|
+
outcome = await writeBoundedSnapshot(path);
|
|
555
|
+
}
|
|
556
|
+
if (outcome === "stable") drainPendingSpillUnlinks();
|
|
557
|
+
else if (outcome === "unstable" && !awaitFollowUp) schedulePersistAt(path, true);
|
|
558
|
+
}
|
|
559
|
+
|
|
292
560
|
function schedulePersist(): void {
|
|
293
|
-
if (persistTimer) return;
|
|
294
561
|
// Resolve the target path NOW: tests (and anything else) may swap OPENCODEX_HOME before the
|
|
295
562
|
// debounce fires, and a late write must land in the home that owned the recorded state.
|
|
296
|
-
|
|
297
|
-
const path = pendingPersistPath;
|
|
298
|
-
persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
|
|
299
|
-
(persistTimer as { unref?: () => void }).unref?.();
|
|
563
|
+
schedulePersistAt(snapshotPath());
|
|
300
564
|
}
|
|
301
565
|
|
|
302
566
|
/** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
|
|
303
567
|
export async function flushResponseState(): Promise<void> {
|
|
304
568
|
if (persistTimer) {
|
|
305
|
-
await persistNow(pendingPersistPath ?? snapshotPath());
|
|
569
|
+
await persistNow(pendingPersistPath ?? snapshotPath(), true);
|
|
306
570
|
return;
|
|
307
571
|
}
|
|
308
572
|
// No pending timer: still await any in-flight write so shutdown does not race (#612).
|
|
309
573
|
await persistGate;
|
|
574
|
+
// A bounded background pass may have scheduled its same-path follow-up while
|
|
575
|
+
// this flush was waiting on the single-flight gate. Shutdown owns one awaited
|
|
576
|
+
// bounded follow-up rather than returning behind that unref'd timer.
|
|
577
|
+
if (persistTimer) await persistNow(pendingPersistPath ?? snapshotPath(), true);
|
|
310
578
|
}
|
|
311
579
|
|
|
312
580
|
function inputItems(input: unknown): unknown[] {
|
|
@@ -325,12 +593,107 @@ function pruneResponses(at = now()): void {
|
|
|
325
593
|
if (!oldest) break;
|
|
326
594
|
deleteEntry(oldest);
|
|
327
595
|
}
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
596
|
+
// Unconditional RAM cap. Resident payloads demote durably; stubs/tombstones are
|
|
597
|
+
// deleted only when even their bounded metadata cannot fit the override.
|
|
598
|
+
while (storedResponseBytes > byteCap() && states.size > 0) {
|
|
599
|
+
const oldestResident = [...states].find(([, entry]) => entry.kind === "resident");
|
|
600
|
+
const oldestId = oldestResident?.[0] ?? states.keys().next().value as string | undefined;
|
|
601
|
+
if (!oldestId) break;
|
|
602
|
+
const entry = states.get(oldestId)!;
|
|
603
|
+
if (entry.kind !== "resident") {
|
|
604
|
+
deleteEntry(oldestId);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
const ref = writeResponseSpillDurably(oldestId, {
|
|
609
|
+
createdAt: entry.createdAt,
|
|
610
|
+
items: entry.items,
|
|
611
|
+
...(entry.providers ? { providers: entry.providers } : {}),
|
|
612
|
+
});
|
|
613
|
+
if (swapResidentForSpill(oldestId, entry, ref)) spillCounters.writes += 1;
|
|
614
|
+
} catch {
|
|
615
|
+
spillCounters.writeFailures += 1;
|
|
616
|
+
replaceWithSpillFailure(oldestId, entry);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Periodic TTL-only sweep; count/byte eviction remains owned by mutation paths. */
|
|
622
|
+
export function sweepExpiredResponseStates(at = now()): number {
|
|
623
|
+
let removed = 0;
|
|
624
|
+
for (const [id, state] of states) {
|
|
625
|
+
if (at - state.createdAt <= RESPONSE_TTL_MS) continue;
|
|
626
|
+
deleteEntry(id);
|
|
627
|
+
removed += 1;
|
|
628
|
+
}
|
|
629
|
+
if (removed > 0) schedulePersist();
|
|
630
|
+
return removed;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
|
|
634
|
+
return {
|
|
635
|
+
count: states.size,
|
|
636
|
+
bytes: storedResponseBytes,
|
|
637
|
+
evictableBytes: residentResponseBytes,
|
|
638
|
+
pinnedBytes: Math.max(0, storedResponseBytes - residentResponseBytes),
|
|
639
|
+
oldestAt: oldestResidentAt,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function evictOldestResponseContinuationForBudget(): number {
|
|
644
|
+
if (oldestResidentId === undefined) return 0;
|
|
645
|
+
const id = oldestResidentId;
|
|
646
|
+
const entry = states.get(id);
|
|
647
|
+
if (!entry || entry.kind !== "resident") return 0;
|
|
648
|
+
try {
|
|
649
|
+
const ref = writeResponseSpillDurably(id, {
|
|
650
|
+
createdAt: entry.createdAt,
|
|
651
|
+
items: entry.items,
|
|
652
|
+
...(entry.providers ? { providers: entry.providers } : {}),
|
|
653
|
+
});
|
|
654
|
+
if (swapResidentForSpill(id, entry, ref)) spillCounters.writes += 1;
|
|
655
|
+
} catch {
|
|
656
|
+
spillCounters.writeFailures += 1;
|
|
657
|
+
replaceWithSpillFailure(id, entry);
|
|
333
658
|
}
|
|
659
|
+
schedulePersist();
|
|
660
|
+
const replacement = states.get(id);
|
|
661
|
+
return !replacement || replacement.kind === "resident"
|
|
662
|
+
? 0
|
|
663
|
+
: Math.max(0, entry.sizeBytes - replacement.sizeBytes);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function materializeEntry(
|
|
667
|
+
id: string,
|
|
668
|
+
entry: StoredResponseState,
|
|
669
|
+
): { ok: true; state: ResidentResponseState } | { ok: false; failure: PreviousResponseReplayFailure } {
|
|
670
|
+
if (entry.kind === "resident") return { ok: true, state: entry };
|
|
671
|
+
if (entry.kind === "spill-failed") {
|
|
672
|
+
return { ok: false, failure: { code: "previous_response_not_found", reason: "spill_failed" } };
|
|
673
|
+
}
|
|
674
|
+
const result = readResponseSpill(id, entry.spill);
|
|
675
|
+
if (!result.ok) {
|
|
676
|
+
spillCounters.readFailures += 1;
|
|
677
|
+
const failure: PreviousResponseReplayFailure = {
|
|
678
|
+
code: "previous_response_not_found",
|
|
679
|
+
reason: result.reason === "missing" ? "spill_missing" : "spill_corrupt",
|
|
680
|
+
};
|
|
681
|
+
replaceWithSpillFailure(id, entry);
|
|
682
|
+
schedulePersist();
|
|
683
|
+
return { ok: false, failure };
|
|
684
|
+
}
|
|
685
|
+
const state = measureResidentEntry(id, {
|
|
686
|
+
createdAt: result.payload.createdAt,
|
|
687
|
+
items: result.payload.items,
|
|
688
|
+
...(result.payload.providers ? { providers: result.payload.providers } : {}),
|
|
689
|
+
});
|
|
690
|
+
if (!state) {
|
|
691
|
+
spillCounters.readFailures += 1;
|
|
692
|
+
replaceWithSpillFailure(id, entry);
|
|
693
|
+
schedulePersist();
|
|
694
|
+
return { ok: false, failure: { code: "previous_response_not_found", reason: "spill_corrupt" } };
|
|
695
|
+
}
|
|
696
|
+
return { ok: true, state };
|
|
334
697
|
}
|
|
335
698
|
|
|
336
699
|
export function expandPreviousResponseInput(body: unknown): unknown {
|
|
@@ -342,14 +705,24 @@ export function expandPreviousResponseInput(body: unknown): unknown {
|
|
|
342
705
|
pruneResponses();
|
|
343
706
|
const previous = states.get(previousId);
|
|
344
707
|
if (!previous) return body;
|
|
708
|
+
const materialized = materializeEntry(previousId, previous);
|
|
709
|
+
if (!materialized.ok) {
|
|
710
|
+
replayFailures.set(request, materialized.failure);
|
|
711
|
+
return body;
|
|
712
|
+
}
|
|
345
713
|
const expanded = {
|
|
346
714
|
...request,
|
|
347
|
-
input: [...
|
|
715
|
+
input: [...materialized.state.items, ...inputItems(request.input)],
|
|
348
716
|
};
|
|
349
|
-
replayedInputPrefixLengths.set(expanded,
|
|
717
|
+
replayedInputPrefixLengths.set(expanded, materialized.state.items.length);
|
|
350
718
|
return expanded;
|
|
351
719
|
}
|
|
352
720
|
|
|
721
|
+
export function previousResponseReplayFailure(body: unknown): PreviousResponseReplayFailure | undefined {
|
|
722
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
|
|
723
|
+
return replayFailures.get(body);
|
|
724
|
+
}
|
|
725
|
+
|
|
353
726
|
/** Number of leading input items restored from previous_response_id state for this exact body. */
|
|
354
727
|
export function previousResponseReplayPrefixLength(body: unknown): number {
|
|
355
728
|
if (!body || typeof body !== "object" || Array.isArray(body)) return 0;
|
|
@@ -364,22 +737,23 @@ export function previousResponseProviderState(responseId: string | undefined): O
|
|
|
364
737
|
if (!responseId) return undefined;
|
|
365
738
|
ensureLoaded();
|
|
366
739
|
pruneResponses();
|
|
367
|
-
const
|
|
740
|
+
const state = states.get(responseId);
|
|
741
|
+
const providers = state?.kind === "spill-failed" ? undefined : state?.providers;
|
|
368
742
|
return providers ? structuredClone(providers) : undefined;
|
|
369
743
|
}
|
|
370
744
|
|
|
371
745
|
export interface ResponseStateMetrics {
|
|
372
|
-
/** Number of retained continuation entries currently in RAM. */
|
|
373
746
|
count: number;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
* memory-pressure signal. */
|
|
747
|
+
residentCount: number;
|
|
748
|
+
spillStubCount: number;
|
|
749
|
+
tombstoneCount: number;
|
|
378
750
|
totalBytes: number;
|
|
379
|
-
|
|
751
|
+
spillPayloadBytes: number;
|
|
380
752
|
largestBytes: number;
|
|
381
|
-
/** Age of the oldest retained entry, in ms (0 when the store is empty). */
|
|
382
753
|
oldestAgeMs: number;
|
|
754
|
+
spillWrites: number;
|
|
755
|
+
spillWriteFailures: number;
|
|
756
|
+
spillReadFailures: number;
|
|
383
757
|
}
|
|
384
758
|
|
|
385
759
|
/**
|
|
@@ -395,16 +769,33 @@ export function responseStateMetrics(): ResponseStateMetrics {
|
|
|
395
769
|
const at = now();
|
|
396
770
|
let largestBytes = 0;
|
|
397
771
|
let oldestCreatedAt = at;
|
|
772
|
+
let residentCount = 0;
|
|
773
|
+
let spillStubCount = 0;
|
|
774
|
+
let tombstoneCount = 0;
|
|
775
|
+
let spillPayloadBytes = 0;
|
|
398
776
|
for (const state of states.values()) {
|
|
399
|
-
const bytes = state.sizeBytes
|
|
777
|
+
const bytes = state.sizeBytes;
|
|
400
778
|
if (bytes > largestBytes) largestBytes = bytes;
|
|
401
779
|
if (state.createdAt < oldestCreatedAt) oldestCreatedAt = state.createdAt;
|
|
780
|
+
if (state.kind === "resident") {
|
|
781
|
+
residentCount += 1;
|
|
782
|
+
} else if (state.kind === "spill") {
|
|
783
|
+
spillStubCount += 1;
|
|
784
|
+
spillPayloadBytes += state.spill.payloadBytes;
|
|
785
|
+
} else tombstoneCount += 1;
|
|
402
786
|
}
|
|
403
787
|
return {
|
|
404
788
|
count: states.size,
|
|
789
|
+
residentCount,
|
|
790
|
+
spillStubCount,
|
|
791
|
+
tombstoneCount,
|
|
405
792
|
totalBytes: storedResponseBytes,
|
|
793
|
+
spillPayloadBytes,
|
|
406
794
|
largestBytes,
|
|
407
795
|
oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0,
|
|
796
|
+
spillWrites: spillCounters.writes,
|
|
797
|
+
spillWriteFailures: spillCounters.writeFailures,
|
|
798
|
+
spillReadFailures: spillCounters.readFailures,
|
|
408
799
|
};
|
|
409
800
|
}
|
|
410
801
|
|
|
@@ -441,7 +832,7 @@ export function rememberResponseState(
|
|
|
441
832
|
return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
|
|
442
833
|
});
|
|
443
834
|
}
|
|
444
|
-
|
|
835
|
+
setResidentEntry(response.id, {
|
|
445
836
|
createdAt: now(),
|
|
446
837
|
items: [...inputItems(request.input), ...response.output],
|
|
447
838
|
// Always preserve the Cursor conversation id so the next tool-result turn can continue the SAME
|
|
@@ -451,10 +842,26 @@ export function rememberResponseState(
|
|
|
451
842
|
// checkpoint must not be reused — but the conversation id string itself is still valid.
|
|
452
843
|
...(Object.keys(normalizedProviderState).length > 0 ? { providers: normalizedProviderState } : {}),
|
|
453
844
|
});
|
|
454
|
-
|
|
845
|
+
enforceAppOwnedMemoryBudget();
|
|
455
846
|
schedulePersist();
|
|
456
847
|
}
|
|
457
848
|
|
|
849
|
+
/** Test-only persistence churn hook; invoked after each atomic snapshot rewrite. */
|
|
850
|
+
export function setResponseStatePersistAttemptHookForTests(hook: (() => void) | null): void {
|
|
851
|
+
persistAttemptHookForTests = hook;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Test-only: deterministically run the pending background debounce pass. */
|
|
855
|
+
export async function runPendingResponseStatePersistForTests(): Promise<void> {
|
|
856
|
+
if (!persistTimer) return;
|
|
857
|
+
await persistNow(pendingPersistPath ?? snapshotPath());
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Test-only: observe whether a debounce/follow-up pass is pending. */
|
|
861
|
+
export function responseStatePersistPendingForTests(): boolean {
|
|
862
|
+
return persistTimer !== null;
|
|
863
|
+
}
|
|
864
|
+
|
|
458
865
|
/** Memory-only reset (simulates a process restart: the snapshot file survives). */
|
|
459
866
|
export function clearResponseStateMemoryForTests(): void {
|
|
460
867
|
if (persistTimer) {
|
|
@@ -464,14 +871,25 @@ export function clearResponseStateMemoryForTests(): void {
|
|
|
464
871
|
pendingPersistPath = null;
|
|
465
872
|
states.clear();
|
|
466
873
|
storedResponseBytes = 0;
|
|
874
|
+
residentResponseBytes = 0;
|
|
875
|
+
oldestResidentId = undefined;
|
|
876
|
+
oldestResidentAt = null;
|
|
877
|
+
stateRevision = 0;
|
|
878
|
+
pendingSpillUnlinks.length = 0;
|
|
879
|
+
spillCounters.writes = 0;
|
|
880
|
+
spillCounters.writeFailures = 0;
|
|
881
|
+
spillCounters.readFailures = 0;
|
|
882
|
+
persistAttemptHookForTests = null;
|
|
467
883
|
loaded = false;
|
|
468
884
|
}
|
|
469
885
|
|
|
470
886
|
export function clearResponseStateForTests(): void {
|
|
887
|
+
for (const entry of states.values()) deleteOwnedSpills(entry);
|
|
471
888
|
clearResponseStateMemoryForTests();
|
|
472
889
|
try {
|
|
473
890
|
unlinkSync(snapshotPath());
|
|
474
891
|
} catch {
|
|
475
892
|
/* no snapshot on disk */
|
|
476
893
|
}
|
|
894
|
+
try { rmSync(responseSpillDirectory(), { recursive: true, force: true }); } catch { /* no spill directory */ }
|
|
477
895
|
}
|