fix: keep runtime exit metadata from recreating deleted sessions

This commit is contained in:
程序员阿江(Relakkes)
2026-09-10 18:33:03 +08:00
parent c068246c15
commit fac81ecc37
2 changed files with 108 additions and 14 deletions
@@ -0,0 +1,73 @@
import { expect, test } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createSandboxedTestEnvironment } from '../../../scripts/pr/test-environment.js'
// Exercise the registered runtime shutdown callback in another process. That
// process caches enabled settings and never observes the parent's 365→0→365.
for (const deleted of [true, false]) {
test(`runtime exit ${deleted ? 'does not recreate a deleted transcript' : 'preserves metadata for an existing transcript'}`, async () => {
const directory = await mkdtemp('/tmp/session-exit-retention-')
const env = createSandboxedTestEnvironment(directory, { TEST_ENABLE_SESSION_PERSISTENCE: '1' })
const configDir = env.CLAUDE_CONFIG_DIR!
await mkdir(configDir, { recursive: true })
await writeFile(join(configDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 365 }))
const source = `
import { mkdir } from 'node:fs/promises'
import { switchSession } from './src/bootstrap/state.ts'
import { getSettings_DEPRECATED } from './src/utils/settings/settings.ts'
import { runCleanupFunctions } from './src/utils/cleanupRegistry.ts'
import { cacheSessionTitle, flushSessionStorage, getTranscriptPathForSession, recordTranscript } from './src/utils/sessionStorage.ts'
const id = 'deadbeef-0000-4000-8000-000000000001'
switchSession(id)
await mkdir(process.env.CLAUDE_CONFIG_DIR, { recursive: true })
getSettings_DEPRECATED()
cacheSessionTitle('CACHED EXIT TITLE')
await recordTranscript([{type:'user', uuid:'deadbeef-0000-4000-8000-000000000002', timestamp:'2026-09-10T00:00:00.000Z', message:{role:'user',content:'CACHED EXIT PRIVATE PROMPT'}}])
await flushSessionStorage()
process.stdout.write(JSON.stringify({ path: getTranscriptPathForSession(id) }) + '\\n')
await new Promise(resolve => process.stdin.once('data', resolve))
// This is the same cleanup registry invoked by gracefulShutdown.
await runCleanupFunctions()
process.exit(0)
`
const child = Bun.spawn([process.execPath, '--no-env-file', '-e', source], {
cwd: join(import.meta.dir, '../../..'), env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe',
})
const errorOutput = new Response(child.stderr).text()
try {
const reader = child.stdout.getReader()
let ready = ''
while (!ready.includes('\n')) {
const result = await reader.read()
if (result.done) throw new Error(`Runtime exited before ready: ${await errorOutput}`)
ready += new TextDecoder().decode(result.value)
}
const { path } = JSON.parse(ready.trim()) as { path: string }
const initial = await readFile(path, 'utf8')
expect(initial).toContain('CACHED EXIT PRIVATE PROMPT')
expect(initial).toContain('CACHED EXIT TITLE')
expect(initial).not.toContain('last-prompt')
if (deleted) {
await writeFile(join(configDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 0 }))
await unlink(path)
await writeFile(join(configDir, 'settings.json'), JSON.stringify({ cleanupPeriodDays: 365 }))
}
child.stdin.write('exit\n')
child.stdin.end()
expect(await child.exited).toBe(0)
expect(await errorOutput).toBe('')
if (deleted) {
expect(await readFile(path, 'utf8').catch(() => null)).toBeNull()
} else {
const final = await readFile(path, 'utf8')
expect(final).toContain('"lastPrompt":"CACHED EXIT PRIVATE PROMPT"')
expect(final.match(/CACHED EXIT TITLE/g)).toHaveLength(2)
}
} finally {
child.kill()
await child.exited
await rm(directory, { recursive: true, force: true })
}
}, 15_000)
}
+35 -14
View File
@@ -4,7 +4,7 @@ import type { Dirent } from 'fs'
// Sync fs primitives for readFileTailSync — separate from fs/promises
// imports above. Named (not wildcard) per CLAUDE.md style; no collisions
// with the async-suffixed names.
import { closeSync, fstatSync, openSync, readSync } from 'fs'
import { appendFileSync as fsAppendFileSync, closeSync, constants, fchmodSync, fstatSync, openSync, readSync } from 'fs'
import {
appendFile as fsAppendFile,
open as fsOpen,
@@ -820,7 +820,7 @@ class Project {
* the SDK cannot touch (last-prompt, agent-*, mode, pr-link) have no
* external-writer concern — their caches are authoritative.
*/
reAppendSessionMetadata(skipTitleRefresh = false): void {
reAppendSessionMetadata(skipTitleRefresh = false, allowCreate = false): void {
if (!this.sessionFile || this.shouldSkipPersistence()) return
const sessionId = getSessionId() as UUID
if (!sessionId) return
@@ -871,7 +871,7 @@ class Project {
type: 'last-prompt',
lastPrompt: this.currentSessionLastPrompt,
sessionId,
})
}, allowCreate)
}
// Unconditional: cache was refreshed from tail above; re-append keeps
// the entry at EOF so compaction-pushed content doesn't evict it.
@@ -880,49 +880,49 @@ class Project {
type: 'custom-title',
customTitle: this.currentSessionTitle,
sessionId,
})
}, allowCreate)
}
if (this.currentSessionTag) {
appendEntryToFile(this.sessionFile, {
type: 'tag',
tag: this.currentSessionTag,
sessionId,
})
}, allowCreate)
}
if (this.currentSessionAgentName) {
appendEntryToFile(this.sessionFile, {
type: 'agent-name',
agentName: this.currentSessionAgentName,
sessionId,
})
}, allowCreate)
}
if (this.currentSessionAgentColor) {
appendEntryToFile(this.sessionFile, {
type: 'agent-color',
agentColor: this.currentSessionAgentColor,
sessionId,
})
}, allowCreate)
}
if (this.currentSessionAgentSetting) {
appendEntryToFile(this.sessionFile, {
type: 'agent-setting',
agentSetting: this.currentSessionAgentSetting,
sessionId,
})
}, allowCreate)
}
if (this.currentSessionMode) {
appendEntryToFile(this.sessionFile, {
type: 'mode',
mode: this.currentSessionMode,
sessionId,
})
}, allowCreate)
}
if (this.currentSessionWorktree !== undefined) {
appendEntryToFile(this.sessionFile, {
type: 'worktree-state',
worktreeSession: this.currentSessionWorktree,
sessionId,
})
}, allowCreate)
}
if (
this.currentSessionPrNumber !== undefined &&
@@ -936,7 +936,7 @@ class Project {
prUrl: this.currentSessionPrUrl,
prRepository: this.currentSessionPrRepository,
timestamp: new Date().toISOString(),
})
}, allowCreate)
}
}
@@ -1092,8 +1092,9 @@ class Project {
// and create a metadata-only file despite --no-session-persistence.
if (this.shouldSkipPersistence()) return
this.ensureCurrentSessionFile()
// mode/agentSetting are cache-only pre-materialization; write them now.
this.reAppendSessionMetadata()
// Only a new user/assistant turn may create a transcript. Exit/compaction
// metadata refreshes must never resurrect a file removed by cleanup.
this.reAppendSessionMetadata(false, true)
if (this.pendingEntries.length > 0) {
const buffered = this.pendingEntries
this.pendingEntries = []
@@ -2699,15 +2700,35 @@ export async function fetchLogs(limit?: number): Promise<LogOption[]> {
}
/**
* Append an entry to a session file. Creates the parent dir if missing.
* Append an entry to a session file. Metadata refreshes opt out of creation:
* a stale process may not have observed retention-zero before it was restored.
*/
/* eslint-disable custom-rules/no-sync-fs -- sync callers (exit cleanup, materialize) */
function appendEntryToFile(
fullPath: string,
entry: Record<string, unknown>,
allowCreate = true,
): void {
const fs = getFsImplementation()
const line = jsonStringify(entry) + '\n'
if (!allowCreate) {
let fd: number
try {
const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW
fd = openSync(fullPath, constants.O_WRONLY | constants.O_APPEND | noFollow)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
throw error
}
try {
if (!fstatSync(fd).isFile()) throw new Error(`Refusing non-regular append target: ${fullPath}`)
if (process.platform !== 'win32') fchmodSync(fd, 0o600)
fsAppendFileSync(fd, line)
} finally {
closeSync(fd)
}
return
}
try {
fs.appendFileSync(fullPath, line, { mode: 0o600 })
} catch {