mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix: keep runtime exit metadata from recreating deleted sessions
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user