Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,17 @@ usually `~/.openclaude/bg-sessions/`; `OPENCLAUDE_CONFIG_DIR` can point
OpenClaude somewhere else. `CLAUDE_CONFIG_DIR` is ignored for OpenClaude
background-session storage. Session names can be reused after older sessions
reach a terminal state; use the session ID to inspect older logs with the same
name.
name. A naturally finished session is recorded as `exited` when its process
returns zero and `failed` when it returns nonzero or handles a termination
signal. `stale` remains the conservative result when the process disappears
without an observed outcome; an explicit successful `openclaude kill` is
recorded as `killed`, and `killed` takes precedence over a natural `exited` or
`failed` outcome for the same process. Terminal outcomes are stored separately
under `bg-sessions/terminal/`; deleting that directory makes finished sessions
fall back to liveness-derived status. OpenClaude does not infer POSIX signal
names on Windows.
Unobservable force termination, host crashes, and power loss remain `stale` on
every platform.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`openclaude attach <id-or-name>` currently reports the matching session and
points to `openclaude logs <id> -f`; full terminal reattach is not implemented
Expand Down
165 changes: 163 additions & 2 deletions src/cli/bg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it } from 'bun:test'
import {
buildBackgroundSessionLaunch,
buildBackgroundChildProcessConfig,
confirmBackgroundSessionLaunch,
followLogFile,
killBackgroundSession,
printExistingLog,
Expand All @@ -15,6 +16,10 @@ import {
parseBackgroundInvocation,
parseLogsInvocation,
} from './bg.js'
import {
BACKGROUND_SESSION_ID_ENV,
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
} from './bgFinalizer.js'
import type {
BackgroundSession,
BackgroundSessionProcessIdentity,
Expand Down Expand Up @@ -453,7 +458,7 @@ describe('background session CLI parsing', () => {
})
})

it('preserves Node exec flags and lets the launcher manage heap relaunch state', () => {
it('preserves Node exec flags while keeping the registered child PID stable', () => {
const config = buildBackgroundChildProcessConfig({
execPath: '/usr/bin/node',
execArgv: ['--max-old-space-size=8192', '--expose-gc'],
Expand All @@ -465,6 +470,8 @@ describe('background session CLI parsing', () => {
},
sessionName: 'tests',
stdoutLogPath: '/tmp/bg.out.log',
backgroundSessionId: 'bg-tests',
launcherPid: 700,
})

expect(config.command).toBe('/usr/bin/node')
Expand All @@ -475,11 +482,53 @@ describe('background session CLI parsing', () => {
'--print',
'fix failing tests',
])
expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBeUndefined()
expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBe('1')
expect(config.env.OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB).toBe('8192')
expect(config.env.CLAUDE_CODE_SESSION_KIND).toBe('bg')
expect(config.env.CLAUDE_CODE_SESSION_LOG).toBe('/tmp/bg.out.log')
expect(config.env.CLAUDE_CODE_SESSION_NAME).toBe('tests')
expect(config.env[BACKGROUND_SESSION_ID_ENV]).toBe('bg-tests')
expect(config.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBe('700')
})

it('supplies launcher heap flags instead of relaunching to a different PID', () => {
const config = buildBackgroundChildProcessConfig({
execPath: '/usr/bin/node',
execArgv: [],
entrypoint: '/repo/bin/openclaude',
childArgs: ['--print', 'fix failing tests'],
processEnv: {
OPENCLAUDE_HEAP_RELAUNCHED: '1',
OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB: '4096',
},
stdoutLogPath: '/tmp/bg.out.log',
backgroundSessionId: 'bg-no-wrapper',
launcherPid: 701,
})

expect(config.args.slice(0, 2)).toEqual([
'--max-old-space-size=4096',
'--expose-gc',
])
expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBe('1')
})

it('prevents the installed launcher from replacing a non-Node registered PID', () => {
const config = buildBackgroundChildProcessConfig({
execPath: '/usr/local/bin/bun',
execArgv: [],
entrypoint: '/repo/bin/openclaude',
childArgs: ['--print', 'work'],
processEnv: {},
stdoutLogPath: '/tmp/bg.out.log',
backgroundSessionId: 'bg-bun-owner',
launcherPid: 702,
})

expect(config.command).toBe('/usr/local/bin/bun')
expect(config.env.OPENCLAUDE_HEAP_RELAUNCHED).toBe('1')
expect(config.env[BACKGROUND_SESSION_ID_ENV]).toBe('bg-bun-owner')
expect(config.env[BACKGROUND_SESSION_LAUNCHER_PID_ENV]).toBe('702')
})

it('escalates process-tree termination and waits for exit before returning', async () => {
Expand All @@ -502,6 +551,118 @@ describe('background session CLI parsing', () => {

expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
})

it('fails a detached launch that becomes stale before finalizer installation', async () => {
const session: BackgroundSession = {
id: 'bg-finalizer-not-installed',
pid: 4243,
cwd: '/repo',
status: 'running',
startedAt: '2026-07-10T08:00:00.000Z',
updatedAt: '2026-07-10T08:00:00.000Z',
sessionId: 'conversation-finalizer-not-installed',
command: ['node', 'openclaude', '--print', 'work'],
stdoutLogPath: '/tmp/bg-finalizer-not-installed.out.log',
stderrLogPath: '/tmp/bg-finalizer-not-installed.err.log',
}
const calls: string[] = []

await expect(
confirmBackgroundSessionLaunch(session, {
isProcessAlive: () => false,
refreshStatuses: async () => {
calls.push('refresh')
return []
},
resolveSession: async id => {
calls.push(`resolve:${id}`)
return { ...session, status: 'stale' }
},
}),
).rejects.toThrow(
'Background session bg-finalizer-not-installed exited before finalization was installed. ' +
'Logs were retained at /tmp/bg-finalizer-not-installed.out.log and /tmp/bg-finalizer-not-installed.err.log.',
)
expect(calls).toEqual([
'refresh',
'resolve:bg-finalizer-not-installed',
])
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('returns a live launch without consulting the registry', async () => {
const session: BackgroundSession = {
id: 'bg-live-confirmation',
pid: 4244,
cwd: '/repo',
status: 'running',
startedAt: '2026-07-10T08:00:00.000Z',
updatedAt: '2026-07-10T08:00:00.000Z',
sessionId: 'conversation-live-confirmation',
command: ['node', 'openclaude', '--print', 'work'],
stdoutLogPath: '/tmp/bg-live-confirmation.out.log',
stderrLogPath: '/tmp/bg-live-confirmation.err.log',
}
const calls: string[] = []

const confirmed = await confirmBackgroundSessionLaunch(session, {
isProcessAlive: () => true,
refreshStatuses: async () => {
calls.push('refresh')
return []
},
resolveSession: async id => {
calls.push(`resolve:${id}`)
return session
},
})

expect(confirmed).toBe(session)
expect(calls).toEqual([])
})

it('returns an authoritative terminal launch after refreshing a dead PID', async () => {
const session: BackgroundSession = {
id: 'bg-terminal-confirmation',
pid: 4245,
cwd: '/repo',
status: 'running',
startedAt: '2026-07-10T08:00:00.000Z',
updatedAt: '2026-07-10T08:00:00.000Z',
sessionId: 'conversation-terminal-confirmation',
command: ['node', 'openclaude', '--print', 'work'],
stdoutLogPath: '/tmp/bg-terminal-confirmation.out.log',
stderrLogPath: '/tmp/bg-terminal-confirmation.err.log',
}
const terminal: BackgroundSession = {
...session,
status: 'failed',
updatedAt: '2026-07-10T08:00:01.000Z',
finishedAt: '2026-07-10T08:00:01.000Z',
exitCode: 23,
terminalReason: 'exit_code',
}
const calls: string[] = []

const confirmed = await confirmBackgroundSessionLaunch(session, {
isProcessAlive: () => false,
refreshStatuses: async () => {
calls.push('refresh')
return [terminal]
},
resolveSession: async id => {
calls.push(`resolve:${id}`)
return terminal
},
})

expect(confirmed).toMatchObject({
status: 'failed',
finishedAt: '2026-07-10T08:00:01.000Z',
exitCode: 23,
terminalReason: 'exit_code',
})
expect(calls).toEqual(['refresh', 'resolve:bg-terminal-confirmation'])
})
})

describe('background session process termination safety', () => {
Expand Down
96 changes: 90 additions & 6 deletions src/cli/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
type BackgroundSessionProcessIdentity,
type BackgroundSessionProcessIdentityOptions,
} from './bgRegistry.js'
import {
BACKGROUND_SESSION_ID_ENV,
BACKGROUND_SESSION_LAUNCHER_PID_ENV,
} from './bgRouting.js'

export type ParsedBackgroundInvocation = {
name?: string
Expand Down Expand Up @@ -50,6 +54,8 @@ export type BuildBackgroundChildProcessConfigInput = {
processEnv: NodeJS.ProcessEnv
sessionName?: string
stdoutLogPath: string
backgroundSessionId: string
launcherPid?: number
}

type PrResumeSelector = true | string
Expand All @@ -61,6 +67,8 @@ export type BuildBackgroundSessionLaunchDeps = {
}

const HEAP_RELAUNCHED_ENV = 'OPENCLAUDE_HEAP_RELAUNCHED'
const HEAP_SIZE_ENV = 'OPENCLAUDE_NODE_MAX_OLD_SPACE_SIZE_MB'
const DEFAULT_HEAP_SIZE_MB = 8192
const DEFAULT_TERM_GRACE_MS = 2_000
const DEFAULT_KILL_GRACE_MS = 2_000
const DEFAULT_KILL_POLL_INTERVAL_MS = 100
Expand Down Expand Up @@ -165,13 +173,43 @@ const SPACE_OPTIONAL_VALUE_FLAGS = new Set([
'-r',
])

function safeNodeExecArgvForBackground(execArgv: string[]): string[] {
return execArgv.filter(
function isNodeExecutable(execPath: string): boolean {
return /^node(?:\.exe)?$/i.test(basename(execPath))
}

function hasNodeFlag(args: string[], flag: string): boolean {
return args.some(arg => arg === flag || arg.startsWith(`${flag}=`))
}

function safeNodeExecArgvForBackground(
execPath: string,
execArgv: string[],
processEnv: NodeJS.ProcessEnv,
): string[] {
const safeArgs = execArgv.filter(
arg =>
arg === '--expose-gc' ||
arg.startsWith('--max-old-space-size') ||
arg.startsWith('--heapsnapshot-near-heap-limit'),
)
if (!isNodeExecutable(execPath)) return safeArgs

const nodeOptions = (processEnv.NODE_OPTIONS ?? '')
.split(/\s+/)
.filter(Boolean)
const effectiveArgs = [...safeArgs, ...nodeOptions]
if (!hasNodeFlag(effectiveArgs, '--max-old-space-size')) {
const configuredHeap = Number.parseInt(processEnv[HEAP_SIZE_ENV] ?? '', 10)
const heapSize =
Number.isSafeInteger(configuredHeap) && configuredHeap > 0
? configuredHeap
: DEFAULT_HEAP_SIZE_MB
safeArgs.push(`--max-old-space-size=${heapSize}`)
}
if (!hasNodeFlag(effectiveArgs, '--expose-gc')) {
safeArgs.push('--expose-gc')
}
return safeArgs
}

export function buildBackgroundChildProcessConfig(
Expand All @@ -185,13 +223,24 @@ export function buildBackgroundChildProcessConfig(
...(input.sessionName
? { CLAUDE_CODE_SESSION_NAME: input.sessionName }
: {}),
[BACKGROUND_SESSION_ID_ENV]: input.backgroundSessionId,
[BACKGROUND_SESSION_LAUNCHER_PID_ENV]: String(
input.launcherPid ?? process.pid,
),
}
delete env[HEAP_RELAUNCHED_ENV]
// Keep the registered detached PID stable under every runtime. The installed
// launcher otherwise relaunches itself before finalizer ownership is checked.
// Node-only heap flags are still supplied by safeNodeExecArgvForBackground.
env[HEAP_RELAUNCHED_ENV] = '1'

return {
command: input.execPath,
args: [
...safeNodeExecArgvForBackground(input.execArgv),
...safeNodeExecArgvForBackground(
input.execPath,
input.execArgv,
input.processEnv,
),
input.entrypoint,
...input.childArgs,
],
Expand Down Expand Up @@ -882,6 +931,31 @@ export async function killBackgroundSession(
return await markKilled(session)
}

type ConfirmBackgroundSessionLaunchOptions = {
isProcessAlive?: (pid: number) => boolean
refreshStatuses?: () => Promise<BackgroundSession[]>
resolveSession?: (id: string) => Promise<BackgroundSession>
}

export async function confirmBackgroundSessionLaunch(
session: BackgroundSession,
options: ConfirmBackgroundSessionLaunchOptions = {},
): Promise<BackgroundSession> {
if ((options.isProcessAlive ?? isProcessRunning)(session.pid)) return session

await (options.refreshStatuses ?? refreshBackgroundSessionStatuses)()
const resolved = await (
options.resolveSession ?? resolveBackgroundSession
)(session.id)
if (resolved.status === 'stale') {
throw new Error(
`Background session ${session.id} exited before finalization was installed. ` +
`Logs were retained at ${session.stdoutLogPath} and ${session.stderrLogPath}.`,
)
}
return resolved
}
Comment thread
jatmn marked this conversation as resolved.

export async function psHandler(_args: string[]): Promise<void> {
const sessions = await refreshBackgroundSessionStatuses()
printSessionTable(sessions)
Expand Down Expand Up @@ -978,6 +1052,8 @@ export async function handleBgFlag(args: string[]): Promise<void> {
processEnv: process.env,
sessionName: parsed.name,
stdoutLogPath: logPaths.stdoutLogPath,
backgroundSessionId: id,
launcherPid: process.pid,
})

let stdoutFd: number | undefined
Expand Down Expand Up @@ -1023,7 +1099,7 @@ export async function handleBgFlag(args: string[]): Promise<void> {
}

const command = [childConfig.command, ...childConfig.args]
const session = await createBackgroundSession({
let session = await createBackgroundSession({
id,
name: parsed.name,
pid: child.pid,
Expand All @@ -1041,7 +1117,15 @@ export async function handleBgFlag(args: string[]): Promise<void> {
fail(errorMessage(error))
})

console.log(`Started background session ${session.id}.`)
session = await confirmBackgroundSessionLaunch(session).catch(error => {
fail(errorMessage(error))
})

console.log(
isTerminalBackgroundSession(session)
? `Background session ${session.id} finished with status ${session.status}.`
: `Started background session ${session.id}.`,
)
if (session.name) console.log(`Name: ${session.name}`)
console.log(`PID: ${session.pid}`)
console.log(`Logs: ${session.stdoutLogPath}`)
Expand Down
Loading
Loading