fix(bg): clean up old completed session artifacts (#2167)

* fix(bg): clean up old completed session artifacts

* fix(bg): reclaim orphaned terminal facts

* test(bg): stabilize reservation cleanup handoff

* test(bg): clarify cleanup race invariants

* fix(bg): validate cleanup completion timestamps

* fix(bg): isolate reused session generations

* fix(bg): recover terminal session handoffs

* fix(bg): retain zero-day cleanup ownership

* fix(bg): preserve handled exception outcomes

* fix(bg): unify retention lifecycle cleanup

* fix(bg): harden retention coordination

* fix(bg): preserve retention pass outcomes

* test(bg): harden cleanup fixtures and launcher checks
This commit is contained in:
Bogdan
2026-09-22 10:22:42 +08:00
committed by GitHub
parent 5fb5abb6f8
commit 70fe2de2f8
18 changed files with 8705 additions and 247 deletions
+14
View File
@@ -1096,6 +1096,7 @@ export async function attachHandler(
export async function killHandler(
args: string[] | string | undefined,
options: { retentionSettingsReady?: boolean } = {},
): Promise<void> {
const target = normalizeArgs(args)[0]
if (!target) fail('Usage: openclaude kill <id-or-name>')
@@ -1107,6 +1108,19 @@ export async function killHandler(
`Failed to kill background session ${session.id}: ${errorMessage(error)}`,
)
})
if (options.retentionSettingsReady !== false) {
try {
const { runBackgroundSessionRetention } = await import(
'../utils/cleanup.js'
)
await runBackgroundSessionRetention({
trigger: 'explicit-kill',
sessionId: killed.id,
})
} catch {
// A retention failure must not replace the successful kill outcome.
}
}
console.log(`Killed background session ${killed.id}.`)
}
+72 -5
View File
@@ -1,14 +1,43 @@
import { writeFile } from 'node:fs/promises'
import { handleBgFlag } from './bg.js'
import { prepareBackgroundSessionFinalizer } from './bgFinalizer.js'
import * as backgroundFinalizer from './bgFinalizer.js'
import { noteBackgroundSessionTerminationSignal } from '../utils/backgroundSessionTermination.js'
const invocation = process.argv.slice(2)
if (invocation[0] === 'launcher') {
await handleBgFlag(['--bg', invocation[1] ?? 'success'])
const cleanupWorkerEnv = 'OPENCLAUDE_INTERNAL_BACKGROUND_CLEANUP_WORKER'
if (process.env[cleanupWorkerEnv] === '1') {
const runCleanupWorker = (
backgroundFinalizer as typeof backgroundFinalizer & {
runBackgroundSessionCleanupWorker?: () => Promise<void>
}
).runBackgroundSessionCleanupWorker
if (!runCleanupWorker) {
throw new Error('background cleanup worker is unavailable')
}
await runCleanupWorker()
} else if (invocation[0] === 'launcher') {
const name = invocation[2]
const originalLog = console.log
if (
process.env.OPENCLAUDE_BG_FINALIZER_FIXTURE_OUTPUT === 'omit-logs'
) {
console.log = (...args: unknown[]) => {
if (String(args[0]).startsWith('Logs:')) return
originalLog(...args)
}
}
try {
await handleBgFlag([
'--bg',
...(name ? ['--name', name] : []),
invocation[1] ?? 'success',
])
} finally {
console.log = originalLog
}
} else {
const mode = invocation.at(-1)
await prepareBackgroundSessionFinalizer()
await backgroundFinalizer.prepareBackgroundSessionFinalizer()
if (mode === 'throw') {
throw new Error('intentional background finalizer fixture failure')
@@ -29,7 +58,45 @@ if (invocation[0] === 'launcher') {
})
}
const readyPath = process.env.OPENCLAUDE_BG_FINALIZER_FIXTURE_READY
if (readyPath) await writeFile(readyPath, 'ready')
if (mode === 'handled-throw') {
const releasePath = process.env.OPENCLAUDE_BG_FINALIZER_FIXTURE_RELEASE
if (!readyPath || !releasePath) {
throw new Error('handled-throw fixture routing is missing')
}
process.once('uncaughtException', async () => {
await writeFile(readyPath, 'ready')
const deadline = Date.now() + 5_000
while (!(await Bun.file(releasePath).exists())) {
if (Date.now() >= deadline) {
process.exit(24)
}
await new Promise(resolve => setTimeout(resolve, 10))
}
process.exit(23)
})
queueMicrotask(() => {
throw new Error('intentional handled fixture failure')
})
} else if (
readyPath &&
process.env.OPENCLAUDE_BG_FINALIZER_FIXTURE_SKIP_READY !== '1'
) {
await writeFile(readyPath, 'ready')
}
if (mode === 'controlled' || mode === 'controlled-exit') {
const releasePath = process.env.OPENCLAUDE_BG_FINALIZER_FIXTURE_RELEASE
if (!releasePath) {
throw new Error('controlled fixture release path is missing')
}
const deadline = Date.now() + 5_000
while (!(await Bun.file(releasePath).exists())) {
if (Date.now() >= deadline) {
throw new Error('controlled fixture release timed out')
}
await new Promise(resolve => setTimeout(resolve, 10))
}
if (mode === 'controlled-exit') process.exit(0)
}
if (mode === 'wait' || mode === 'sigint' || mode === 'sigterm') {
setInterval(() => {}, 1_000)
}
File diff suppressed because it is too large Load Diff
+326 -12
View File
@@ -1,6 +1,13 @@
import { spawn } from 'node:child_process'
import { resolve } from 'node:path'
import { isProcessRunning } from '../utils/genericProcessUtils.js'
import { logForDebugging } from '../utils/debug.js'
import { registerCleanup } from '../utils/cleanupRegistry.js'
import { SettingsSchema } from '../utils/settings/types.js'
import {
filterInvalidModelPricing,
filterInvalidPermissionRules,
} from '../utils/settings/validation.js'
import {
beginBackgroundSessionSignalTracking,
type ObservedBackgroundSessionSignal,
@@ -12,11 +19,15 @@ import {
type BackgroundSession,
} from './bgRegistry.js'
import {
BACKGROUND_SESSION_CLEANUP_OWNER_PID_ENV,
BACKGROUND_SESSION_CLEANUP_WORKER_ENV,
BACKGROUND_SESSION_ID_ENV,
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
} from './bgRouting.js'
export {
BACKGROUND_SESSION_CLEANUP_OWNER_PID_ENV,
BACKGROUND_SESSION_CLEANUP_WORKER_ENV,
BACKGROUND_SESSION_ID_ENV,
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
} from './bgRouting.js'
@@ -24,6 +35,17 @@ export {
const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/
const DEFAULT_REGISTRATION_WAIT_MS = 5_000
const DEFAULT_REGISTRATION_POLL_MS = 10
const DEFAULT_CLEANUP_WORKER_WAIT_MS = 10_000
const DEFAULT_CLEANUP_WORKER_POLL_MS = 25
const BACKGROUND_SESSION_RETENTION_POLICY_STATE_ENV =
'OPENCLAUDE_INTERNAL_BACKGROUND_RETENTION_POLICY_STATE'
type BackgroundRetentionPolicyState = 'preserved' | 'invalid'
type BackgroundRetentionSettingsHandoff = {
args: string[]
policyState: BackgroundRetentionPolicyState
}
type PrepareBackgroundSessionFinalizerOptions = {
env?: NodeJS.ProcessEnv
@@ -38,6 +60,16 @@ type PrepareBackgroundSessionFinalizerOptions = {
onExit?: (listener: (code: number) => void) => void
finalize?: typeof recordBackgroundSessionNaturalTermination
finalizeSync?: typeof recordBackgroundSessionNaturalTerminationSync
startCleanupWorker?: (
sessionId: string,
ownerPid: number,
launcherPid: number,
settingsArgs: string[],
policyState: BackgroundRetentionPolicyState,
) => void
spawnCleanupWorkerProcess?: typeof spawn
settingsArgs?: string[]
settingsCwd?: string
getObservedSignal?: () => ObservedBackgroundSessionSignal | undefined
debug?: (message: string) => void
}
@@ -63,9 +95,10 @@ function defaultDebug(message: string): void {
logForDebugging(message, { level: 'error' })
}
function currentProcessExitCode(): number {
const value = process.exitCode
if (value === undefined) return 0
function normalizeProcessExitCode(
value: string | number | null | undefined,
): number {
if (value === undefined || value === null) return 0
const parsed =
typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
return typeof parsed === 'number' &&
@@ -75,6 +108,10 @@ function currentProcessExitCode(): number {
: 1
}
function currentProcessExitCode(): number {
return normalizeProcessExitCode(process.exitCode)
}
function reportFinalizationFailure(
debug: (message: string) => void,
error: unknown,
@@ -88,6 +125,19 @@ function reportFinalizationFailure(
}
}
function reportPostFinalizationCleanupFailure(
debug: (message: string) => void,
error: unknown,
): void {
try {
debug(
`Background session post-finalization cleanup failed (${boundedFailureKind(error)})`,
)
} catch {
// Diagnostics must never replace the child process's original outcome.
}
}
function parsePositivePid(value: string | undefined): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined
const parsed = Number(value)
@@ -120,28 +170,243 @@ async function waitForOwnedSession(
| 'registrationPollMs'
>
>,
): Promise<'owned' | 'mismatch' | 'timeout'> {
): Promise<BackgroundSession | 'mismatch' | 'timeout'> {
const attempts = Math.max(
1,
Math.ceil(options.registrationWaitMs / options.registrationPollMs),
)
for (let attempt = 0; attempt < attempts; attempt += 1) {
const session = await options.readSession(id)
if (session) return session.pid === ownerPid ? 'owned' : 'mismatch'
if (session) return session.pid === ownerPid ? session : 'mismatch'
if (!options.isLauncherAlive(launcherPid)) {
const finalSession = await options.readSession(id)
if (finalSession) {
return finalSession.pid === ownerPid ? 'owned' : 'mismatch'
return finalSession.pid === ownerPid ? finalSession : 'mismatch'
}
return 'timeout'
}
await options.sleep(options.registrationPollMs)
}
const session = await options.readSession(id)
if (session) return session.pid === ownerPid ? 'owned' : 'mismatch'
if (session) return session.pid === ownerPid ? session : 'mismatch'
return 'timeout'
}
type BackgroundSessionCleanupWorkerStartOptions = {
sessionId: string
ownerPid: number
launcherPid: number
env?: NodeJS.ProcessEnv
execPath?: string
execArgv?: string[]
entrypoint?: string
settingsArgs?: string[]
settingsCwd?: string
policyState?: BackgroundRetentionPolicyState
spawnProcess?: typeof spawn
}
function backgroundRetentionSettingsHandoff(
args: string[],
settingsCwd: string,
): BackgroundRetentionSettingsHandoff {
const selected: string[] = []
let policyState: BackgroundRetentionPolicyState = 'preserved'
for (const flag of ['--settings', '--setting-sources']) {
const inlinePrefix = `${flag}=`
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg?.startsWith(inlinePrefix)) {
const handoff = backgroundRetentionSettingsValue(
flag,
arg.slice(inlinePrefix.length),
settingsCwd,
)
if (handoff.policyState === 'invalid') policyState = 'invalid'
if (handoff.value !== undefined) selected.push(flag, handoff.value)
break
}
if (arg === flag && index + 1 < args.length) {
const handoff = backgroundRetentionSettingsValue(
flag,
args[index + 1]!,
settingsCwd,
)
if (handoff.policyState === 'invalid') policyState = 'invalid'
if (handoff.value !== undefined) selected.push(flag, handoff.value)
break
}
}
}
return { args: selected, policyState }
}
function backgroundRetentionSettingsValue(
flag: string,
value: string,
settingsCwd: string,
): { value?: string; policyState: BackgroundRetentionPolicyState } {
if (flag !== '--settings') {
return { value, policyState: 'preserved' }
}
const trimmed = value.trim()
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
return {
value: resolve(settingsCwd, value),
policyState: 'preserved',
}
}
try {
const parsed = JSON.parse(trimmed) as unknown
const inlineErrors = [
...filterInvalidPermissionRules(parsed, '--settings'),
...filterInvalidModelPricing(parsed, '--settings'),
]
if (
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed) ||
inlineErrors.length > 0 ||
!SettingsSchema().safeParse(parsed).success
) {
return { policyState: 'invalid' }
}
if (!Object.hasOwn(parsed, 'cleanupPeriodDays')) {
return { policyState: 'preserved' }
}
return {
value: JSON.stringify({
cleanupPeriodDays: (parsed as { cleanupPeriodDays?: unknown })
.cleanupPeriodDays,
}),
policyState: 'preserved',
}
} catch {
return { policyState: 'invalid' }
}
}
export function startBackgroundSessionCleanupWorker(
options: BackgroundSessionCleanupWorkerStartOptions,
): void {
const entrypoint = options.entrypoint ?? process.argv[1]
if (!entrypoint) {
throw new Error('Background cleanup worker entrypoint is unavailable')
}
const workerEnv = { ...(options.env ?? process.env) }
if (!SAFE_ID_RE.test(options.sessionId)) {
throw new Error('Invalid background cleanup session ID')
}
workerEnv[BACKGROUND_SESSION_ID_ENV] = options.sessionId
workerEnv[BACKGROUND_SESSION_CLEANUP_WORKER_ENV] = '1'
workerEnv[BACKGROUND_SESSION_CLEANUP_OWNER_PID_ENV] = String(
options.ownerPid,
)
workerEnv[BACKGROUND_SESSION_LAUNCHER_PID_ENV] = String(options.launcherPid)
const settingsHandoff = backgroundRetentionSettingsHandoff(
options.settingsArgs ?? process.argv.slice(2),
options.settingsCwd ?? process.cwd(),
)
workerEnv[BACKGROUND_SESSION_RETENTION_POLICY_STATE_ENV] =
options.policyState === 'invalid'
? 'invalid'
: settingsHandoff.policyState
const child = (options.spawnProcess ?? spawn)(
options.execPath ?? process.execPath,
[
...(options.execArgv ?? process.execArgv),
entrypoint,
...settingsHandoff.args,
],
{
detached: true,
env: workerEnv,
stdio: 'ignore',
},
)
child.once('error', () => {})
child.unref()
}
type RunBackgroundSessionCleanupWorkerOptions = {
env?: NodeJS.ProcessEnv
isProcessAlive?: (pid: number) => boolean
sleep?: (ms: number) => Promise<void>
waitMs?: number
pollMs?: number
cleanup?: (
sessionId: string,
waitForProcessesToExit: () => Promise<boolean>,
reloadSettings?: () => boolean,
) => Promise<void>
reloadSettings?: () => boolean
}
async function runDefaultPostFinalizationCleanup(
sessionId: string,
waitForProcessesToExit: () => Promise<boolean>,
reloadSettings?: () => boolean,
): Promise<void> {
const { cleanupBackgroundSessionsAfterFinalization } = await import(
'../utils/cleanup.js'
)
await cleanupBackgroundSessionsAfterFinalization(
sessionId,
waitForProcessesToExit,
reloadSettings,
)
}
export async function runBackgroundSessionCleanupWorker(
options: RunBackgroundSessionCleanupWorkerOptions = {},
): Promise<void> {
const env = options.env ?? process.env
if (env[BACKGROUND_SESSION_CLEANUP_WORKER_ENV] !== '1') return
const ownerPid = parsePositivePid(
env[BACKGROUND_SESSION_CLEANUP_OWNER_PID_ENV],
)
const launcherPid = parsePositivePid(
env[BACKGROUND_SESSION_LAUNCHER_PID_ENV],
)
const sessionId = env[BACKGROUND_SESSION_ID_ENV]
const policyState = env[BACKGROUND_SESSION_RETENTION_POLICY_STATE_ENV]
delete env[BACKGROUND_SESSION_CLEANUP_WORKER_ENV]
delete env[BACKGROUND_SESSION_CLEANUP_OWNER_PID_ENV]
delete env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]
delete env[BACKGROUND_SESSION_ID_ENV]
delete env[BACKGROUND_SESSION_RETENTION_POLICY_STATE_ENV]
if (
ownerPid === undefined ||
launcherPid === undefined ||
!sessionId ||
!SAFE_ID_RE.test(sessionId)
) {
return
}
if (policyState !== undefined && policyState !== 'preserved') return
const isProcessAlive = options.isProcessAlive ?? isBackgroundLauncherAlive
const sleep =
options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)))
const waitMs = options.waitMs ?? DEFAULT_CLEANUP_WORKER_WAIT_MS
const pollMs = options.pollMs ?? DEFAULT_CLEANUP_WORKER_POLL_MS
const pids = [...new Set([ownerPid, launcherPid])]
const waitForProcessesToExit = async (): Promise<boolean> => {
const attempts = Math.max(1, Math.ceil(waitMs / pollMs))
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (pids.every(pid => !isProcessAlive(pid))) return true
await sleep(pollMs)
}
return pids.every(pid => !isProcessAlive(pid))
}
await (options.cleanup ?? runDefaultPostFinalizationCleanup)(
sessionId,
waitForProcessesToExit,
options.reloadSettings,
)
}
export async function prepareBackgroundSessionFinalizer(
options: PrepareBackgroundSessionFinalizerOptions = {},
): Promise<BackgroundSessionFinalizerPreparation> {
@@ -159,7 +424,7 @@ export async function prepareBackgroundSessionFinalizer(
}
const ownerPid = options.pid ?? process.pid
const ownership = await waitForOwnedSession(id, ownerPid, launcherPid, {
const ownedSession = await waitForOwnedSession(id, ownerPid, launcherPid, {
readSession: options.readSession ?? readBackgroundSessionForOwner,
isLauncherAlive: options.isLauncherAlive ?? isBackgroundLauncherAlive,
sleep:
@@ -170,11 +435,11 @@ export async function prepareBackgroundSessionFinalizer(
registrationPollMs:
options.registrationPollMs ?? DEFAULT_REGISTRATION_POLL_MS,
})
if (ownership === 'mismatch') {
if (ownedSession === 'mismatch') {
scrubRoutingEnvironment(env)
return 'invalid-routing'
}
if (ownership === 'timeout') {
if (ownedSession === 'timeout') {
scrubRoutingEnvironment(env)
throw new Error('Background session registration was not established')
}
@@ -187,8 +452,43 @@ export async function prepareBackgroundSessionFinalizer(
const getObservedSignal =
options.getObservedSignal ?? beginBackgroundSessionSignalTracking()
const debug = options.debug ?? defaultDebug
const retentionSettingsHandoff = backgroundRetentionSettingsHandoff(
options.settingsArgs ?? process.argv.slice(2),
options.settingsCwd ?? process.cwd(),
)
const startCleanupWorker =
options.startCleanupWorker ??
((
cleanupSessionId,
cleanupOwnerPid,
cleanupLauncherPid,
cleanupSettingsArgs,
cleanupPolicyState,
) =>
startBackgroundSessionCleanupWorker({
sessionId: cleanupSessionId,
ownerPid: cleanupOwnerPid,
launcherPid: cleanupLauncherPid,
settingsArgs: cleanupSettingsArgs,
policyState: cleanupPolicyState,
spawnProcess: options.spawnCleanupWorkerProcess,
}))
let finalized = false
const startPostFinalizationCleanup = () => {
try {
startCleanupWorker(
id,
ownerPid,
launcherPid,
retentionSettingsHandoff.args,
retentionSettingsHandoff.policyState,
)
} catch (error) {
reportPostFinalizationCleanupFailure(debug, error)
}
}
const currentTermination = () => {
const signal = getObservedSignal()
return signal === undefined
@@ -199,11 +499,16 @@ export async function prepareBackgroundSessionFinalizer(
const finalizeAwaited = async () => {
if (finalized) return
try {
await finalize(id, currentTermination(), { ownerPid })
await finalize(id, currentTermination(), {
ownerPid,
expectedSession: ownedSession,
})
finalized = true
} catch (error) {
reportFinalizationFailure(debug, error)
return
}
startPostFinalizationCleanup()
}
const registerFinalizerCleanup = options.registerCleanup ?? registerCleanup
registerFinalizerCleanup(finalizeAwaited)
@@ -221,16 +526,25 @@ export async function prepareBackgroundSessionFinalizer(
finalizeSync(
id,
signal === undefined ? { exitCode: code } : { signal },
{ ownerPid },
{ ownerPid, expectedSession: ownedSession },
)
finalized = true
} catch (error) {
reportFinalizationFailure(debug, error)
return
}
startPostFinalizationCleanup()
}
if (options.onExit) {
options.onExit(onExit)
} else {
const originalExit = process.exit
process.exit = ((...args: Parameters<typeof process.exit>) => {
onExit(
normalizeProcessExitCode(args.length === 0 ? process.exitCode : args[0]),
)
return originalExit(...args)
}) as typeof process.exit
process.once('exit', onExit)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2068 -167
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -4,6 +4,10 @@ export const BACKGROUND_SESSION_ID_ENV =
'OPENCLAUDE_INTERNAL_BACKGROUND_SESSION_ID'
export const BACKGROUND_SESSION_LAUNCHER_PID_ENV =
'OPENCLAUDE_INTERNAL_BACKGROUND_LAUNCHER_PID'
export const BACKGROUND_SESSION_CLEANUP_WORKER_ENV =
'OPENCLAUDE_INTERNAL_BACKGROUND_CLEANUP_WORKER'
export const BACKGROUND_SESSION_CLEANUP_OWNER_PID_ENV =
'OPENCLAUDE_INTERNAL_BACKGROUND_CLEANUP_OWNER_PID'
export const BACKGROUND_PROCESS_MARKER_FLAG =
'--openclaude-bg-session-marker'
+91 -9
View File
@@ -19,6 +19,7 @@ import { join } from 'node:path'
import { Command } from '@commander-js/extra-typings'
import { applyChildProcessHeapOptions } from './applyChildProcessHeapOptions.js'
import {
BACKGROUND_SESSION_CLEANUP_WORKER_ENV,
BACKGROUND_SESSION_ID_ENV,
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
} from '../cli/bgRouting.js'
@@ -45,9 +46,15 @@ const mockProfileCheckpoint = mock((_checkpoint: string) => {})
const mockPsHandler = mock(async (_args: string[]) => {})
const mockLogsHandler = mock(async (_args: string[]) => {})
const mockAttachHandler = mock(async (_args: string[]) => {})
const mockKillHandler = mock(async (_args: string[]) => {})
const mockKillHandler = mock(
async (
_args: string[],
_options?: { retentionSettingsReady?: boolean },
) => {},
)
const mockHandleBgFlag = mock(async (_args: string[]) => {})
const mockPrepareBackgroundSessionFinalizer = mock(async () => 'installed')
const mockRunBackgroundSessionCleanupWorker = mock(async () => {})
const mockLoadEnvFile = mock((_filePath: string) => ({}))
const mockParseProviderEnvFileArgs = mock((_args: string[]) => ({ paths: [] }))
const mockReapplyRememberedEnvFileValues = mock(() => {})
@@ -67,6 +74,7 @@ const mockGetProviderValidationError = mock(
async (_env: NodeJS.ProcessEnv) => undefined,
)
const mockEagerLoadSettingsFromArgs = mock((_args: string[]) => ({ ok: true }))
const mockResetSettingsCache = mock(() => {})
const mockResolveOutOfProcessTeammateProviderFromCliArgs = mock(
(_args: string[], _settings: unknown): ProviderOverride | undefined =>
undefined,
@@ -91,6 +99,7 @@ const runtimeMocks = [
mockKillHandler,
mockHandleBgFlag,
mockPrepareBackgroundSessionFinalizer,
mockRunBackgroundSessionCleanupWorker,
mockLoadEnvFile,
mockParseProviderEnvFileArgs,
mockReapplyRememberedEnvFileValues,
@@ -100,6 +109,7 @@ const runtimeMocks = [
mockApplyStartupEnvFromProfile,
mockGetProviderValidationError,
mockEagerLoadSettingsFromArgs,
mockResetSettingsCache,
mockResolveOutOfProcessTeammateProviderFromCliArgs,
mockApplyAgentProviderOverrideToEnv,
mockGetInitialSettings,
@@ -344,18 +354,15 @@ describe('cli.tsx — --provider startup ordering', () => {
expect(process.env.GEMINI_MODEL).toBe('gemini-2.0-flash')
})
it('dispatches background session management before config and provider validation', async () => {
it('dispatches background session management before provider validation', async () => {
const src = await Bun.file(`${import.meta.dir}/cli.tsx`).text()
const bgManagementIndex = src.indexOf("args[0] === 'ps'")
const configEnableIndex = src.indexOf('enableConfigs()')
const providerValidationIndex = src.indexOf(
'await validateProviderEnvForStartupOrExit()',
)
expect(bgManagementIndex).toBeGreaterThanOrEqual(0)
expect(configEnableIndex).toBeGreaterThanOrEqual(0)
expect(providerValidationIndex).toBeGreaterThanOrEqual(0)
expect(bgManagementIndex).toBeLessThan(configEnableIndex)
expect(bgManagementIndex).toBeLessThan(providerValidationIndex)
})
@@ -389,6 +396,7 @@ const mockImporters = {
}),
bgFinalizer: async () => ({
prepareBackgroundSessionFinalizer: mockPrepareBackgroundSessionFinalizer,
runBackgroundSessionCleanupWorker: mockRunBackgroundSessionCleanupWorker,
}),
envFile: async () => ({
loadEnvFile: mockLoadEnvFile,
@@ -421,6 +429,9 @@ const mockImporters = {
flagSettings: async () => ({
eagerLoadSettingsFromArgs: mockEagerLoadSettingsFromArgs,
}),
settingsCache: async () => ({
resetSettingsCache: mockResetSettingsCache,
}),
agentRouting: async () => ({
applyAgentProviderOverrideToEnv: mockApplyAgentProviderOverrideToEnv,
resolveOutOfProcessTeammateProviderFromCliArgs:
@@ -491,15 +502,44 @@ describe('cli.tsx — background routing behavior', () => {
await runCliEntrypoint([command, ...tail], bgOptions)
expect(handler.mock.calls).toEqual([[tail]])
if (command === 'kill') {
expect(mockKillHandler.mock.calls).toEqual([
[tail, { retentionSettingsReady: true }],
])
} else {
expect(handler.mock.calls).toEqual([[tail]])
}
expect(mockParseProviderEnvFileArgs).not.toHaveBeenCalled()
expect(mockHandleBgFlag).not.toHaveBeenCalled()
expect(mockEnableConfigs).not.toHaveBeenCalled()
expect(mockEnableConfigs).toHaveBeenCalledTimes(
command === 'kill' ? 1 : 0,
)
expect(mockEagerLoadSettingsFromArgs).toHaveBeenCalledTimes(
command === 'kill' ? 1 : 0,
)
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
expect(mockCliMain).not.toHaveBeenCalled()
}
})
it('keeps kill reachable when retention settings cannot be loaded', async () => {
mockEagerLoadSettingsFromArgs.mockImplementationOnce(() => ({
ok: false,
message: 'missing retention settings',
}))
await runCliEntrypoint(
['kill', 'session-1', '--settings', 'missing.json'],
bgOptions,
)
expect(mockKillHandler).toHaveBeenCalledWith(
['session-1', '--settings', 'missing.json'],
{ retentionSettingsReady: false },
)
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
})
it('establishes background finalizer ownership before any command path', async () => {
process.env[BACKGROUND_SESSION_ID_ENV] = 'bg-entrypoint'
mockPrepareBackgroundSessionFinalizer.mockImplementationOnce(async () => {
@@ -518,6 +558,36 @@ describe('cli.tsx — background routing behavior', () => {
expect(mockEnableConfigs).not.toHaveBeenCalled()
})
it('runs an internal cleanup worker before any command path', async () => {
process.env[BACKGROUND_SESSION_CLEANUP_WORKER_ENV] = '1'
try {
await runCliEntrypoint(['ps'], bgOptions)
} finally {
delete process.env[BACKGROUND_SESSION_CLEANUP_WORKER_ENV]
}
expect(mockRunBackgroundSessionCleanupWorker).toHaveBeenCalledTimes(1)
expect(mockPrepareBackgroundSessionFinalizer).not.toHaveBeenCalled()
expect(mockPsHandler).not.toHaveBeenCalled()
expect(mockEnableConfigs).toHaveBeenCalledTimes(1)
expect(mockResetSettingsCache).toHaveBeenCalledTimes(1)
expect(mockEagerLoadSettingsFromArgs.mock.calls).toEqual([[['ps']]])
expect(mockRunBackgroundSessionCleanupWorker).toHaveBeenCalledWith({
reloadSettings: expect.any(Function),
})
const workerOptions = (
mockRunBackgroundSessionCleanupWorker.mock.calls as unknown as Array<
[{ reloadSettings?: () => boolean }]
>
)[0]?.[0]
expect(workerOptions?.reloadSettings?.()).toBe(true)
expect(mockResetSettingsCache).toHaveBeenCalledTimes(2)
expect(mockEagerLoadSettingsFromArgs.mock.calls).toEqual([
[['ps']],
[['ps']],
])
})
it('routes partial background metadata through the finalizer before dispatch', async () => {
process.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV] = '123'
try {
@@ -543,10 +613,22 @@ describe('cli.tsx — background routing behavior', () => {
await runCliEntrypoint([command, '--bg', 'session-1'], bgOptions)
expect(handler.mock.calls).toEqual([[['--bg', 'session-1']]])
const tail = ['--bg', 'session-1']
if (command === 'kill') {
expect(mockKillHandler.mock.calls).toEqual([
[tail, { retentionSettingsReady: true }],
])
} else {
expect(handler.mock.calls).toEqual([[tail]])
}
expect(mockParseProviderEnvFileArgs).not.toHaveBeenCalled()
expect(mockHandleBgFlag).not.toHaveBeenCalled()
expect(mockEnableConfigs).not.toHaveBeenCalled()
expect(mockEnableConfigs).toHaveBeenCalledTimes(
command === 'kill' ? 1 : 0,
)
expect(mockEagerLoadSettingsFromArgs).toHaveBeenCalledTimes(
command === 'kill' ? 1 : 0,
)
expect(mockValidateProviderEnvForStartupOrExit).not.toHaveBeenCalled()
expect(mockCliMain).not.toHaveBeenCalled()
}
+26 -1
View File
@@ -1,5 +1,6 @@
import { feature } from 'bun:bundle';
import {
BACKGROUND_SESSION_CLEANUP_WORKER_ENV,
BACKGROUND_SESSION_ID_ENV,
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
} from '../cli/bgRouting.js'
@@ -265,6 +266,9 @@ type CliEntrypointImporters = {
flagSettings: () => Promise<
typeof import('../utils/settings/flagSettings.js')
>
settingsCache: () => Promise<
typeof import('../utils/settings/settingsCache.js')
>
agentRouting: () => Promise<
typeof import('../services/api/agentRouting.js')
>
@@ -289,6 +293,7 @@ const defaultCliEntrypointImporters: CliEntrypointImporters = {
providerProfile: () => import('../utils/providerProfile.js'),
providerValidation: () => import('../utils/providerValidation.js'),
flagSettings: () => import('../utils/settings/flagSettings.js'),
settingsCache: () => import('../utils/settings/settingsCache.js'),
agentRouting: () => import('../services/api/agentRouting.js'),
settings: () => import('../utils/settings/settings.js'),
cliArgs: () => import('../utils/cliArgs.js'),
@@ -319,6 +324,20 @@ export async function main(
options: CliEntrypointOptions = {},
): Promise<void> {
const importers = getCliEntrypointImporters(options.importers)
if (process.env[BACKGROUND_SESSION_CLEANUP_WORKER_ENV] === '1') {
const { enableConfigs } = await importers.config()
enableConfigs()
const { eagerLoadSettingsFromArgs } = await importers.flagSettings()
const { resetSettingsCache } = await importers.settingsCache()
const reloadSettings = () => {
resetSettingsCache()
return eagerLoadSettingsFromArgs(args).ok
}
if (!reloadSettings()) return
const { runBackgroundSessionCleanupWorker } = await importers.bgFinalizer()
await runBackgroundSessionCleanupWorker({ reloadSettings })
return
}
// The detached CLI is the registered background-session PID. Establish
// exact registry ownership and install its terminal finalizer before any
// fast path or startup validation can call process.exit(). The private env
@@ -368,7 +387,13 @@ export async function main(
await bg.attachHandler(args.slice(1));
break;
case 'kill':
await bg.killHandler(args.slice(1));
{
const { enableConfigs } = await importers.config()
enableConfigs()
const { eagerLoadSettingsFromArgs } = await importers.flagSettings()
const retentionSettingsReady = eagerLoadSettingsFromArgs(args).ok
await bg.killHandler(args.slice(1), { retentionSettingsReady });
}
break;
}
return;
+1 -1
View File
@@ -114,7 +114,7 @@ Plugin syntax: \`plugin-name@source\` where source is \`claude-code-marketplace\
### Other Settings
- \`language\`: Preferred response language (e.g., "japanese")
- \`cleanupPeriodDays\`: Days to keep transcripts (default: 30; 0 disables persistence entirely)
- \`cleanupPeriodDays\`: Days to keep local session artifacts, including transcripts and completed background sessions (default: 30; 0 disables transcript persistence and removes background artifacts after completion)
- \`respectGitignore\`: Whether to respect .gitignore (default: true)
- \`spinnerTipsEnabled\`: Show tips in spinner
- \`spinnerVerbs\`: Customize spinner verbs (\`{ "mode": "append" | "replace", "verbs": [...] }\`)
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'bun:test'
import {
startBackgroundHousekeeping,
startBackgroundSessionReconciliation,
} from './backgroundHousekeeping.js'
describe('background session housekeeping', () => {
it('starts recurring background-session reconciliation', async () => {
let callback: (() => void) | undefined
let runs = 0
let retentionSchedules = 0
startBackgroundHousekeeping({
backgroundSessionReconciliation: {
cleanup: async () => {
runs++
},
setInterval: scheduledCallback => {
callback = scheduledCallback
return { unref: () => {} }
},
},
backgroundSessionRetention: {
setInterval: () => {
retentionSchedules++
return { unref: () => {} }
},
},
_reconciliationOnlyForTesting: true,
})
callback?.()
await new Promise(resolve => setTimeout(resolve, 0))
expect(runs).toBe(1)
expect(retentionSchedules).toBe(0)
})
it('schedules recurring positive-retention cleanup every 24 hours', async () => {
let callback: (() => void) | undefined
let intervalMs: number | undefined
let runs = 0
startBackgroundHousekeeping({
backgroundSessionReconciliation: {
setInterval: () => ({ unref: () => {} }),
},
backgroundSessionRetention: {
cleanup: async () => {
runs++
},
setInterval: (scheduledCallback, scheduledIntervalMs) => {
callback = scheduledCallback
intervalMs = scheduledIntervalMs
return { unref: () => {} }
},
},
_backgroundSessionTimersOnlyForTesting: true,
})
expect(intervalMs).toBe(24 * 60 * 60 * 1000)
callback?.()
await new Promise(resolve => setTimeout(resolve, 0))
expect(runs).toBe(1)
})
it('runs recurring reconciliation without overlapping passes', async () => {
let callback: (() => void) | undefined
let intervalMs: number | undefined
let unrefCalls = 0
let runs = 0
let releaseFirstRun!: () => void
const firstRun = new Promise<void>(resolve => {
releaseFirstRun = resolve
})
startBackgroundSessionReconciliation({
cleanup: async () => {
runs++
if (runs === 1) await firstRun
},
setInterval: (scheduledCallback, scheduledIntervalMs) => {
callback = scheduledCallback
intervalMs = scheduledIntervalMs
return {
unref: () => {
unrefCalls++
},
}
},
})
expect(intervalMs).toBe(60_000)
expect(unrefCalls).toBe(1)
callback?.()
callback?.()
await Promise.resolve()
expect(runs).toBe(1)
releaseFirstRun()
await firstRun
await new Promise(resolve => setTimeout(resolve, 0))
callback?.()
await Promise.resolve()
expect(runs).toBe(2)
})
})
+76 -2
View File
@@ -15,20 +15,95 @@ const registerProtocolModule = feature('LODESTONE')
import { getIsInteractive, getLastInteractionTime } from '../bootstrap/state.js'
import {
cleanupBackgroundSessionRetentionInBackground,
cleanupBackgroundSessionsInBackground,
cleanupNpmCacheForAnthropicPackages,
cleanupOldMessageFilesInBackground,
cleanupOldVersionsThrottled,
} from './cleanup.js'
import { logError } from './log.js'
import { cleanupOldVersions } from './nativeInstaller/index.js'
import { autoUpdateMarketplacesAndPluginsInBackground } from './plugins/pluginAutoupdate.js'
// 24 hours in milliseconds
const RECURRING_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
// Terminal facts are a durable handoff from background children. The cleanup
// coordinator globally throttles this minute trigger and streams only a bounded
// fact/metadata sample, without rerunning the age-based retention sweep.
const BACKGROUND_SESSION_RECONCILIATION_INTERVAL_MS = 60 * 1000
// 10 minutes after start.
const DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION = 10 * 60 * 1000
export function startBackgroundHousekeeping(): void {
type BackgroundSessionScheduleOptions = {
cleanup?: () => Promise<unknown>
setInterval?: (
callback: () => void,
intervalMs: number,
) => { unref(): unknown }
_onPassFinishedForTesting?: () => void
}
type BackgroundHousekeepingOptions = {
backgroundSessionReconciliation?: BackgroundSessionScheduleOptions
backgroundSessionRetention?: BackgroundSessionScheduleOptions
_reconciliationOnlyForTesting?: boolean
_backgroundSessionTimersOnlyForTesting?: boolean
}
function scheduleNonOverlappingBackgroundCleanup(
cleanup: () => Promise<unknown>,
intervalMs: number,
options: BackgroundSessionScheduleOptions,
): void {
const scheduleInterval =
options.setInterval ??
((callback, intervalMs) => setInterval(callback, intervalMs))
let running = false
const interval = scheduleInterval(() => {
if (running) return
running = true
void Promise.resolve()
.then(() => cleanup())
.catch(error => logError(error as Error))
.finally(() => {
running = false
options._onPassFinishedForTesting?.()
})
}, intervalMs)
interval.unref()
}
function startBackgroundSessionRetention(
options: BackgroundSessionScheduleOptions = {},
): void {
scheduleNonOverlappingBackgroundCleanup(
options.cleanup ?? cleanupBackgroundSessionRetentionInBackground,
RECURRING_CLEANUP_INTERVAL_MS,
options,
)
}
export function startBackgroundSessionReconciliation(
options: BackgroundSessionScheduleOptions = {},
): void {
scheduleNonOverlappingBackgroundCleanup(
options.cleanup ?? cleanupBackgroundSessionsInBackground,
BACKGROUND_SESSION_RECONCILIATION_INTERVAL_MS,
options,
)
}
export function startBackgroundHousekeeping(
options: BackgroundHousekeepingOptions = {},
): void {
startBackgroundSessionReconciliation(
options.backgroundSessionReconciliation,
)
if (options._reconciliationOnlyForTesting) return
startBackgroundSessionRetention(options.backgroundSessionRetention)
if (options._backgroundSessionTimersOnlyForTesting) return
void initMagicDocs()
void initSkillImprovement()
if (feature('EXTRACT_MEMORIES')) {
@@ -39,7 +114,6 @@ export function startBackgroundHousekeeping(): void {
if (feature('LODESTONE') && getIsInteractive()) {
void registerProtocolModule!.ensureDeepLinkProtocolRegistered()
}
let needsCleanup = true
async function runVerySlowOps(): Promise<void> {
// If the user did something in the last minute, don't make them wait for these slow operations to run.
+1179 -2
View File
File diff suppressed because it is too large Load Diff
+309 -3
View File
@@ -1,6 +1,13 @@
import * as fs from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { dirname, join } from 'path'
import {
cleanupBackgroundSessionsBefore,
reconcileBackgroundSessionTerminalFacts,
snapshotBackgroundSessionRecoveryJournal,
takeBackgroundSessionRecoveryBatch,
type BackgroundSessionRecoveryTarget,
} from '../cli/bgRegistry.js'
import { logEvent } from '../services/analytics/index.js'
import { CACHE_PATHS } from './cachePaths.js'
import { logForDebugging } from './debug.js'
@@ -15,12 +22,49 @@ import { getDefaultPlansDirectory } from './plans.js'
import { getSettingsWithAllErrors } from './settings/allErrors.js'
import {
getSettings_DEPRECATED,
getSettingsWithErrors,
rawSettingsContainsKey,
} from './settings/settings.js'
import { resetSettingsCache } from './settings/settingsCache.js'
import { TOOL_RESULTS_SUBDIR } from './toolResultStorage.js'
import { cleanupStaleAgentWorktrees } from './worktree.js'
const DEFAULT_CLEANUP_PERIOD_DAYS = 30
const BACKGROUND_RECOVERY_INTERVAL_MS = 60 * 1000
const BACKGROUND_RETENTION_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000
const BACKGROUND_RECOVERY_ENTRY_LIMIT = 256
export type BackgroundSessionRetentionTrigger =
| 'natural-finalization'
| 'explicit-kill'
| 'periodic-recovery'
| 'periodic-retention'
export type BackgroundSessionRetentionResult =
| 'ran'
| 'partial'
| 'failed'
| 'skipped'
| 'invalid-policy'
type BackgroundSessionRetentionRequest =
| {
trigger: 'natural-finalization'
sessionId: string
waitForProcessesToExit: () => Promise<boolean>
reloadSettings?: () => boolean
}
| { trigger: 'explicit-kill'; sessionId: string }
| {
trigger: 'periodic-recovery'
_afterThrottleLockForTesting?: () => Promise<void>
_recoveryBatchLimitForTesting?: number
}
| {
trigger: 'periodic-retention'
_afterThrottleLockForTesting?: () => Promise<void>
_afterJournalSnapshotForTesting?: () => Promise<void>
}
function getCutoffDate(): Date {
const settings = getSettings_DEPRECATED() || {}
@@ -588,8 +632,7 @@ export async function cleanupOldMessageFilesInBackground(): Promise<void> {
// If settings have validation errors but the user explicitly set cleanupPeriodDays,
// skip cleanup entirely rather than falling back to the default (30 days).
// This prevents accidentally deleting files when the user intended a different retention period.
const { errors } = getSettingsWithAllErrors()
if (errors.length > 0 && rawSettingsContainsKey('cleanupPeriodDays')) {
if (cleanupPeriodSettingIsInvalid()) {
logForDebugging(
'Skipping cleanup: settings have validation errors but cleanupPeriodDays was explicitly set. Fix settings errors to enable cleanup.',
)
@@ -598,6 +641,7 @@ export async function cleanupOldMessageFilesInBackground(): Promise<void> {
await cleanupOldMessageFiles()
await cleanupOldSessionFiles()
await runBackgroundSessionRetention({ trigger: 'periodic-retention' })
await cleanupOldPlanFiles()
await cleanupOldFileHistoryBackups()
await cleanupOldSessionEnvDirs()
@@ -612,3 +656,265 @@ export async function cleanupOldMessageFilesInBackground(): Promise<void> {
await cleanupNpmCacheForAnthropicPackages()
}
}
function readBackgroundRetentionPeriod(
options: { fresh?: boolean } = {},
):
| { state: 'valid'; days: number }
| { state: 'invalid' } {
try {
if (options.fresh) resetSettingsCache()
const { errors } = getSettingsWithErrors()
if (errors.length > 0 && rawSettingsContainsKey('cleanupPeriodDays')) {
return { state: 'invalid' }
}
const days =
getSettings_DEPRECATED()?.cleanupPeriodDays ??
DEFAULT_CLEANUP_PERIOD_DAYS
return Number.isFinite(days) && days >= 0
? { state: 'valid', days }
: { state: 'invalid' }
} catch {
return { state: 'invalid' }
}
}
function logBackgroundReconciliation(
reconciliation: Awaited<
ReturnType<typeof reconcileBackgroundSessionTerminalFacts>
>,
): void {
if (reconciliation.errors > 0) {
logForDebugging(
`Background session reconciliation: ${reconciliation.sessionsUpdated} sessions, ${reconciliation.errors} errors`,
)
}
}
function logBackgroundCleanup(
backgroundResult: Awaited<
ReturnType<typeof cleanupBackgroundSessionsBefore>
>,
): void {
if (
backgroundResult.sessionsRemoved > 0 ||
backgroundResult.artifactsRemoved > 0 ||
backgroundResult.errors > 0
) {
logForDebugging(
`Background session cleanup: ${backgroundResult.sessionsRemoved} sessions, ${backgroundResult.artifactsRemoved} artifacts, ${backgroundResult.errors} errors`,
)
}
}
async function runWithBackgroundRetentionThrottle(
markerName: string,
intervalMs: number,
operation: () => Promise<
Exclude<BackgroundSessionRetentionResult, 'skipped'>
>,
afterLock?: () => Promise<void>,
): Promise<BackgroundSessionRetentionResult> {
const markerPath = join(
getClaudeConfigHomeDir(),
'bg-sessions',
markerName,
)
const markerIsFresh = async (): Promise<boolean> => {
try {
return Date.now() - (await fs.stat(markerPath)).mtimeMs < intervalMs
} catch {
return false
}
}
let locked = false
try {
if (await markerIsFresh()) return 'skipped'
await fs.mkdir(dirname(markerPath), { recursive: true })
await lockfile.lock(markerPath, { retries: 0, realpath: false })
locked = true
} catch {
return 'skipped'
}
try {
if (await markerIsFresh()) return 'skipped'
await afterLock?.()
const outcome = await operation()
if (outcome !== 'ran' && outcome !== 'partial') return outcome
try {
await fs.writeFile(markerPath, new Date().toISOString())
} catch (error) {
logError(error instanceof Error ? error : new Error(String(error)))
}
return outcome
} catch (error) {
logError(error instanceof Error ? error : new Error(String(error)))
return 'failed'
} finally {
if (locked) {
await lockfile.unlock(markerPath, { realpath: false }).catch(() => {})
}
}
}
export async function runBackgroundSessionRetention(
request: BackgroundSessionRetentionRequest,
): Promise<BackgroundSessionRetentionResult> {
let policy = readBackgroundRetentionPeriod()
if (policy.state === 'invalid') return 'invalid-policy'
if (request.trigger === 'natural-finalization') {
if (policy.days !== 0) return 'skipped'
if (!(await request.waitForProcessesToExit())) return 'skipped'
if (request.reloadSettings && !request.reloadSettings()) {
return 'invalid-policy'
}
policy = readBackgroundRetentionPeriod()
if (policy.state === 'invalid') return 'invalid-policy'
if (policy.days !== 0) return 'skipped'
}
if (
request.trigger === 'natural-finalization' ||
request.trigger === 'explicit-kill'
) {
const reconciliation = await reconcileBackgroundSessionTerminalFacts({
sessionIds: [request.sessionId],
})
logBackgroundReconciliation(reconciliation)
if (policy.days === 0) {
const cleanup = await cleanupBackgroundSessionsBefore(
new Date(Date.now() + 1),
{ sessionIds: [request.sessionId] },
)
logBackgroundCleanup(cleanup)
}
return 'ran'
}
if (request.trigger === 'periodic-recovery') {
return await runWithBackgroundRetentionThrottle(
'.recovery-pass',
BACKGROUND_RECOVERY_INTERVAL_MS,
async () => {
const currentPolicy = readBackgroundRetentionPeriod({ fresh: true })
if (currentPolicy.state === 'invalid') return 'invalid-policy'
if (currentPolicy.days !== 0) return 'ran'
const batch = await takeBackgroundSessionRecoveryBatch(
request._recoveryBatchLimitForTesting ??
BACKGROUND_RECOVERY_ENTRY_LIMIT,
)
const retrySessionIds: string[] = []
const retryTerminalFacts: typeof batch.terminalFacts = []
for (const id of batch.sessionIds) {
// Attribute retryable errors to their ID, so retained entries cannot
// hold unrelated work at the front of the bounded journal.
const reconciliation =
await reconcileBackgroundSessionTerminalFacts({
sessionIds: [id],
})
logBackgroundReconciliation(reconciliation)
const cleanup = await cleanupBackgroundSessionsBefore(
new Date(Date.now() + 1),
{
sessionIds: [id],
orphanedTerminalFacts: batch.terminalFacts.filter(
target => target.id === id,
),
onRetry: target => {
if (target) retryTerminalFacts.push(target)
},
},
)
logBackgroundCleanup(cleanup)
if (reconciliation.errors > 0 || cleanup.errors > 0) {
retrySessionIds.push(id)
}
}
// Save retries before publishing the cursor that acknowledges this batch.
await batch.commit(retrySessionIds, retryTerminalFacts)
return retrySessionIds.length > 0 ? 'partial' : 'ran'
},
request._afterThrottleLockForTesting,
)
}
return await runWithBackgroundRetentionThrottle(
'.retention-pass',
BACKGROUND_RETENTION_SWEEP_INTERVAL_MS,
async () => {
resetSettingsCache()
if (cleanupPeriodSettingIsInvalid()) return 'invalid-policy'
const currentPolicy = readBackgroundRetentionPeriod({ fresh: true })
if (currentPolicy.state === 'invalid') return 'invalid-policy'
const cutoff = new Date(
Date.now() - currentPolicy.days * 24 * 60 * 60 * 1000,
)
const journal = await snapshotBackgroundSessionRecoveryJournal()
await request._afterJournalSnapshotForTesting?.()
const retryTargets: BackgroundSessionRecoveryTarget[] = []
let retryInventoryComplete = true
const onRetry = (target?: BackgroundSessionRecoveryTarget) => {
if (target) retryTargets.push(target)
else retryInventoryComplete = false
}
const reconciliation = await reconcileBackgroundSessionTerminalFacts({
onRetry,
})
logBackgroundReconciliation(reconciliation)
const cleanup = await cleanupBackgroundSessionsBefore(cutoff, {
onRetry,
})
logBackgroundCleanup(cleanup)
// A full sweep owns the snapshot only when all its work succeeded.
// On partial failure leave it discoverable by a later process.
if (reconciliation.errors > 0 || cleanup.errors > 0) {
await journal.retry(retryTargets)
return retryInventoryComplete ? 'partial' : 'failed'
}
await journal.commit()
return 'ran'
},
request._afterThrottleLockForTesting,
)
}
function cleanupPeriodSettingIsInvalid(): boolean {
try {
const { errors } = getSettingsWithAllErrors()
return errors.length > 0 && rawSettingsContainsKey('cleanupPeriodDays')
} catch {
return true
}
}
export async function cleanupBackgroundSessionsInBackground(): Promise<
BackgroundSessionRetentionResult
> {
return await runBackgroundSessionRetention({
trigger: 'periodic-recovery',
})
}
export async function cleanupBackgroundSessionRetentionInBackground(): Promise<
BackgroundSessionRetentionResult
> {
resetSettingsCache()
if (cleanupPeriodSettingIsInvalid()) return 'invalid-policy'
return await runBackgroundSessionRetention({
trigger: 'periodic-retention',
})
}
export async function cleanupBackgroundSessionsAfterFinalization(
sessionId: string,
waitForProcessesToExit: () => Promise<boolean>,
reloadSettings?: () => boolean,
): Promise<BackgroundSessionRetentionResult> {
return await runBackgroundSessionRetention({
trigger: 'natural-finalization',
sessionId,
waitForProcessesToExit,
...(reloadSettings ? { reloadSettings } : {}),
})
}
@@ -0,0 +1,380 @@
import { createHash } from 'node:crypto'
import { mkdir, utimes } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
createBackgroundSession,
recordBackgroundSessionNaturalTerminationSync,
} from '../cli/bgRegistry.js'
import { killHandler } from '../cli/bg.js'
import { enableConfigs } from './config.js'
import { eagerLoadSettingsFromArgs } from './settings/flagSettings.js'
import {
cleanupBackgroundSessionsAfterFinalization,
cleanupBackgroundSessionsInBackground,
cleanupOldMessageFilesInBackground,
runBackgroundSessionRetention,
} from './cleanup.js'
try {
const mode = process.env.OPENCLAUDE_CLEANUP_FIXTURE_MODE
if (mode === 'pass-outcome') {
enableConfigs()
const settingsPath = join(
process.env.OPENCLAUDE_CONFIG_DIR!,
'settings.json',
)
const settingsResult = eagerLoadSettingsFromArgs([
'--settings',
settingsPath,
])
if (!settingsResult.ok) throw new Error(settingsResult.message)
const trigger =
process.env.OPENCLAUDE_CLEANUP_TRIGGER === 'periodic-retention'
? 'periodic-retention'
: 'periodic-recovery'
const afterLock = async () => {
if (process.env.OPENCLAUDE_CLEANUP_REJECT_AFTER_LOCK === '1') {
await Bun.write(
settingsPath,
JSON.stringify({ cleanupPeriodDays: 'invalid' }),
)
}
}
const result = await runBackgroundSessionRetention(
trigger === 'periodic-recovery'
? { trigger, _afterThrottleLockForTesting: afterLock }
: {
trigger,
_afterThrottleLockForTesting: afterLock,
_afterJournalSnapshotForTesting: async () => {
if (
process.env.OPENCLAUDE_CLEANUP_APPEND_AFTER_SNAPSHOT !== '1'
)
return
const session = await createBackgroundSession({
id: 'bg-snapshot-appended',
pid: process.pid,
cwd: process.cwd(),
command: ['openclaude', '--print', 'fixture'],
sessionId: 'snapshot-appended-conversation',
})
recordBackgroundSessionNaturalTerminationSync(
session.id,
{ exitCode: 0 },
{
ownerPid: process.pid,
expectedSession: session,
},
)
if (
process.env.OPENCLAUDE_CLEANUP_THROW_AFTER_SNAPSHOT === '1'
) {
throw new Error('intentional full-sweep interruption')
}
},
},
)
process.stdout.write(`${JSON.stringify({ result })}\n`)
} else if (
mode === 'periodic-retention' ||
mode === 'periodic-retention-policy-recheck'
) {
enableConfigs()
const settingsResult = eagerLoadSettingsFromArgs([
'--settings',
join(process.env.OPENCLAUDE_CONFIG_DIR!, 'settings.json'),
])
if (!settingsResult.ok) throw new Error(settingsResult.message)
const { cleanupBackgroundSessionRetentionInBackground } =
await import('./cleanup.js')
process.stdout.write(
`${JSON.stringify({
result:
mode === 'periodic-retention'
? await cleanupBackgroundSessionRetentionInBackground()
: await runBackgroundSessionRetention({
trigger: 'periodic-retention',
_afterThrottleLockForTesting: async () => {
await Bun.write(
join(process.cwd(), '.mcp.json'),
'{invalid',
)
},
}),
})}\n`,
)
} else if (
mode === 'periodic-recovery' ||
mode === 'periodic-recovery-bounded'
) {
enableConfigs()
const settingsResult = eagerLoadSettingsFromArgs([
'--settings',
join(process.env.OPENCLAUDE_CONFIG_DIR!, 'settings.json'),
])
if (!settingsResult.ok) throw new Error(settingsResult.message)
process.stdout.write(
`${JSON.stringify({
result:
mode === 'periodic-recovery'
? await cleanupBackgroundSessionsInBackground()
: await runBackgroundSessionRetention({
trigger: 'periodic-recovery',
_recoveryBatchLimitForTesting: (() => {
const limit = Number(
process.env.OPENCLAUDE_CLEANUP_RECOVERY_LIMIT,
)
if (!Number.isSafeInteger(limit) || limit < 1) {
throw new Error(
'OPENCLAUDE_CLEANUP_RECOVERY_LIMIT must be a positive safe integer',
)
}
return limit
})(),
}),
})}\n`,
)
} else if (mode === 'periodic-policy-recheck') {
enableConfigs()
const settingsPath = join(
process.env.OPENCLAUDE_CONFIG_DIR!,
'settings.json',
)
const settingsResult = eagerLoadSettingsFromArgs([
'--settings',
settingsPath,
])
if (!settingsResult.ok) throw new Error(settingsResult.message)
const session = await createBackgroundSession({
id: 'bg-periodic-policy-recheck',
pid: process.pid,
cwd: process.cwd(),
command: ['openclaude', '--print', 'fixture'],
sessionId: 'bg-periodic-policy-recheck-conversation',
})
recordBackgroundSessionNaturalTerminationSync(
session.id,
{ exitCode: 0 },
{
ownerPid: process.pid,
expectedSession: session,
now: new Date(Date.now() - 1_000),
},
)
const result = await runBackgroundSessionRetention({
trigger: 'periodic-recovery',
_afterThrottleLockForTesting: async () => {
await Bun.write(
settingsPath,
JSON.stringify({ cleanupPeriodDays: 30 }),
)
},
})
process.stdout.write(
`${JSON.stringify({
result,
metadataPresent: await Bun.file(
join(
dirname(dirname(session.stdoutLogPath)),
'sessions',
`${session.id}.json`,
),
).exists(),
})}\n`,
)
} else if (mode === 'marker-failure') {
enableConfigs()
const settingsPath = join(
process.env.OPENCLAUDE_CONFIG_DIR!,
'settings.json',
)
const settingsResult = eagerLoadSettingsFromArgs([
'--settings',
settingsPath,
])
if (!settingsResult.ok) throw new Error(settingsResult.message)
const plansDir = join(process.env.OPENCLAUDE_CONFIG_DIR!, 'plans')
const planPath = join(plansDir, 'old-plan.md')
await mkdir(plansDir, { recursive: true })
await Bun.write(planPath, 'old plan')
const old = new Date('2000-01-01T00:00:00.000Z')
await utimes(planPath, old, old)
await Bun.write(
join(process.env.OPENCLAUDE_CONFIG_DIR!, 'bg-sessions'),
'not a directory',
)
await cleanupOldMessageFilesInBackground()
process.stdout.write(
`${JSON.stringify({
completed: true,
planPresent: await Bun.file(planPath).exists(),
})}\n`,
)
} else if (mode === 'post-finalization-policy-recheck') {
const session = await createBackgroundSession({
id: 'bg-post-finalization-policy-recheck',
pid: process.pid,
cwd: process.cwd(),
command: ['openclaude', '--print', 'fixture'],
sessionId: 'bg-post-finalization-policy-recheck-conversation',
})
recordBackgroundSessionNaturalTerminationSync(
session.id,
{ exitCode: 0 },
{
ownerPid: process.pid,
expectedSession: session,
now: new Date(Date.now() - 1_000),
},
)
const settingsPath = join(
process.env.OPENCLAUDE_CONFIG_DIR!,
'settings.json',
)
let waits = 0
let reloads = 0
const result = await cleanupBackgroundSessionsAfterFinalization(
session.id,
async () => {
waits++
await Bun.write(
settingsPath,
JSON.stringify({ cleanupPeriodDays: 30 }),
)
return true
},
() => {
reloads++
return eagerLoadSettingsFromArgs(['--settings', settingsPath]).ok
},
)
process.stdout.write(
`${JSON.stringify({
result,
waits,
reloads,
metadataPresent: await Bun.file(
join(
dirname(dirname(session.stdoutLogPath)),
'sessions',
`${session.id}.json`,
),
).exists(),
})}\n`,
)
} else if (mode === 'post-finalization-gate') {
let waits = 0
await cleanupBackgroundSessionsAfterFinalization(
'bg-post-finalization-gate',
async () => {
waits++
return true
},
)
process.stdout.write(`${JSON.stringify({ waits })}\n`)
} else if (
mode === 'post-finalization-exact-cutoff' ||
mode === 'post-finalization-timeout'
) {
const exactNow = Date.now()
const processMarker = 'd'.repeat(64)
const session = await createBackgroundSession({
id: 'bg-post-finalization-exact',
name: 'post-finalization-exact',
pid: process.pid,
cwd: process.cwd(),
command: [
'openclaude',
`--openclaude-bg-session-marker=${processMarker}`,
'--print',
'fixture',
],
sessionId: 'bg-post-finalization-exact-conversation',
processMarker,
now: new Date(exactNow - 1_000),
})
const root = dirname(dirname(session.stdoutLogPath))
const metadataPath = join(root, 'sessions', `${session.id}.json`)
const reservationPath = join(
root,
'names',
`${createHash('sha256').update(session.name!).digest('hex')}.json`,
)
recordBackgroundSessionNaturalTerminationSync(
session.id,
{ exitCode: 0 },
{
ownerPid: process.pid,
expectedSession: session,
now: new Date(exactNow),
},
)
const originalDateNow = Date.now
let waits = 0
try {
Date.now = () => exactNow
await cleanupBackgroundSessionsAfterFinalization(
session.id,
async () => {
waits++
return mode === 'post-finalization-exact-cutoff'
},
)
} finally {
Date.now = originalDateNow
}
process.stdout.write(
`${JSON.stringify({
waits,
metadataPresent: await Bun.file(metadataPath).exists(),
reservationPresent: await Bun.file(reservationPath).exists(),
stdoutPresent: await Bun.file(session.stdoutLogPath).exists(),
stderrPresent: await Bun.file(session.stderrLogPath).exists(),
})}\n`,
)
} else if (mode === 'explicit-kill') {
const session = await createBackgroundSession({
id: 'bg-explicit-kill-cleanup',
pid: process.pid,
cwd: process.cwd(),
command: ['openclaude', '--print', 'fixture'],
sessionId: 'bg-explicit-kill-cleanup-conversation',
})
recordBackgroundSessionNaturalTerminationSync(
session.id,
{ exitCode: 0 },
{
ownerPid: process.pid,
expectedSession: session,
now: new Date(Date.now() - 1_000),
},
)
const originalConsoleLog = console.log
try {
console.log = () => {}
await killHandler([session.id])
} finally {
console.log = originalConsoleLog
}
const root = dirname(dirname(session.stdoutLogPath))
process.stdout.write(
`${JSON.stringify({
metadataPresent: await Bun.file(
join(root, 'sessions', `${session.id}.json`),
).exists(),
stdoutPresent: await Bun.file(session.stdoutLogPath).exists(),
stderrPresent: await Bun.file(session.stderrLogPath).exists(),
})}\n`,
)
} else {
await cleanupOldMessageFilesInBackground()
process.stdout.write(`${JSON.stringify({ completed: true })}\n`)
}
} catch (error) {
process.stderr.write(
`background cleanup fixture failed: ${
error instanceof Error ? (error.stack ?? error.message) : String(error)
}\n`,
)
process.exitCode = 1
}
+1 -1
View File
@@ -451,7 +451,7 @@ export const SettingsSchema = lazySchema(() =>
.int()
.optional()
.describe(
'Number of days to retain chat transcripts (default: 30). Setting to 0 disables session persistence entirely: no transcripts are written and existing transcripts are deleted at startup.',
'Number of days to retain local session artifacts, including chat transcripts and completed background-session metadata and logs (default: 30). Setting to 0 disables transcript persistence entirely and removes completed background-session artifacts after they finish.',
),
env: EnvironmentVariablesSchema()
.optional()
+1 -1
View File
@@ -47,7 +47,7 @@ const TIP_MATCHERS: TipMatcher[] = [
ctx.expected === '0',
tip: {
suggestion:
'Must be 0 or greater. Set a positive number for days to retain transcripts (default is 30). Setting 0 disables session persistence entirely: no transcripts are written and existing transcripts are deleted at startup.',
'Must be 0 or greater. Set a positive number for days to retain local session artifacts, including transcripts and completed background sessions (default is 30). Setting 0 disables transcript persistence and removes completed background-session artifacts after they finish.',
},
},
{