mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
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:
@@ -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}.`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+1133
-37
File diff suppressed because it is too large
Load Diff
+326
-12
@@ -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
+1058
-6
File diff suppressed because it is too large
Load Diff
+2068
-167
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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": [...] }\`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+309
-3
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user