fix: release Codex prompt claims when their turns complete (#21138)

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-09-17 20:12:11 -07:00
committed by GitHub
co-authored by m4air
parent 51f809aa82
commit fbfe3a2e74
12 changed files with 3154 additions and 1 deletions
@@ -0,0 +1,53 @@
# Codex prompt claims retained after turn completion
Confirmed cancellation keeps a prompt claim until its turn completes. If the prompt's lookup entries are evicted or replaced first, the old `clearTurn()` cannot find it. The separate claims map then retains the prompt until the whole session is cleared.
The fix includes claimed prompts in the existing exact-thread/turn cleanup. Existing turn matching and `forget()` object-identity checks preserve a replacement prompt's authority. Registry limits and cancellation timing are unchanged.
## Source ownership and reachability
1. `codex-structured-provider-events.ts:57` registers incoming prompt requests and publishes them through the translator. `codex-structured-session-acquire.ts:95` binds the translator's turn cleanup to the session registry.
2. `codex-structured-prompt-ownership.ts:33` acquires the claim. Confirmed cancellation deliberately leaves it owned; unsuccessful/unconfirmed cancellation releases it. The actual `CodexStructuredTurnCancellation` invokes the confirmation callback after the injected interrupt transport acknowledges success.
3. `codex-prompt-registry.ts:258` trims the address and journal-binding maps independently. Neither trim removes claims. Replacing the same journal address can similarly leave the old claim without a lookup entry.
4. A later `turn/completed` goes through `translateCodexNotification`, the journal translator and `settleCodexJournalTurn`. Accepted lifecycle settlement invokes `clearPromptTurn` at `codex-structured-journal-settlement.ts:170`.
5. The old cleanup enumerates only address/binding values. The fix also enumerates `claims.keys()`, still filtering by the exact thread/turn. `forget()` deletes replacement lookup entries only when they contain that same prompt object.
The safely expired owner is the claim for the terminal turn whose cleanup has been admitted. Live claims survive unrelated turn cleanup and registry eviction. A refused lifecycle settlement does not clear them.
## Reproduce
From the worktree root, using installed dependencies:
```sh
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=192 docs/audits/codex-prompt-claim-retention/reproduce.cjs
```
For Electron, use its installed executable with `ELECTRON_RUN_AS_NODE=1`, the same flags and script. The final optional argument selects the report path; the default is `node-results.json` beside the script. No Electron window is created.
`sources.cjs` reverses `fix.patch` against current source and rejects a baseline hash mismatch. It neither reads a previous commit to reconstruct the implementation nor changes product files. The proof bundles actual source in memory. Each report records effective source and bundle hashes, dependency hashes and runtime versions. Only the requested report is written.
The fixture uses the actual registry, server-request delivery, cancellation ownership function, cancellation class, journal translator and delayed notification delivery. It injects an accepting journal sink, interrupt transport and primary-turn lookup. Prompts belong to child threads, so the production child-turn cancellation branch does not enumerate or terminate processes. Every injected process helper throws if unexpectedly called.
The sequence creates 32 ordinary small prompt objects, confirms their cancellations, admits 256 unrelated prompts to evict lookup entries, then completes the original exact turns. WeakRefs count prompt liveness after forced collections. No large payload is attached. The 20-second deadline and 192 MiB heap limit bound the proof.
## Results
Both [Node 26.6.0](./node-results.json) and [Electron 43.7.0 / Node 24.21.0](./electron-results.json) produced:
| Observation | Before | After |
| -------------------------------------------------------------------------- | -----: | ----: |
| Retained cancelled prompts after lookup eviction, before completion | 32 | 32 |
| Retained after exact turn completion | 32 | 0 |
| Retained after all lookup maps become empty | 32 | 0 |
| Retained after session clear | 0 | 0 |
| Old prompt retained after same-address replacement and old-turn completion | 1 | 0 |
Ordinary completion releases its prompt on both versions. Wrong-thread, wrong-turn and refused-completion controls preserve claims. The replacement prompt and its active claim remain valid after old-turn cleanup on both versions.
The four regression tests cover 32 evicted claims, replacement authority, a compatibility turn digest and session cleanup. Applying the reversed source produces three expected failures; the session-clear control passes. Existing prompt ownership/reply tests also pass on the reversed source. Current source passes 71 tests across six files plus Node, CLI and Web typechecks; [validation.json](./validation.json) records commands and other checks.
## Limits
This is a code-level lifetime defect. The request/completion ordering is deliberately injected; this is not a capture of Codex emitting that sequence or an affected host. Counts do not measure ordinary prompt bytes or establish a growth rate. It does not identify the cause of #19831 or any other incident.
[source-versions.json](./source-versions.json) records matching baseline source at the named main revision. No historical application runtime was reproduced. The fix uses existing turn ownership and identity checks; it adds no arbitrary eviction policy.
@@ -0,0 +1,24 @@
import { resolve } from 'node:path'
import { createRequire } from 'node:module'
import { defineConfig, mergeConfig } from 'vitest/config'
import baseConfig from '../../../config/vitest.config.ts'
const loadSources = createRequire(import.meta.url)(
resolve('docs/audits/codex-prompt-claim-retention/sources.cjs')
)
const { before } = loadSources()
export default mergeConfig(
baseConfig,
defineConfig({
plugins: [
{
name: 'codex-claim-before-fix',
enforce: 'pre',
transform(_code, id) {
const source = before.get(resolve(id.split('?')[0]))
return source === undefined ? undefined : { code: source, map: null }
}
}
]
})
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
--- a/src/main/codex/codex-prompt-registry.ts
+++ b/src/main/codex/codex-prompt-registry.ts
@@ -226,7 +226,7 @@
clearTurn(threadId: string, turnId: string): void {
const prompts = new Set(
- [...this.byAddress.values(), ...this.boundPrompts.values()].filter(
+ [...this.byAddress.values(), ...this.boundPrompts.values(), ...this.claims.keys()].filter(
(prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId)
)
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
const assert = require('node:assert/strict')
const { createHash } = require('node:crypto')
const { readFileSync, writeFileSync } = require('node:fs')
const { resolve, relative } = require('node:path')
const esbuild = require('esbuild')
const Module = require('node:module')
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
assert.equal(typeof global.gc, 'function')
const { root, before, after, hashes } = require('./sources.cjs')()
const sourcePath = 'src/main/codex/codex-prompt-registry.ts'
const source = before.get(resolve(root, sourcePath))
const candidate = after.get(resolve(root, sourcePath))
const hash = (value) => createHash('sha256').update(value).digest('hex')
const entry = `
export { CodexPromptRegistry } from './src/main/codex/codex-prompt-registry';
export { cancelCodexStructuredTurn } from './src/main/codex/codex-structured-prompt-ownership';
export { CodexStructuredTurnCancellation } from './src/main/codex/codex-structured-turn-cancellation';
export { createCodexJournalTranslator } from './src/main/codex/codex-structured-journal-translation';
export { deliverCodexServerRequest, translateCodexNotification } from './src/main/codex/codex-structured-provider-events';
`
async function build(mode) {
const result = await esbuild.build({
stdin: {
contents: entry,
resolveDir: root,
loader: 'ts',
sourcefile: 'codex-claim-proof-entry.ts'
},
absWorkingDir: root,
bundle: true,
platform: 'node',
format: 'cjs',
packages: 'external',
write: false,
metafile: true,
logLevel: 'silent',
plugins: [
{
name: 'candidate-only-in-memory',
setup(build) {
build.onLoad({ filter: /\/codex-prompt-registry\.ts$/ }, (args) => {
assert.equal(args.path, resolve(root, sourcePath))
return { contents: mode === 'candidate' ? candidate : source, loader: 'ts' }
})
}
}
]
})
const bundlePath = resolve(root, `codex-claim-${mode}-proof.cjs`)
const loaded = new Module(bundlePath, module)
loaded.filename = bundlePath
loaded.paths = Module._nodeModulePaths(root)
loaded._compile(result.outputFiles[0].text, bundlePath)
const dependencies = Object.keys(result.metafile.inputs)
.filter((path) => path.startsWith('src/'))
.map((path) => ({
path,
sha256: hash(
path === sourcePath
? mode === 'original'
? source
: candidate
: readFileSync(resolve(root, path))
)
}))
return { api: loaded.exports, bundleSha256: hash(result.outputFiles[0].contents), dependencies }
}
const run = require('./scenario.cjs')
async function main() {
const deadline = setTimeout(() => {
process.stderr.write('proof deadline\n')
process.exit(2)
}, 20_000)
const results = {}
const versions = {}
for (const mode of ['original', 'candidate']) {
const built = await build(mode)
results[mode] = await run(built.api, mode)
versions[mode] = { bundleSha256: built.bundleSha256, dependencies: built.dependencies }
}
clearTimeout(deadline)
const report = {
capturedAt: new Date().toISOString(),
runtime: process.versions,
scope:
'Actual registry, server-request translation, cancellation ownership/cancellation class, journal translator, delayed turn completion. Injected accepted sink, interrupt transport, and compaction lookup. No provider, process enumeration/termination, or host data. Bundles load in memory; only the requested report is written.',
sourceHashes: hashes,
countsOnly: true,
noPayloadAmplification: true,
results,
versions
}
const output = process.argv[2] ?? resolve(__dirname, 'node-results.json')
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`)
process.stdout.write(
`${JSON.stringify(
{ output: relative(root, output), sourceHashes: report.sourceHashes, results },
null,
2
)}\n`
)
}
main().catch((error) => {
process.stderr.write(`${error.stack}\n`)
process.exit(1)
})
@@ -0,0 +1,215 @@
const assert = require('node:assert/strict')
const admitted = () => ({ accepted: true })
function fixture(api) {
const prompts = new api.CodexPromptRegistry()
const state = { prompts, requestCount: 0, lastBinding: null, blockCompletion: false }
const sink = {
appendItem() {},
appendTombstone() {},
publish() {},
tryAppendItem: admitted,
tryAppendTombstone: admitted,
tryAppendLifecycleBatch: (id) =>
state.blockCompletion && id.startsWith('turn-completed:')
? { accepted: false, reason: 'backpressure' }
: admitted(),
tryPublish: admitted
}
const translator = api.createCodexJournalTranslator({
sink,
sessionId: 'session',
primaryThreadId: () => 'primary',
bindPromptItemId: (id, thread, promptKey, turn) => {
prompts.bindJournalItemId(id, thread, promptKey, turn)
state.lastBinding = id
},
clearPromptTurn: (thread, turn) => prompts.clearTurn(thread, turn)
})
const session = {
threadId: 'primary',
prompts,
translator,
fence: 7,
acquisitionGeneration: 'generation',
ended: false,
connection: {
request: async (method) => {
assert.equal(method, 'turn/interrupt')
state.requestCount++
return {}
},
respondWithError() {
throw new Error('unexpected server refusal')
},
respond() {
throw new Error('unexpected prompt response')
}
}
}
const emit = (_session, event) => translator.handle(event)
const cancellation = new api.CodexStructuredTurnCancellation({
emit,
captureTurnProcesses: async () => {
throw new Error('no process enumeration allowed')
},
terminateTurnProcesses: async () => {
throw new Error('no process termination allowed')
}
})
cancellation.register(session)
return Object.assign(state, {
api,
session,
translator,
cancellation,
emit,
sessions: new Map([['session', session]]),
compactions: { providerTurnId: () => 'primary-turn' }
})
}
function register(
state,
serial,
thread = `child-${serial}`,
turn = `turn-${serial}`,
item = `item-${serial}`
) {
state.lastBinding = null
const admission = state.api.deliverCodexServerRequest(
'session',
state.session,
{
id: serial,
method: 'item/commandExecution/requestApproval',
params: { threadId: thread, turnId: turn, itemId: item, command: 'echo bounded-proof' }
},
state.emit
)
assert.equal(admission.accepted, true)
assert.equal(typeof state.lastBinding, 'string')
const prompt = state.prompts.find(state.lastBinding)
assert.ok(prompt)
return { ref: new WeakRef(prompt), id: state.lastBinding, thread, turn }
}
async function cancel(state, record) {
const result = await state.api.cancelCodexStructuredTurn({
sessions: state.sessions,
compactions: state.compactions,
cancellation: state.cancellation,
request: {
sessionId: 'session',
turnId: 'primary-turn',
fence: 7,
prompt: { itemId: record.id, kind: 'approval' }
}
})
assert.equal(result.cancelled, true)
}
function complete(state, thread, turn, expectedAccepted = true) {
const admission = state.api.translateCodexNotification({
sessionId: 'session',
session: state.session,
method: 'turn/completed',
params: { threadId: thread, turn: { id: turn, status: 'interrupted' } },
turnCancellation: state.cancellation,
emit: state.emit
})
assert.equal(admission.accepted, expectedAccepted)
}
async function alive(records) {
for (let round = 0; round < 8; round++) {
await new Promise(setImmediate)
global.gc()
}
return records.filter((record) => record.ref.deref() !== undefined).length
}
async function run(api, mode) {
const state = fixture(api)
const ordinary = register(state, 1)
await cancel(state, ordinary)
assert.equal(await alive([ordinary]), 1)
complete(state, ordinary.thread, ordinary.turn)
const ordinaryAfterCompletion = await alive([ordinary])
assert.equal(ordinaryAfterCompletion, 0)
const records = []
for (let index = 0; index < 32; index++) {
const record = register(state, index + 10)
await cancel(state, record)
records.push(record)
}
assert.equal(await alive(records), 32)
// Unrelated child traffic evicts old binding/address entries without ending their turns.
for (let index = 0; index < 256; index++) {
register(state, index + 1000, 'other-child', 'other-turn')
}
const sizesAfterEviction = state.prompts.sizes
for (const record of records) {
assert.equal(state.prompts.find(record.id), null)
}
const afterEvictionBeforeCompletion = await alive(records)
assert.equal(afterEvictionBeforeCompletion, 32)
complete(state, 'wrong-child', records[0].turn)
complete(state, records[0].thread, 'wrong-turn')
assert.equal(await alive(records), 32)
register(state, 5000, records[0].thread, records[0].turn)
state.blockCompletion = true
complete(state, records[0].thread, records[0].turn, false)
assert.equal(await alive(records), 32)
state.blockCompletion = false
for (const record of records) {
complete(state, record.thread, record.turn)
}
const afterExactTurnCompletion = await alive(records)
assert.equal(afterExactTurnCompletion, mode === 'original' ? 32 : 0)
complete(state, 'other-child', 'other-turn')
assert.deepEqual(state.prompts.sizes, { prompts: 0, journalBindings: 0 })
const afterAllLookupMapsEmpty = await alive(records)
assert.equal(afterAllLookupMapsEmpty, mode === 'original' ? 32 : 0)
state.prompts.clear()
const afterSessionClear = await alive(records)
assert.equal(afterSessionClear, 0)
// Replacing a journal address must not let old-turn completion clear the new prompt/claim.
const old = register(state, 2000, 'reuse-child', 'old-turn', 'reused-item')
await cancel(state, old)
const newer = register(state, 2001, 'reuse-child', 'new-turn', 'reused-item')
assert.equal(newer.id, old.id)
const replacementClaim = state.prompts.claimBound(newer.id)
assert.ok(replacementClaim)
complete(state, old.thread, old.turn)
assert.equal(
state.prompts.ownsBoundClaim(replacementClaim, newer.id, newer.thread, newer.turn),
true
)
const oldAfterReplacementCompletion = await alive([old])
assert.equal(oldAfterReplacementCompletion, mode === 'original' ? 1 : 0)
state.prompts.releaseClaim(replacementClaim)
complete(state, newer.thread, newer.turn)
state.prompts.clear()
state.translator.dispose()
return {
ordinaryAfterCompletion,
cancelledPrompts: 32,
sizesAfterEviction,
afterEvictionBeforeCompletion,
afterExactTurnCompletion,
afterAllLookupMapsEmpty,
afterSessionClear,
oldAfterReplacementCompletion,
replacementClaimPreserved: true,
wrongThreadPreserved: true,
wrongTurnPreserved: true,
rejectedCompletionPreserved: true,
successfulInterruptRequests: state.requestCount
}
}
module.exports = run
@@ -0,0 +1,54 @@
{
"baselineHashes": {
"src/main/codex/codex-prompt-registry.ts": "254c530216f47abfc3572bb1689161ad3c10775af2cdceda7e94f443e52c0691"
},
"namedRefs": [
{
"ref": "HEAD",
"revision": "9e2c137548bf99f91255ab4862c01145e42a0883",
"sha256": "254c530216f47abfc3572bb1689161ad3c10775af2cdceda7e94f443e52c0691",
"matchesBaseline": true
},
{
"ref": "origin/main",
"revision": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818",
"sha256": "254c530216f47abfc3572bb1689161ad3c10775af2cdceda7e94f443e52c0691",
"matchesBaseline": true
}
],
"historicalRuntimeReproduced": false,
"callbackProvenance": [
{
"path": "src/main/codex/codex-structured-session-acquire.ts",
"sha256": "71cd2bae18944c2aaa3ea2a1d00958890e4c8219d5aae9a4de48dd692562ace0"
},
{
"path": "src/main/codex/codex-structured-session-adapter.ts",
"sha256": "eab8820250ebdb1b3f6b3ab9287e16686bd6766f5af5dd29e081d7e47f083b20"
},
{
"path": "src/main/codex/codex-structured-provider-events.ts",
"sha256": "a411dc378e521ea8fc915e2a2a21476341a30f440e8233ae5649205d2a1e2e44"
},
{
"path": "src/main/codex/codex-structured-prompt-ownership.ts",
"sha256": "8d86f3784b79a1470e4002527bdfa505efe2762cba865de7716442f7d9730013"
},
{
"path": "src/main/codex/codex-structured-turn-cancellation.ts",
"sha256": "518b8e5a2f6dc4f8e46e01366c0a61830d6c805b68101484726bd62bbfc831a8"
},
{
"path": "src/main/codex/codex-structured-journal-translation.ts",
"sha256": "56e17b9649471e804d29d1d54a3e14b4298ca8af2e25c4759dc37fce000b06a1"
},
{
"path": "src/main/codex/codex-structured-journal-settlement.ts",
"sha256": "761616c292e15894b337fdd171acd7c2c1272fcabdbad3ea460f8831a923c797"
},
{
"path": "src/main/codex/codex-structured-session-close.ts",
"sha256": "f85aa2cdcfd2ddf34ae0be8397a3896128f04a989eaff750f8e72eee3398b361"
}
]
}
@@ -0,0 +1,29 @@
const assert = require('node:assert/strict')
const { createHash } = require('node:crypto')
const { readFileSync } = require('node:fs')
const { resolve } = require('node:path')
const { applyPatch, parsePatch, reversePatch } = require('diff')
module.exports = function loadSources() {
const root = resolve(__dirname, '../../..')
const expected = JSON.parse(readFileSync(resolve(__dirname, 'source-versions.json'), 'utf8'))
const parsed = parsePatch(readFileSync(resolve(__dirname, 'fix.patch'), 'utf8'))
const before = new Map()
const after = new Map()
const hashes = {}
assert.equal(parsed.length, 1)
for (const patch of parsed) {
const path = patch.newFileName.replace(/^b\//, '')
assert.ok(Object.hasOwn(expected.baselineHashes, path), `Unexpected patch path: ${path}`)
const absolute = resolve(root, path)
const current = readFileSync(absolute, 'utf8')
const baseline = applyPatch(current, reversePatch(patch))
assert.notEqual(baseline, false, `Source changed; review fix.patch: ${path}`)
const hash = (source) => createHash('sha256').update(source).digest('hex')
assert.equal(hash(baseline), expected.baselineHashes[path], `Baseline drift: ${path}`)
before.set(absolute, baseline)
after.set(absolute, current)
hashes[path] = { before: hash(baseline), after: hash(current) }
}
return { root, before, after, hashes }
}
@@ -0,0 +1,69 @@
{
"backgroundLaunch": true,
"newRegressionCases": 4,
"before": {
"config": "docs/audits/codex-prompt-claim-retention/before.config.mjs",
"passed": 31,
"failed": 3,
"exitCode": 1,
"paths": [
"src/main/codex/codex-prompt-registry-retention.test.ts",
"src/main/codex/codex-structured-prompt-ownership.test.ts",
"src/main/codex/codex-structured-prompt-replies.test.ts"
],
"expectedFailures": [
"releases 32 evicted claims only when their exact turn completes",
"preserves a replacement prompt and its active claim when the old turn completes",
"finds an evicted claim through its bounded turn digest"
]
},
"after": {
"config": "config/vitest.config.ts",
"passed": 71,
"failed": 0,
"exitCode": 0,
"paths": [
"src/main/codex/codex-prompt-registry-retention.test.ts",
"src/main/codex/codex-structured-prompt-ownership.test.ts",
"src/main/codex/codex-structured-prompt-replies.test.ts",
"src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts",
"src/main/codex/codex-structured-journal-translation-settlement.test.ts",
"src/main/codex/codex-structured-session-close.test.ts"
]
},
"typechecks": {
"command": "node config/scripts/run-typecheck-projects-in-parallel.mjs",
"projects": [
"config/tsconfig.node.json",
"config/tsconfig.tc.cli.json",
"config/tsconfig.tc.web.json"
],
"exitCode": 0
},
"focusedOxlint": {
"ordinaryExitCode": 0,
"typeAwareExitCode": 0,
"noIgnore": true,
"files": 6
},
"changedCodeQuality": {
"command": "node config/scripts/check-changed-code-quality.mjs",
"base": "2fccacadbe23",
"changedFiles": 310,
"newFindings": 0,
"exitCode": 0
},
"proof": {
"command": "node --expose-gc --max-old-space-size=192 docs/audits/codex-prompt-claim-retention/reproduce.cjs",
"node": "26.6.0",
"electron": "43.7.0",
"electronNode": "24.21.0",
"bothExitCode": 0,
"beforeRetainedAfterExactCompletion": 32,
"afterRetainedAfterExactCompletion": 0,
"ordinaryPromptBytes": "not measured",
"payloadAmplification": false,
"ordering": "injected delayed child-turn completion, not an affected-host capture"
},
"formatCheckExitCode": 0
}
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire'
import { CodexPromptRegistry, type CodexPendingPrompt } from './codex-prompt-registry'
function registerPrompt(
registry: CodexPromptRegistry,
index: number,
threadId = 'thread',
turnId: string | null = 'turn',
itemId = `item-${index}`
): { itemId: string; prompt: CodexPendingPrompt } {
const prompt = registry.register({
id: index,
method: 'item/commandExecution/requestApproval',
params: { itemId, threadId, turnId }
})
if (!prompt) {
throw new Error('Fixture prompt was refused')
}
const journalItemId = `journal:${threadId}:${itemId}`
registry.bindJournalItemId(journalItemId, threadId, itemId, turnId)
return { itemId: journalItemId, prompt }
}
function claimPrompt(
registry: CodexPromptRegistry,
index: number,
turnId = 'turn',
itemId?: string
): WeakRef<CodexPendingPrompt> {
const registered = registerPrompt(registry, index, 'thread', null, itemId)
registry.bindJournalItemId(registered.itemId, 'thread', registered.prompt.promptKey, turnId)
if (!registry.claimBound(registered.itemId)) {
throw new Error('Fixture prompt could not be claimed')
}
return new WeakRef(registered.prompt)
}
async function collect(): Promise<void> {
if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') {
throw new Error('The test runner must enable --expose-gc')
}
for (let round = 0; round < 5; round += 1) {
await new Promise<void>((resolve) => setImmediate(resolve))
globalThis.gc()
}
}
function evictLookupEntries(registry: CodexPromptRegistry): void {
for (let index = 0; index < 256; index += 1) {
registerPrompt(registry, index + 1_000, 'other-thread', 'other-turn')
}
}
describe('Codex prompt claim lifetime', () => {
it('releases 32 evicted claims only when their exact turn completes', async () => {
const registry = new CodexPromptRegistry()
const prompts = Array.from({ length: 32 }, (_, index) => claimPrompt(registry, index))
evictLookupEntries(registry)
expect(registry.sizes).toEqual({ prompts: 128, journalBindings: 256 })
expect(registry.find('journal:thread:item-0')).toBeNull()
registry.clearTurn('other-thread', 'turn')
registry.clearTurn('thread', 'other-turn')
await collect()
expect(prompts.filter((prompt) => prompt.deref() !== undefined)).toHaveLength(32)
registry.clearTurn('thread', 'turn')
await collect()
expect(prompts.filter((prompt) => prompt.deref() !== undefined)).toHaveLength(0)
expect(registry.sizes).toEqual({ prompts: 128, journalBindings: 256 })
registry.clear()
})
it('preserves a replacement prompt and its active claim when the old turn completes', async () => {
const registry = new CodexPromptRegistry()
const old = claimPrompt(registry, 1, 'old-turn', 'same-item')
const replacement = registerPrompt(registry, 2, 'thread', 'new-turn', 'same-item')
const claim = registry.claimBound(replacement.itemId)
if (!claim) {
throw new Error('Replacement prompt could not be claimed')
}
registry.clearTurn('thread', 'old-turn')
await collect()
expect(old.deref()).toBeUndefined()
expect(registry.find(replacement.itemId)).toBe(replacement.prompt)
expect(registry.ownsBoundClaim(claim, replacement.itemId, 'thread', 'new-turn')).toBe(true)
registry.clearTurn('thread', 'new-turn')
expect(registry.ownsClaim(claim)).toBe(false)
})
it('finds an evicted claim through its bounded turn digest', async () => {
const registry = new CodexPromptRegistry()
const turnId = 'x'.repeat(AGENT_SESSION_ID_MAX_LENGTH + 1)
const prompt = claimPrompt(registry, 1, turnId)
evictLookupEntries(registry)
registry.clearTurn('thread', turnId)
await collect()
expect(prompt.deref()).toBeUndefined()
registry.clear()
})
it('releases evicted claims when the session is cleared', async () => {
const registry = new CodexPromptRegistry()
const prompt = claimPrompt(registry, 1)
evictLookupEntries(registry)
registry.clear()
await collect()
expect(prompt.deref()).toBeUndefined()
})
})
+1 -1
View File
@@ -226,7 +226,7 @@ export class CodexPromptRegistry {
clearTurn(threadId: string, turnId: string): void {
const prompts = new Set(
[...this.byAddress.values(), ...this.boundPrompts.values()].filter(
[...this.byAddress.values(), ...this.boundPrompts.values(), ...this.claims.keys()].filter(
(prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId)
)
)